diff --git a/README.md b/README.md index c4df7d3..9a7d6a7 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ # Wownerogue -A browser-based roguelike synchronized with Monero (XMR) and Wownero (WOW) block timing. Built with Node.js/Express and Socket.IO for real-time play, optional crypto payments, and automated payouts. +A browser-based roguelike synchronized with Monero (XMR) and Wownero (WOW) block timing. The same codebase runs the original solo dungeon, the social Tavern, and operator-selected PvP/race rulesets, with separate free and paid leaderboards. ## Features -- **Provably fair** gaming with pre-game hash commitments +- **Provably fair** solo runs with two-party, pre-entry commitments and per-depth fingerprints - **Live spectator mode** - watch active games in real-time - **Persistent chat** with 30-day history - **Transaction history** - view payment and payout records - **Multiple game modes**: Free play, per-game payments, or credit bundles +- **Social Tavern** with chat, live solo spectating, and in-place race queues - **Real-time multiplayer races**: free, prestige-credit, or crypto winner-take-pot races every block - Configurable difficulty with house edge tuning - Automatic wallet RPC integration for payments and payouts @@ -30,13 +31,16 @@ Open http://localhost:3000 to play. ## Game Modes -| Mode | Cost | Payout | Description | +| Mode | Cost | Payout | Leaderboard | |------|------|--------|-------------| -| **FREE** | None | None | No payments required | -| **PAID_SINGLE** | 1 WOW/game | 2× escape, 3× with treasure | Per-run payment | -| **PAID_CREDITS** | 10 credits/5 WOW | Optional | Buy credit bundles | +| **FREE** | None | None | Pleb | +| **PAID_SINGLE** | Per-run chain payment | Profile-controlled | Paid prestige | +| **PAID_CREDITS** | Purchased credit | Profile-controlled | Paid prestige | -When both modes are enabled (`ALLOW_MIXED_MODE=true`), players with credits can start games instantly without the payment modal. +The shipped mainnet profile offers free plus paid-prestige play with every payout switch off. The XMR +stagenet profile offers the same modes with 2× escape and 3× treasure-escape test payouts. See +`src/.env.mainnet.example` and `src/.env.stagenet.example`; payout switches are independent from +payment intake and fail closed. ## Match Mode (Multiplayer Races) @@ -51,6 +55,10 @@ crypto block as long as at least two players are queued. Supported economies: See `docs/MATCH_MODE.md` for full architecture, configuration, and API details. +For deterministic multi-bot playtesting and 16:9/9:16 ad footage, use the loopback-only, +free-only capture harness documented in [`docs/PVP_CAPTURE.md`](docs/PVP_CAPTURE.md). It drives the +real match engine over Socket.IO but cannot connect to a deployment, database, wallet, or payout path. + ## Difficulty & Pacing Difficulty has two levers, both keyed to the crypto network so `cryptoType` actually shapes the @@ -95,10 +103,11 @@ disable with `NETWORK_TUNING_DISABLED=true`. The counts are sim-derived starting ### Provably Fair Gaming -1. Server generates a random seed and shows its SHA-256 hash before the game -2. The seed deterministically generates the dungeon layout and positions -3. After the game, the seed is revealed for verification -4. Players can verify `hash(seed) = commitment` to prove no manipulation +1. The server publishes a one-use, socket-bound SHA-256 commitment before paid entry. +2. The browser adds an independent WebCrypto client seed; neither side alone selects the final seed. +3. The effective seed deterministically generates each dungeon depth and its audit fingerprint. +4. After completion, the server seed and depth manifest are revealed at `/verify/:gameId` and + `/api/verify/:gameId`; active-game server seeds remain private. ### Spectator Mode @@ -127,13 +136,16 @@ A warning appears if localStorage is unavailable. | Endpoint | Description | |----------|-------------| -| `GET /health` | Server health, uptime, memory, wallet status | +| `GET /health` | Redacted dependency/readiness summary | +| `GET /health/live` | Process liveness probe | +| `GET /health/ready` | Dependency readiness probe (503 while degraded) | | `GET /api/game-modes` | Current modes and pricing | | `GET /api/user/:socketId/credits` | User credit balance | | `GET /api/user/:socketId/payments` | Payment history (paginated) | | `GET /api/user/:socketId/payouts` | Payout history (paginated) | | `POST /api/user/:socketId/address` | Set payout address | -| `GET /verify/:gameId` | Provably fair verification page | +| `GET /verify/:gameId` | Human-readable fairness verification page | +| `GET /api/verify/:gameId` | Machine-readable fairness proof | ### Admin Endpoints @@ -141,7 +153,7 @@ Require `X-Admin-Key` header matching `ADMIN_API_KEY` env variable. | Endpoint | Description | |----------|-------------| -| `POST /api/admin/refund/payment` | Refund a payment, deduct credits if applicable | +| `POST /api/admin/refund/payment` | Request an idempotent refund; consumed grants require review | | `POST /api/admin/credits/adjust` | Add or remove credits from a user | | `GET /api/admin/users/search` | Search users by socket ID or address | @@ -208,7 +220,7 @@ DIRECT_PAYOUT_TREASURE=3.0 # Wallet RPC (required for paid modes) PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570 WALLET_RPC_USER=user -WALLET_RPC_PASSWORD=password +WALLET_RPC_PASSWORD=use-a-distinct-strong-rpc-secret # Database DB_HOST=localhost diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d3cdb57..e4e6e38 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -62,17 +62,9 @@ block deadline, so the house edge is a simulation problem. `src/sim/` drives the headless bots; `NETWORK_TUNING` + multi-level depth are the calibrated levers. See [BALANCE_SIM.md](BALANCE_SIM.md) and [MULTI_LEVEL.md](MULTI_LEVEL.md). -## Regenerating the API reference (autodocs) - -The code is heavily JSDoc-commented. To regenerate a Markdown API reference from those comments: - -```bash -cd src && npm install # first time, pulls jsdoc-to-markdown (devDependency) -npm run docs:api # writes docs/API/*.md -``` - -Autodocs cover the **reference** layer (what each function/param is). The **why** — the seams and -flows in the pages above — is hand-authored and lives here in `docs/`. Keep both. +The code is heavily JSDoc-commented, while the hand-authored pages here explain subsystem seams and +operator intent. A docs-only generator is intentionally not part of the production dependency lock; +add one in a dedicated documentation toolchain if generated API pages become a release artifact. ## Conventions worth knowing diff --git a/docs/CLONE_MIGRATIONS.md b/docs/CLONE_MIGRATIONS.md new file mode 100644 index 0000000..173fe38 --- /dev/null +++ b/docs/CLONE_MIGRATIONS.md @@ -0,0 +1,79 @@ +# Disposable clone migration gate + +`npm run db:migrate:clone` applies the release's normal ordered migrations to an existing, +disposable PostgreSQL clone. It is the migration step for a restored production dump before an +immutable release is eligible for activation. It never creates, restores, drops, audits, or +activates a database. + +## Hard target contract + +The runner refuses to open a connection unless all of these conditions hold: + +- `DB_HOST` is explicit loopback (`127.0.0.1`, `::1`, or `localhost`) or the standard local Unix + socket directory `/run/postgresql` or `/var/run/postgresql`; +- `DB_NAME` starts with `monerogue_`, `wownerogue_`, or `wowngeon_` and contains a complete + underscore-delimited `clone`, `restore`, or `canary` token; +- the name contains no live marker beginning with `prd`, `prod`, `production`, `live`, `mainnet`, + `master`, `primary`, `current`, or `active`; +- `CLONE_MIGRATION_EXPECT_DATABASE` exactly equals `DB_NAME`; +- `CLONE_MIGRATION_CONFIRM` exactly equals `MIGRATE_DISPOSABLE_CLONE:`; +- `DB_PORT` and `DB_USER` are explicit, `DATABASE_URL` is absent, and the process receives no + command-line arguments. + +After connecting, the runner checks PostgreSQL's `current_database()` before any DDL. It then calls +the same `DatabaseManager.runMigrations()` used by application startup: lexical filename order, +the `schema_migrations` ledger, and one transaction per newly applied file. Finally, it requires the +ledger to equal the complete repository migration manifest, in order, with no missing or extra +entry. The manifest is discovered from the candidate release and must be contiguous from 001; the +current floor is migration 040. A successful current release reports: + +```text +clone_migration_status=ok +database=monerogue_restore_20260721t220048z_28d513b29b36 +migration_count=40 +latest_migration=040_paid_match_entropy_precommit.sql +``` + +## Invocation + +Create and restore the clone with the reviewed backup procedure first. Keep the target variables in +a protected operator-owned environment file and database credentials in a mode-0600 `PGPASSFILE`; +do not type a password, connection URL, or target as a command argument. For example, the inherited +non-secret target environment is: + +```dotenv +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_NAME=monerogue_restore_20260721t220048z_28d513b29b36 +DB_USER=monerogue +PGPASSFILE=/run/wowngeon/clone-migration.pgpass +CLONE_MIGRATION_EXPECT_DATABASE=monerogue_restore_20260721t220048z_28d513b29b36 +CLONE_MIGRATION_CONFIRM=MIGRATE_DISPOSABLE_CLONE:monerogue_restore_20260721t220048z_28d513b29b36 +``` + +Database names use lowercase letters, digits, and underscores only. Normalize a release ID into a +separate safe tag rather than copying a hyphenated artifact ID into `DB_NAME`. + +Load that protected environment through the reviewed service/wrapper, change into the extracted +candidate's `src` directory, and run only: + +```bash +npm run db:migrate:clone +``` + +Do not append `--` arguments. The runner never logs host, role, password, connection URL, or +`PGPASSFILE` contents. `DB_PASSWORD` is supported only for an already-protected inherited service +environment and is kept in memory; `PGPASSFILE` is preferred. + +## Required follow-on gates and limits + +Run `scripts/deploy/financial-audit.sql` and the restored-data constraint validation gate against +the same clone after migration. Compare the result with the pre-migration audit, then test the +candidate and predecessor against the migrated clone as required by the fleet runbook. + +This runner does not prove that a database with a disposable-looking name is operationally +disposable, does not prevent another application process from connecting, and does not acquire an +activation-wide drain lock. The operator must create the clone from the intended fresh dump, keep +it outside public service configuration, and destroy it separately after the release decision. A +successful result authorizes only the next audit/test gate; it does not authorize a symlink switch, +wallet action, payout, or traffic restoration. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 44f3003..d09cfcc 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -20,7 +20,10 @@ This guide covers deploying Wownerogue to a production environment. - [ ] Set secure database credentials - [ ] Configure wallet-rpc with authentication - [ ] Set `ADMIN_API_KEY` for admin endpoints -- [ ] Firewall: expose only 80/443 +- [ ] Run `npm run preflight` with the final environment +- [ ] Keep `PAYOUTS_ENABLED=false` on real-money prestige instances +- [ ] Complete a stagenet payment and payout before enabling a payout instance +- [ ] Firewall: allow Node 3000/3001 only from the reverse proxy; keep wallet/daemon RPC loopback-only - [ ] Protect `/admin.html` with basic auth - [ ] Set up database backups (see [LOGS_AND_BACKUP.md](./LOGS_AND_BACKUP.md)) - [ ] Configure log rotation @@ -30,71 +33,35 @@ This guide covers deploying Wownerogue to a production environment. ## Create Dedicated User -Create an isolated system user for security: +Create an isolated, non-login service identity. Release directories and the `current` link remain +root-owned; the service identity receives read/traverse access only and must never own its code: ```bash -# Create system user with no login shell and dedicated home -sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/www/wownerogue --create-home wownerogue - -# Create .ssh directory for deploy key -sudo mkdir -p /var/www/wownerogue/.ssh -sudo chmod 700 /var/www/wownerogue/.ssh -sudo chown wownerogue:wownerogue /var/www/wownerogue/.ssh +sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/www/wownerogue --no-create-home wownerogue +sudo install -d -m 0750 -o root -g wownerogue /var/www/wownerogue +sudo install -d -m 0750 -o root -g wownerogue /var/www/wownerogue/releases +sudo install -d -m 0750 -o root -g wownerogue /etc/wownerogue # Create log directory -sudo mkdir -p /var/log/wownerogue -sudo chown wownerogue:wownerogue /var/log/wownerogue +sudo install -d -m 0750 -o wownerogue -g wownerogue /var/log/wownerogue ``` --- -## Setup Deploy Key and Clone Repository +## Stage reviewed artifacts, not a repository clone -Generate an SSH deploy key for the wownerogue user: +Build the runtime artifact from a clean, reviewed commit as described in +[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). The production host needs no Git checkout, deploy key, +or write-capable source identity. The reviewed fleet +`playbooks/wowngeon-stage-candidate.yml` role transfers exactly one tarball to a newly selected +root-owned mode-0700 candidate inbox and compares its remote SHA-256 with the independently +recorded literal digest. Follow the fleet Wowngeon runbook; the role is a one-shot custody boundary, +not deployment. -```bash -# Generate deploy key (as root, since user has no shell) -sudo ssh-keygen -t ed25519 -C "wownerogue-deploy" -f /var/www/wownerogue/.ssh/id_ed25519 -N "" - -# Set correct ownership and permissions -sudo chown wownerogue:wownerogue /var/www/wownerogue/.ssh/id_ed25519* -sudo chmod 600 /var/www/wownerogue/.ssh/id_ed25519 -sudo chmod 644 /var/www/wownerogue/.ssh/id_ed25519.pub - -# Display the public key -sudo cat /var/www/wownerogue/.ssh/id_ed25519.pub -``` - -Add the deploy key to GitHub: -1. Go to your repository → **Settings** → **Deploy keys** -2. Click **Add deploy key** -3. Paste the public key from the command above -4. Leave "Allow write access" unchecked (read-only is sufficient) -5. Click **Add key** - -Configure SSH for GitHub: - -```bash -# Create SSH config for the wownerogue user -sudo tee /var/www/wownerogue/.ssh/config << 'EOF' -Host github.com - HostName github.com - User git - IdentityFile ~/.ssh/id_ed25519 - IdentitiesOnly yes - StrictHostKeyChecking accept-new -EOF - -sudo chown wownerogue:wownerogue /var/www/wownerogue/.ssh/config -sudo chmod 600 /var/www/wownerogue/.ssh/config -``` - -Clone the repository: - -```bash -# Clone as the wownerogue user (replace YOUR_USERNAME with your GitHub username) -sudo -u wownerogue git clone git@github.com:YOUR_USERNAME/wownerogue.git /var/www/wownerogue/app -``` +The current fleet repository deliberately has no executable extraction/activation helper yet. +Until that helper exists, the safe host boundary is blob-only staging: do not manually +extract beneath `releases/`, install dependencies there, change `current`, restart an application, +or run migrations merely because the blob has arrived. --- @@ -112,77 +79,62 @@ node --version npm --version ``` -Install dependencies and audit for vulnerabilities: - -```bash -cd /var/www/wownerogue/app/src -sudo -u wownerogue npm install - -# Check for known vulnerabilities -sudo -u wownerogue npm audit - -# Auto-fix vulnerabilities where possible -sudo -u wownerogue npm audit fix -``` +The eventual reviewed extraction/activation helper installs exactly the locked production graph in its private, +writable staging directory with `npm ci --omit=dev --ignore-scripts --no-audit --no-fund`, verifies +it with `npm ls --omit=dev`, and seals the finished release root-owned and non-writable before it can +be selected. Never make a release tree writable by `wownerogue`. Online advisory lookup is a +separate explicitly authorized source-review operation; never run `npm audit fix` in staging or on +a live release. --- ## Set File Permissions ```bash -# Set ownership of application files -sudo chown -R wownerogue:wownerogue /var/www/wownerogue - -# Restrict permissions (owner only, no world access) -sudo chmod 750 /var/www/wownerogue -sudo chmod 640 /var/www/wownerogue/app/src/.env +# Releases and selector are immutable to the service identity. +sudo chown root:wownerogue /var/www/wownerogue /var/www/wownerogue/releases +sudo chmod 0750 /var/www/wownerogue /var/www/wownerogue/releases +sudo chmod 0640 /etc/wownerogue/app.env ``` -**Database permissions** - create a restricted PostgreSQL role: +Do not use recursive ownership changes on `/var/www/wownerogue`; they can hand the service process +write access to immutable code or the rollback selector. The reviewed extraction/activation helper must verify +every individual release's ownership and modes before activation. + +**Database ownership** - the application currently runs its own migrations at startup, so its +database role must own the database/schema (or otherwise retain DDL rights). Create it this way: ```sql -- Create role with minimal privileges CREATE USER wownerogue WITH PASSWORD 'secure-password-here'; CREATE DATABASE wownerogue OWNER wownerogue; - --- Revoke dangerous permissions -REVOKE CREATE ON SCHEMA public FROM wownerogue; - --- Grant only what's needed -GRANT CONNECT ON DATABASE wownerogue TO wownerogue; -GRANT USAGE ON SCHEMA public TO wownerogue; -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO wownerogue; -GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO wownerogue; ``` -**Filesystem isolation** (optional, for extra hardening): - -```bash -# Prevent the user from accessing other users' home directories -sudo chmod 700 /home/* - -# Mount application directory with noexec for uploads (if applicable) -# Add to /etc/fstab if using separate partition -``` - ---- +Do not revoke schema `CREATE` from this role while application startup owns migration execution; +that makes a fresh release fail partway through startup. A separate one-shot migrator role is the +follow-up path if runtime DDL privileges need to be removed. ## Database Setup ### Run Migrations -Database migrations run automatically on server start (via `databaseManager.runMigrations()`), but you can also run them manually: +Database migrations run automatically, in filename order, inside one transaction per file and are +recorded in `schema_migrations`. Do not apply migration files directly with `psql`: bypassing the +ledger makes startup attempt them again. Before a release, take a restricted PostgreSQL backup and +test the complete migration set against a restored copy of each production database using the +fail-closed [disposable clone migration gate](CLONE_MIGRATIONS.md). -```bash -cd /var/www/wownerogue/app/src +The migration ledger does not prove that rows predating a `NOT VALID` constraint are clean. Run +the read-only historical audit and the rollback-only native validation proof described in +[`FINANCIAL_CONSTRAINT_VALIDATION.md`](FINANCIAL_CONSTRAINT_VALIDATION.md) for each restored +database. The native `VALIDATE CONSTRAINT` gate deliberately refuses live database names. -# Source environment variables -source .env - -# Run migrations manually if needed -psql -h $DB_HOST -U $DB_USER -d $DB_NAME -f migrations/001_initial_schema.sql -# ... repeat for each migration file in order -``` +Install the reviewed `wowngeon-db-backup.sh`, `.service`, and `.timer` through the hash-pinned fleet +operations change—not from the runtime artifact—and create +`/var/backups/wowngeon/daily` as `postgres:postgres` mode `0700`, then enable the timer. Each run +writes custom-format dumps atomically, verifies their catalogs with `pg_restore --list`, and records +a SHA-256 sidecar. Configure retention/remote replication according to the operator's recovery +policy; the supplied job deliberately does not delete backups. ### Database Reset (Development Only) @@ -195,41 +147,21 @@ npm run db:create # Recreate schema ## systemd Service -Save to `/etc/systemd/system/wownerogue.service`: - -```ini -[Unit] -Description=Wownerogue Game Server -After=network.target postgresql.service - -[Service] -Type=simple -User=wownerogue -Group=wownerogue -# Adjust paths if you cloned to a different location -WorkingDirectory=/var/www/wownerogue/app/src -EnvironmentFile=/var/www/wownerogue/app/src/.env -ExecStart=/usr/bin/node index.js -Restart=on-failure -RestartSec=10 - -# Security hardening -NoNewPrivileges=yes -ProtectSystem=strict -ProtectHome=yes -PrivateTmp=yes -ReadWritePaths=/var/www/wownerogue /var/log/wownerogue - -[Install] -WantedBy=multi-user.target -``` +Install reviewed application/firewall templates through the hash-pinned fleet operations change; +the runtime artifact intentionally has no service units. Wallet-RPC replacement remains subject to +the explicit NO-GO gates in [DEPLOY_INSTANCES.md](DEPLOY_INSTANCES.md). Application units read secrets from +`/etc/wownerogue/app.env` or `/etc/monerogue/app.env`, not from an immutable release directory. +They run `npm run preflight` before every start and use `/var/www//current/src` so rollback +is an atomic symlink switch. Enable and start: ```bash -# Create .env from example first! -sudo -u wownerogue cp /var/www/wownerogue/app/src/.env.example /var/www/wownerogue/app/src/.env -sudo vim /var/www/wownerogue/app/src/.env # Edit with your settings +# Install the correct profile outside the release and replace every placeholder. +sudo install -d -m 0750 -o root -g wownerogue /etc/wownerogue +# Use the separately reviewed source/fleet copy; environment examples are not in the runtime archive. +sudo install -m 0640 -o root -g wownerogue /src/.env.mainnet.example /etc/wownerogue/app.env +sudoedit /etc/wownerogue/app.env # Enable and start service sudo systemctl daemon-reload @@ -243,7 +175,7 @@ The systemd hardening options: - `ProtectSystem=strict` - mounts filesystem read-only except allowed paths - `ProtectHome=yes` - hides /home, /root, /run/user - `PrivateTmp=yes` - isolates /tmp -- `ReadWritePaths` - whitelists writable directories +- `ReadWritePaths` - whitelists the wallet state directory in wallet-RPC units --- @@ -258,6 +190,10 @@ If using Nginx Proxy Manager: - Forward Port: `3000` - Enable "Websockets Support" toggle +Record the proxy's fixed source address in `/etc/wowngeon/firewall.env` as +`NPM_SOURCE_IPV4=
`. Direct access to 3000/3001 must be rejected; this also makes the +single-hop `TRUST_PROXY_HOPS=1` setting safe. + 2. **SSL Tab** - Request a new SSL certificate - Enable "Force SSL" @@ -281,13 +217,15 @@ location /socket.io/ { ## Updating the Deployment -```bash -cd /var/www/wownerogue/app -sudo -u wownerogue git pull -cd src && sudo -u wownerogue npm install -sudo -u wownerogue npm audit fix -sudo systemctl restart wownerogue -``` +Run the full suite from the clean source commit, then build and verify the runtime artifact using +[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). Today, use the fleet blob-staging playbook and stop at +the independently hash-verified root-owned candidate blob because the reviewed extraction/activation +helper does not yet exist. When that helper implements the documented contract, it—not an application service identity or a +manual shell session—will build a new immutable `/var/www//releases/`, install +the locked graph, run the clone/preflight gates, seal ownership, and atomically select `current`. +Never run `git pull`, `npm install`, or `npm audit fix` inside the active release. Keep the previous +release and database backup until the new version has passed public health, WebSocket, +payment-intake, and (stagenet only) payout smoke tests. --- @@ -299,10 +237,11 @@ To run multiple instances (e.g., Wownero on port 3000 and Monero stagenet on por ``` /var/www/ -├── wownerogue/ # Wownero instance (port 3000) -│ └── app/src/.env -└── monerogue/ # Monero instance (port 3001) - └── app/src/.env +├── wownerogue/ # releases/ + current symlink (port 3000) +└── monerogue/ # releases/ + current symlink (port 3001) +/etc/ +├── wownerogue/app.env # root:wownerogue, 0640 +└── monerogue/app.env # root:monerogue, 0640 ``` ### Configuration Differences @@ -312,31 +251,15 @@ To run multiple instances (e.g., Wownero on port 3000 and Monero stagenet on por | `PORT` | 3000 | 3001 | | `CRYPTO_TYPE` | WOW | XMR | | `MONERO_NETWORK` | mainnet | stagenet | -| `DB_NAME` | wownerogue | monerogue_stagenet | -| `PRIMARY_WALLET_ENDPOINT` | http://127.0.0.1:34570 | http://127.0.0.1:38070 | +| `DB_NAME` | wownerogue | monerogue | +| `PRIMARY_WALLET_ENDPOINT` | http://127.0.0.1:34570 | http://127.0.0.1:38083 | | `PRIMARY_RPC_ENDPOINT` | http://127.0.0.1:34568 | http://127.0.0.1:38081 | ### Separate systemd Service -Create `/etc/systemd/system/monerogue.service`: - -```ini -[Unit] -Description=Monerogue Game Server (Stagenet) -After=network.target postgresql.service - -[Service] -Type=simple -User=monerogue -WorkingDirectory=/var/www/monerogue/app/src -EnvironmentFile=/var/www/monerogue/app/src/.env -ExecStart=/usr/bin/node index.js -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=multi-user.target -``` +Install the hash-pinned fleet copy of `monerogue.service`; it is intentionally absent from the +runtime artifact. Do not hand-maintain a second, weaker unit. The reviewed template contains the +same preflight, graceful-stop, immutable-release path, and systemd sandbox as the mainnet service. --- @@ -374,10 +297,13 @@ journalctl -u wownerogue -f # Follow logs ### Health Endpoint ```bash -curl http://localhost:3000/health +curl http://localhost:3000/health/live +curl --fail http://localhost:3000/health/ready ``` -Returns server uptime, memory usage, wallet status, and balance. +`/health/live` checks the process. `/health/ready` returns 503 until PostgreSQL, the chain daemon, +and (for paid instances) wallet RPC are ready. Public probes do not expose wallet balances or RPC +endpoints; those remain in the authenticated admin dashboard. ### Admin Dashboard @@ -397,8 +323,9 @@ Access `/admin.html` with your `ADMIN_API_KEY` for: # Check logs for errors journalctl -u wownerogue -n 50 -# Verify environment file -sudo -u wownerogue cat /var/www/wownerogue/app/src/.env +# Validate the protected environment without printing it +cd /var/www/wownerogue/current/src +sudo -u wownerogue bash -c 'set -a; . /etc/wownerogue/app.env; set +a; npm run preflight' # Test database connection psql -h localhost -U wownerogue -d wownerogue -c "SELECT 1;" @@ -408,17 +335,17 @@ psql -h localhost -U wownerogue -d wownerogue -c "SELECT 1;" ```bash # Test wallet RPC directly -curl -u user:password -X POST http://127.0.0.1:34570/json_rpc \ +curl --digest -u user:password -X POST http://127.0.0.1:34570/json_rpc \ -d '{"jsonrpc":"2.0","id":"0","method":"get_balance"}' \ -H 'Content-Type: application/json' ``` ### Database Migrations -If migrations fail, check the `migrations` table: +If migrations fail, check the `schema_migrations` ledger: ```sql -SELECT * FROM migrations ORDER BY id; +SELECT filename, applied_at FROM schema_migrations ORDER BY filename; ``` Migrations are tracked by name to prevent duplicate execution. diff --git a/docs/DEPLOY_INSTANCES.md b/docs/DEPLOY_INSTANCES.md index d7051b0..01effc6 100644 --- a/docs/DEPLOY_INSTANCES.md +++ b/docs/DEPLOY_INSTANCES.md @@ -5,38 +5,40 @@ Two independent instances of the same secured codebase, behind Nginx Proxy Manag | Instance | Domain | Port | Network | Money | Payouts | Leaderboards | |----------|--------|------|---------|-------|---------|--------------| | **wownerogue** (existing) | play.wowne.ro | 3000 | Wownero **mainnet** | real WOW | **OFF** | Pleb + Hall of Champions | -| **monerogue** (new) | monerogue.app | 3001 | Monero **stagenet** | fake XMR | **ON** | Pleb + Hall of Champions | +| **monerogue** (new) | monerogue.app | 3001 | Monero **stagenet** | fake XMR | **ON (target; wallet-gated)** | Pleb + Hall of Champions | + +Stagenet payouts are a technical test surface, not authorization to offer real-money wagering. +`ALLOW_MAINNET_PAYOUTS=true` is only an engineering interlock; obtain jurisdiction-specific legal, +age/access-control, responsible-play, and operational review before attaching redeemable value. NPM already forwards both domains to these ports + handles TLS — no reverse-proxy work here. -> ⚠️ Nothing below has been executed. Run only after review. Step 0 is the urgent one -> (the live mainnet instance still runs pre-fix code with the underpayment exploit + payouts). - -Server facts (verified): root+sudo; `monerod --stagenet` RPC on `127.0.0.1:38081`; -`monero-wallet-rpc` at `/usr/bin`; stagenet wallet `…/monero-…-v0.18.4.4/test(.keys)`; -Postgres has `wownerogue`,`wowrace`; ports `3001` and `38083` free; code at -`/var/www/wownerogue/app` on branch `main` (commit 82fdcc0). +> ⚠️ This is an operator runbook, not a record of actions already executed. Deploy only a reviewed, +> tagged release after the full test suite and both preflights pass. --- -## Step 0 — Get the secured branch onto the server +## Step 0 — Stage an immutable reviewed release -```bash -# Local: push the remediation branch (or merge to main first) -git push origin security/phase-0-critical-fixes +Build and verify one runtime artifact using [RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md), then +transfer only that blob into a new root-owned mode-0700 candidate inbox. Compare the host's SHA-256 +with the independently recorded literal digest; the transferred sidecar is not an independent +provenance source. -# Server (mainnet instance): -sudo -u wownerogue git -C /var/www/wownerogue/app fetch origin -sudo -u wownerogue git -C /var/www/wownerogue/app checkout security/phase-0-critical-fixes -cd /var/www/wownerogue/app/src && sudo -u wownerogue npm ci -# Migrations run automatically on boot; or apply manually with the app's runner. -``` +There is currently no reviewed fleet extraction/activation helper. Blob-only staging is allowed; +manual extraction under `/var/www//releases`, dependency installation, clone migration, +preflight, service restart, and `current` changes remain blocked until that helper implements the +complete ownership/path/hash/rollback contract. Never make a release directory writable by either +application service identity. --- ## Step 1 — Reconfigure the MAINNET instance: free-to-play, NO payouts -Edit `/var/www/wownerogue/app/src/.env` — change these keys (keep everything else): +Install the reviewed source profile `src/.env.mainnet.example` as `/etc/wownerogue/app.env` (mode +0640, `root:wownerogue`), replace every placeholder, and review these money gates. Environment +examples are intentionally not present in the runtime artifact; provision this file through the +hash-pinned fleet/source change, never from the application release directory. ```ini FREE_PLAY_ENABLED=true # offer free play as a choice (Pleb board) @@ -45,11 +47,16 @@ DIRECT_PAYOUTS_ENABLED=false CREDITS_PAYOUTS_ENABLED=false DIRECT_REQUIRES_ADDRESS=false # no payout = no payout address needed CREDITS_REQUIRES_ADDRESS=false -TRUST_PROXY=true # we are behind NPM; read real client IP from X-Forwarded-For +TRUST_PROXY=true +TRUST_PROXY_HOPS=1 # exactly one NPM hop ``` Players still buy credits / pay entry (→ Hall of Champions prestige); free players → Pleb -board. No payouts = not gambling. +board. This profile sends no winnings, but legal classification still depends on the product, +jurisdiction, and access controls; obtain counsel before treating that as a legal conclusion. + +The following are post-activation verification commands, not authorization to restart the live +service while the wallet/preflight and fleet activation gates remain open: ```bash sudo systemctl restart wownerogue.service @@ -57,46 +64,60 @@ journalctl -u wownerogue.service -n 50 --no-pager # verify: "Payment system: E curl -s localhost:3000/api/game-modes | jq '{freePlayEnabled, PAID_SINGLE:.PAID_SINGLE.payoutMultiplier}' ``` +**MAINNET WALLET NO-GO:** this application release does not authorize changing, copying, stopping, +or replacing the Wownero wallet, its key file, password, unit, or RPC authentication. Keep payouts +off. A later wallet change requires independently recoverable keyfile/password and mnemonic custody, +an off-host recovery proof, an isolated rehearsal, exact address/balance verification, and a +separately reviewed maintenance/rollback plan. Root-level wallet templates are operations source, +not files in the runtime artifact and not approval to install them. + --- -## Step 2 — Stand up the MONERO STAGENET wallet-rpc +## Step 2 — MONERO STAGENET wallet boundary: NO-GO + +Do **not** install a replacement unit, create wallet identities/directories, copy wallet or key +files, capture a password, provision RPC credentials, stop/restart the wallet, or change the live +application endpoint as part of this release. The current wallet, unit, and application dependency +remain unchanged. + +The only retained design is +[`README-monero-stagenet-parallel-candidate.md`](../scripts/deploy/README-monero-stagenet-parallel-candidate.md). +It currently approves only a disposable synthetic fixture and, after independent review, a fixed +read-only inventory preflight. Even its manual candidate service is not approved for installation. +Before any host mutation, its complete crash/failure-injection gates, stopped-copy restart proof, +offline address match, keyfile/password restore, mnemonic/off-host custody, and isolated unfunded +rehearsal must all pass against an immutable reviewed hash. + +Until a separate wallet promotion is approved, a stagenet application candidate must use an +isolated database with payments, payouts, and match money disabled. Paid intake and the payout +canary remain blocked; do not bypass application preflight to make them start. + +## Step 3 — Create the monerogue identity, immutable layout, and DB ```bash -sudo cp scripts/deploy/monero-wallet-rpc.service /etc/systemd/system/ -sudoedit /etc/systemd/system/monero-wallet-rpc.service # set REPLACE_WITH_WALLET_PASSWORD -sudo systemctl daemon-reload -sudo systemctl enable --now monero-wallet-rpc.service -sudo systemctl status monero-wallet-rpc.service --no-pager -# sanity: wallet answers + is the stagenet wallet that holds the coins -curl -s http://127.0.0.1:38083/json_rpc -d '{"jsonrpc":"2.0","id":"0","method":"get_balance"}' -H 'Content-Type: application/json' +sudo useradd --system --home /var/www/monerogue --no-create-home --shell /usr/sbin/nologin monerogue +sudo install -d -m 0750 -o root -g monerogue /var/www/monerogue +sudo install -d -m 0750 -o root -g monerogue /var/www/monerogue/releases +sudo install -d -m 0750 -o root -g monerogue /etc/monerogue + +sudo -u postgres createuser --pwprompt monerogue +sudo -u postgres createdb --owner=monerogue --template=template0 monerogue ``` -> Needs the wallet password (not in the repo). Confirm `…/v0.18.4.4/test` is the stagenet -> wallet with the coins, or point `--wallet-file` at the right one. - -## Step 3 — Create the monerogue user, code checkout, and DB - -```bash -sudo useradd --system --home /var/www/monerogue --shell /usr/sbin/nologin monerogue -sudo mkdir -p /var/www/monerogue && sudo chown monerogue:monerogue /var/www/monerogue -sudo -u monerogue git clone git@github.com:jwinterm/wownerogue.git /var/www/monerogue/app -sudo -u monerogue git -C /var/www/monerogue/app checkout security/phase-0-critical-fixes -cd /var/www/monerogue/app/src && sudo -u monerogue npm ci - -sudo -u postgres createdb monerogue -sudo -u postgres psql -c "CREATE USER monerogue WITH PASSWORD 'CHANGE_ME';" -sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE monerogue TO monerogue;" -``` +The service identity owns its database role, not its application files. Do not clone source onto +the production host or recursively `chown` `/var/www/monerogue` to the service user. ## Step 4 — The monerogue `.env` -Write `/var/www/monerogue/app/src/.env` (owned by `monerogue`, `chmod 600`). Stagenet/fake -money, so payouts are fine. Amounts are XMR atomic units (1 XMR = 1e12): +This is the eventual paid stagenet profile, not permission to activate it while Step 2 is NO-GO. +Write `/etc/monerogue/app.env` as `root:monerogue` mode `0640`. Amounts are XMR atomic units +(1 XMR = 1e12): ```ini NODE_ENV=production PORT=3001 TRUST_PROXY=true +TRUST_PROXY_HOPS=1 CRYPTO_TYPE=XMR MONERO_NETWORK=stagenet HOSTED_BY=https://monerogue.app @@ -127,19 +148,19 @@ CREDITS_PACKAGES=[{"id":"small","credits":10,"price":"90000000000","bonus":0},{" ALLOW_MIXED_MODE=true PREFER_CREDITS_FIRST=true -# --- payouts --- (bankroll: ~0.3797 sXMR in the "test" wallet) +# --- payouts --- (verify the unlocked stagenet bankroll before every rollout) PAYOUTS_ENABLED=true PAYOUT_MIN_AMOUNT=1000000000 # 0.001 XMR PAYOUT_MAX_PER_GAME=50000000000 # 0.05 XMR safety cap (real max is 3x*0.01 = 0.03) PAYOUT_BATCH_INTERVAL=300 PAYOUT_MAX_RETRIES=3 -LOW_BALANCE_THRESHOLD=50000000000 # alert/halt when unlocked balance drops below 0.05 XMR +BALANCE_WARN=100000000000 # warn below 0.10 XMR unlocked +BALANCE_CRITICAL=50000000000 # stop accepting new payout liability below 0.05 XMR # Tip: lower DIRECT_GAME_PRICE/CREDITS_PAYOUT_BASE to 0.001 XMR (1000000000) for many more # demo plays per the small bankroll; refill from a stagenet faucet as needed. # --- wallet RPC (monero stagenet) --- PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:38083 -# wallet-rpc started with --disable-rpc-login, so no WALLET_RPC_USER/PASSWORD # --- daemon RPC (monerod stagenet) --- PRIMARY_RPC_ENDPOINT=http://127.0.0.1:38081 @@ -160,33 +181,49 @@ ADMIN_API_KEY=GENERATE_WITH_openssl_rand_hex_32 SMIRK_ENABLED=false ``` +The application unit separately requires `/etc/monerogue/wallet-rpc.env`, owned +`root:monerogue` mode `0640`, containing only `WALLET_RPC_USER` and `WALLET_RPC_PASSWORD`. Provision +it through the approved secrets workflow only after the wallet gate clears. Never place either +password in a command argument, unit `ExecStart`, shell history, or this runbook. + +Production startup proves that wallet RPC actually enforces Digest authentication: an intentional +unauthenticated, read-only probe must receive a usable `401` Digest challenge before the +authenticated identity probe is accepted. Merely setting application credentials is insufficient; +a wallet RPC that still accepts the unauthenticated request makes preflight/readiness fail closed. + ```bash -sudo chmod 600 /var/www/monerogue/app/src/.env -sudo chown monerogue:monerogue /var/www/monerogue/app/src/.env +sudo chmod 0640 /etc/monerogue/app.env +sudo chown root:monerogue /etc/monerogue/app.env +cd /var/www/monerogue/releases//src +sudo -u monerogue bash -c 'set -a; . /etc/monerogue/app.env; . /etc/monerogue/wallet-rpc.env; set +a; npm run preflight' ``` ## Step 5 — Install + start the monerogue instance -```bash -sudo cp scripts/deploy/monerogue.service /etc/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable --now monerogue.service -journalctl -u monerogue.service -n 60 --no-pager # expect: listening on :3001, payments ENABLED -curl -s localhost:3001/health | jq '{network:.network, wallet:.wallet.status, mode:.gameMode}' -``` +The runtime artifact contains no service units. Install or update `monerogue.service` only through +the separately reviewed, hash-pinned `~/src/such-fleet` change after the wallet and application +gates pass. Do not copy a unit from the application release or start this paid profile while Step 2 +is NO-GO. + +Install the hash-pinned fleet copies of `wowngeon-firewall.sh` and +`wowngeon-firewall.service`, then set the fixed proxy source as `NPM_SOURCE_IPV4=
` in +`/etc/wowngeon/firewall.env`. Verify direct access to ports 3000/3001 is rejected while both +domains, websocket upgrades, and local readiness probes still work. ## Step 6 — Verify end to end - `monerogue.app` loads; `play.wowne.ro` still healthy. -- Stagenet: buy credits / pay entry with a stagenet wallet → game starts → win → payout - arrives (this is the real `wownero-wallet-rpc`/`monero-wallet-rpc` integration test that - TODO.md has always wanted — now on fake money). -- Both boards populate: free game → Pleb, paid game → Hall of Champions, on each instance. +- After a separate wallet GO and isolated canary: buy credits / pay entry with a stagenet wallet → + game starts → win → payout arrives. This check is blocked while Step 2 is NO-GO. +- After the corresponding gates clear, both boards populate: free game → Pleb, paid game → Hall of + Champions, on each instance. - Mainnet: confirm a winning paid game does NOT send a payout (no crypto out). --- ## Rollback -- `sudo systemctl stop monerogue monero-wallet-rpc` (stagenet instance is independent; stopping it can't affect mainnet). -- Mainnet: `git checkout main && systemctl restart wownerogue` to revert the reconfig. +- Stop only the affected instance, atomically repoint `/var/www//current` to the previous + immutable release, and restart it. Keep the pre-deploy database backup for an operator-reviewed + restore if a data rollback is required. +- Never overwrite either legacy dirty checkout; preserve it until the new release has passed its soak. diff --git a/docs/FINANCIAL_CONSTRAINT_VALIDATION.md b/docs/FINANCIAL_CONSTRAINT_VALIDATION.md new file mode 100644 index 0000000..b6f5d1e --- /dev/null +++ b/docs/FINANCIAL_CONSTRAINT_VALIDATION.md @@ -0,0 +1,69 @@ +# Historical financial constraint release gate + +Several migrations add `CHECK ... NOT VALID`. PostgreSQL enforces those constraints for new and +changed rows, but it does not scan rows that existed before the constraint was added. A migration +even a current migration ledger therefore does not, by itself, prove that all historical financial rows obey +the current constraints. + +Two repository-owned gates close that evidence gap without changing a live database. + +## 1. Read-only historical audit + +`scripts/deploy/financial-constraint-audit.sql` checks the exact connected database name, verifies +the complete 18-constraint catalog inventory, and reports a named count for every historical +predicate. It uses one repeatable-read, read-only transaction and rolls it back. Connection and +credential handling remain operator-specific; do not put a database password in command history. + +```bash +psql -X -v ON_ERROR_STOP=1 \ + --set=expected_database=EXACT_DATABASE_NAME \ + --file=scripts/deploy/financial-constraint-audit.sql +``` + +Exit status `0` means all required constraints exist and every old row satisfies them. Any missing +constraint or nonzero category exits `3`. Reconcile violations with a separately reviewed, +backup-backed repair; this audit intentionally has no repair mode. + +This check may be run read-only against production and should also run against every restored +release candidate database. It reports how many constraints remain catalog-marked unvalidated; +that count is informational because the second gate proves PostgreSQL's validator itself. + +## 2. Explicit validation on a disposable restore + +`scripts/deploy/financial-constraint-validate-restore.sql` includes the read-only audit, then issues +an explicit `ALTER TABLE ... VALIDATE CONSTRAINT` for all 18 constraints inside a transaction. It +checks `pg_constraint.convalidated` and rolls the entire transaction back, so no catalog flag is +durably changed. + +`VALIDATE CONSTRAINT` still scans tables, takes locks, and transiently updates PostgreSQL catalogs. +For that reason this script fails before `BEGIN` unless all three conditions hold: + +- the connected name exactly equals `expected_database`; +- the database name contains `restore` or `scratch`; +- the exact disposable-restore confirmation is supplied. + +Run it only against a freshly restored, isolated database with no application traffic: + +```bash +psql -X -v ON_ERROR_STOP=1 \ + --set=expected_database=monerogue_release_restore \ + --set=confirm_disposable=I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE \ + --file=scripts/deploy/financial-constraint-validate-restore.sql +``` + +A pass proves both the explicit historical predicates and PostgreSQL's native validation path on +that restored data. It does not mark live constraints validated. A later durable live +`VALIDATE CONSTRAINT` operation requires its own reviewed maintenance plan, backup/restore proof, +lock budget, and rollback decision; do not fold that mutation into application startup. + +## Static coverage + +The focused test inventories every migration occurrence of `NOT VALID` and fails if either gate +omits a constraint: + +```bash +npm --prefix src test -- --runTestsByPath ../test/financialConstraintValidation.test.js +``` + +Adding another `NOT VALID` constraint requires adding its exact historical predicate, its explicit +restore validation statement, and its table/name entry to both gates. diff --git a/docs/FINANCIAL_RECONCILIATION.md b/docs/FINANCIAL_RECONCILIATION.md new file mode 100644 index 0000000..4220f97 --- /dev/null +++ b/docs/FINANCIAL_RECONCILIATION.md @@ -0,0 +1,42 @@ +# Financial reconciliation + +The admin dashboard has separate **Payouts** and **Refunds** views. Start with the +**Needs Attention** filter and compare the row, application logs, and wallet history. +The overview and each list show `processing` and `needs_review` counts; these are +liabilities, not failed transfers that can safely be resent. + +## State rules + +- A payout in `processing` or `needs_review` may already have broadcast. Never use the + retry action for it. +- The payout retry action is available only for a hashless `failed` or + `permanently_failed` row. Use it only after proving the recorded error happened before + broadcast and confirming that wallet history contains no matching transfer. The UI + sends the API's required explicit confirmation. +- A refund in `requested` has not yet been claimed by the refund worker. A refund in + `processing` may have broadcast. A refund in `needs_review` is quarantined. There is no + dashboard resend action for any refund state. +- `recorded` means entitlements/payment state was reversed without an on-chain transfer; + `completed` requires transaction-hash evidence. + +## Reconciliation checklist + +1. Preserve the database row, service logs, wallet files, and current backups before + changing anything. +2. Match currency/network, destination, exact atomic amount, creation/processing time, + transaction hash (if present), and wallet outgoing-transfer history. +3. If a matching wallet transfer exists, record its hash and confirmation evidence in an + audited remediation. Do not create a replacement transfer. +4. If no transfer appears, treat that as inconclusive until the wallet is synchronized and + the relevant time window and mempool are checked. Escalate ambiguous cases instead of + retrying. +5. Re-run the read-only financial audit after any reviewed database remediation and retain + the before/after evidence with the incident record. + +Periodic alerts use the same aggregate states. `FINANCIAL_REVIEW_STALE_MS` controls when a +payout `processing` row or refund `requested`/`processing` row is considered stale; the +default is 15 minutes. Alerts are read-only and never move or retry a transfer. + +There is intentionally no generic "mark resolved" endpoint. A reviewed state transition +needs a case-specific, audited remediation because the correct result depends on wallet +evidence and whether value was actually transferred. diff --git a/docs/MATCH_MODE.md b/docs/MATCH_MODE.md index 2deaa2c..1c7fb91 100644 --- a/docs/MATCH_MODE.md +++ b/docs/MATCH_MODE.md @@ -21,21 +21,32 @@ starts. ## Lifecycle: a race every block The existing solo `queueHandler` already reacts to new crypto blocks. `MatchScheduler` subscribes to -the same block event and drains per-economy queues: +the same block-count event. Free and value-bearing queues intentionally have different starts: -- If a queue has ≥2 players at block H, all queued players are placed into a new `MatchRoom` and the - race starts immediately. -- If a queue has 1 player, they carry over to block H+1 and may leave at any time. +- A free queue with ≥2 players drains into a `MatchRoom` immediately on the current block event. +- A paid queue with ≥2 players at canonical header H atomically creates a `pending` match and links + the exact FIFO queue rows and entrants. That transaction records a deterministic entrant-freeze + commitment and fixes canonical header H+the configured entropy delay as its target; it does not + read a block hash or create a playable seed. A fresh post-commit count is persisted while the + target is still in the future. +- Only a later, sufficiently confirmed block-count event can resolve that pending match. The + scheduler twice requests the canonical hash for the exact target, verifies the frozen IDs again, + derives the seed, and changes the match from `pending` to `starting` before collection/countdown. + It immediately checks the hash again. Another height and server randomness are not substitutes. +- If a queue has 1 player, they carry over and may leave until a freeze claims their row. - If a queue is empty, nothing happens. -A race ends when: -1. The first player reaches the exit. -2. The next block is detected (with a `MATCH_MIN_DURATION_MS` floor to prevent degenerate short - races). +A match ends when: +1. Its selected ruleset resolves (for example, first exit, last alive, or all players resolved). +2. If `ruleset.timing.blockDeadline` is enabled, the first advancing canonical header after both + the match's start header and `MATCH_MIN_DURATION_MS` of active play is observed. A duplicate + block poll, the start header itself, or a block arriving before the floor cannot end the match; + after an early block, the match waits for the next advancing header. 3. The `MATCH_HARD_CEILING_MS` absolute ceiling expires. -If no one escaped, living players are ranked by proximity to the exit; dead players are ranked after -them by progress, treasure, and moves. +Final placement and `winnerId` come from the selected ruleset's deterministic rank strategy. The +classic race uses exit/proximity ranking; last-alive and score-attack use their own competitive +rankings. ## Architecture @@ -63,7 +74,8 @@ them by progress, treasure, and moves. See migration `src/migrations/022_match_mode.sql`: -- `matches` — one race, provably-fair seed, economy, pot/fee, winner. +- `matches` — one race, including the durable `pending` entrant-freeze envelope and later + verifiable seed commitment, economy, pot/fee, winner. - `match_entrants` — per-player state, placement, score, payment link. - `match_events` — replay / spectator / audit feed. - `match_queue_entries` — persisted queue for restart safety. @@ -71,11 +83,35 @@ See migration `src/migrations/022_match_mode.sql`: - `users.race_entries` — hot-read ticket balance. - `payouts.match_id` — links winner payout to match. -### Provably fair +### Match seed verification -Each match commits to a SHA-256 `seed_hash` at creation. The `seed` is revealed on finish so anyone -can regenerate the exact dungeon and verify the hash. This mirrors the existing solo -`games.dungeon_seed` / `games.seed` pattern. +Paid matches use `future-block-freeze-v2` / `future-chain-block-v2`. At canonical header H the +server commits the exact economy, ruleset, sorted durable FIFO queue-entry IDs, configured delay, +and target `H + MATCH_PAID_ENTROPY_DELAY_BLOCKS` in a `pending` row before requesting any hash. +After that transaction commits, a fresh strict daemon count must prove that the target does not yet +exist; the witnessed tip and verification timestamp are durable and immutable. A delayed commit +that reaches the target is cancelled and every exact escrow anchor is refunded without reading the +target hash. Legacy v1 or otherwise unverified pending freezes are also refunded at startup. + +Activation waits until the target itself has the configured confirmation depth (the target counts +as confirmation one). The scheduler reads count/header/count/header and requires both exact-height +headers to have the same canonical hash. It re-reads that target immediately after the activation +transaction and before ticket collection, gameplay, or client notification; an unavailable or +changed hash aborts and refunds the match. The playable seed is SHA-256-derived only from the v2 +freeze commitment and exact target hash. The persisted/public proof records the delay, required +confirmations, minimum activation tip, and post-commit witness. Confirmation settings are safety +metadata rather than extra seed material. + +Monero-family `getblockcount` is a count, while `get_block(height)` uses zero-based header heights. +The scheduler normalizes the event exactly once (`observedHeaderHeight = blockCount - 1`). Every +persisted/disclosed `blockHeight` is the actual header height passed unchanged to `get_block`, so the +published height resolves to the published hash without an off-by-one translation. + +This proves the documented seed derivation and deterministic dungeon; it does not prove honest +input handling, block-source independence, payout delivery, or resistance to a malicious chain +producer/operator. Free development matches may use a server-random commitment and are labelled +`server commitment only`. Do not describe match mode as equivalent to the solo two-party fairness +protocol without an independent review. ## Configuration @@ -85,12 +121,21 @@ Add to `src/.env` (documented in `src/.env.example`): # Enable match mode (default false) MATCH_ENABLED=false -# Max players per race (2–32) +# Max players per match (also bounded by the selected ruleset; built-ins max at 8) MATCH_MAX_PLAYERS=4 # Server tick interval in ms MATCH_TICK_MS=250 +# Server-selected gameplay ruleset; clients cannot override it +# race | last-alive | score-attack | coop-escape +MATCH_RULESET_ID=race + +# Paid freeze target distance and activation confirmation depth. +# Production requires explicit safe integers from 2 through 100. +MATCH_PAID_ENTROPY_DELAY_BLOCKS=2 +MATCH_PAID_ENTROPY_CONFIRMATIONS=2 + # Minimum race duration before the next block can end it MATCH_MIN_DURATION_MS=20000 @@ -100,20 +145,25 @@ MATCH_HARD_CEILING_MS=240000 # Credits cost for prestige-only races MATCH_CREDITS_COST=1 -# Crypto race house fee percent (default 5) +# Crypto race house fee percent (required; 0 <= fee < 100) MATCH_HOUSE_FEE_PERCENT=5 -# Optional: override per-player entry fee atomic amount. -# If unset, uses the existing per-game price (singleGamePrice / DIRECT_GAME_PRICE). -# MATCH_ENTRY_FEE_ATOMIC=5000000000 +# Required per-player funded ticket value in atomic units. +MATCH_ENTRY_FEE_ATOMIC=5000000000 -# Enable the crypto race-entry ticket economy +# Crypto admission requires both explicit match gates and an outer atomic-unit payout cap MATCH_CRYPTO_RACE_ENABLED=false +MATCH_PAYOUTS_ENABLED=false +MATCH_PAYOUT_MAX=50000000000 ``` Crypto races also require: - A paid instance (`GAME_MODE=PAID_SINGLE` / `PAID_CREDITS` or equivalent `PAYMENT_MODES`). -- Race-entry ticket products in the credit/cosmetic catalog with `grants.race_entries: N`. +- At least one race-entry product with `grants.race_entries: N` and + `grants.race_entry_value_atomic` exactly equal to `MATCH_ENTRY_FEE_ATOMIC`. The product price + must cover `N * MATCH_ENTRY_FEE_ATOMIC`; confirmation records a durable lot per payment. +- A competitive single-winner ruleset. `coop-escape` is rejected until split-payout semantics are + implemented. ## Client surfaces @@ -125,7 +175,7 @@ Crypto races also require: ## Socket events Server emits: -- `game_mode_info` — now includes `modes.match { enabled, economies, maxPlayers }`. +- `game_mode_info` — includes `modes.match { enabled, economies, maxPlayers, activeRuleset, rulesets }`. - `match_queue_joined` / `match_queue_left` — queue responses. - `match_joined` / `match_start` / `match_tick` / `match_end` — race lifecycle for players. - `tavern_match_list` / `tavern_match_tick` / `tavern_match_end` — public race feed for tavern @@ -139,11 +189,17 @@ Client sends: ## Leaderboards -- **Free races** create synthetic `games` rows with `game_mode = 'FREE'` for the Pleb board. -- **Credits/prestige races** populate the `prestige_leaderboard` view and are served by - `GET /api/leaderboard?board=prestige`. -- **Crypto races** create synthetic `games` rows with `game_mode = 'PAID_CREDITS'` for the Hall of - Champions board. +- **Free competitive matches** create synthetic `games` rows with `game_mode = 'FREE'` for the + Pleb board. +- **Credits/prestige competitive matches** are served from authoritative `matches` and + `match_entrants` rows by `GET /api/leaderboard?board=prestige`. +- **Crypto competitive matches** create synthetic `games` rows with `game_mode = 'PAID_CREDITS'` + for the Hall of Champions board. + +An individual win requires the durable match winner and placement #1 to agree; reaching the exit +alone is never treated as a win in last-alive or score-attack. `coop-escape` is collective, records +no individual `winnerId`, and keeps its results in `matches` / `match_entrants`; it is intentionally +excluded from individual boards until a dedicated team leaderboard exists. ## Tests @@ -154,15 +210,8 @@ cd src npx jest ../test/match ``` -Current result: **40 passing** across 7 suites: - -- `matchRoom.test.js` -- `matchQueue.test.js` -- `matchScheduler.test.js` -- `matchLeaderboard.test.js` -- `matchPayoutService.test.js` -- `tavernMatchBridge.test.js` -- `matchReconnect.test.js` +The suite includes block-deadline, authoritative leaderboard-result, co-op exclusion, durable +finish, and finished-room reconnect regressions in addition to the core room/queue/payout tests. ## Files added / modified @@ -188,7 +237,18 @@ Modified: ## Operational notes - Match mode is **opt-in** and inert unless `MATCH_ENABLED=true`. +- `MATCH_RULESET_ID` is trusted server configuration; unknown/solo-only values fall back to `race`. - Crypto race payouts reuse the existing `payouts` table, batch processor, and retry service. -- The unique partial index `idx_payouts_one_per_match` prevents double payouts. -- Race-entry tickets are held in escrow on queue join and consumed only when a match starts. +- `MATCH_PAYOUTS_ENABLED=false` stops new admission and refunds queued ticket escrow. Matches whose + pot/liability was already accepted still create their durable payout; the global payout master + switch may pause dispatch without erasing it. +- The unconditional unique index `idx_payouts_one_per_match` prevents a replacement payout in every + status, including `failed` and `needs_review`. +- Only confirmed-payment ticket lots with the current exact entry value can enter a crypto race; + legacy/admin-granted aggregate tickets cannot create a payout liability. The exact lot is held + on queue join and consumed in the same transaction that accepts the immutable match liability. - Players who disconnect during a race have a 30-second grace period to reconnect. +- Paid freeze/start block events are serialized. A crash before activation leaves a resumable + `pending` freeze; a crash after activation is handled by abandoned-match recovery. Orderly + shutdown stops the scheduler, waits for its block task, and transactionally cancels/refunds every + still-pending freeze before shutting down match transport. diff --git a/docs/PRODUCTION_DISCLOSURES.md b/docs/PRODUCTION_DISCLOSURES.md new file mode 100644 index 0000000..eff5f53 --- /dev/null +++ b/docs/PRODUCTION_DISCLOSURES.md @@ -0,0 +1,85 @@ +# Production disclosures and paid-action gate + +This implementation makes the running server describe its actual free, paid-prestige, payout, +test-network, and PvP switches. It does **not** claim that the product is licensed or lawful in a +particular place. Obtain jurisdiction-specific advice before accepting assets with real value. + +## Required production settings + +Every production instance with `PAYMENTS_ENABLED=true` fails preflight/startup unless all of these +are explicit and valid: + +```dotenv +OPERATOR_NAME=Truthful responsible operator name +OPERATOR_CONTACT_URL=mailto:support@example.com +OPERATOR_CONTACT_LABEL=Support +LEGAL_POLICY_VERSION=2026-07-21-v1 +TERMS_EFFECTIVE_DATE=2026-07-21 +MINIMUM_AGE=18 +PAID_ACKNOWLEDGEMENT_REQUIRED=true +``` + +Production match mode also requires explicit paid-fairness timing, even if only prestige credits +are presently exposed: + +```dotenv +MATCH_PAID_ENTROPY_DELAY_BLOCKS=2 +MATCH_PAID_ENTROPY_CONFIRMATIONS=2 +``` + +Both are bounded safe integers from 2 through 100. Paid PvP persists the actual delay, +confirmation requirement, activation threshold, and post-commit daemon-tip witness in its public +proof; changing these settings does not reinterpret an already-frozen match. + +`RESTRICTED_LOCATIONS_NOTICE` is optional application copy. Add it only after legal review; text is +not geofencing. Changing the terms or material disclosures requires a new `LEGAL_POLICY_VERSION`, +which invalidates prior browser-session acknowledgements. + +## Enforcement contract + +- `/api/disclosures` returns non-secret, no-store policy data derived from runtime switches. +- `/terms`, `/privacy`, and `/responsible-play` render the active mode and operator metadata. +- Before a payment invoice, paid solo/credit entry, or paid PvP queue join, the browser requires + unselected age/reading/risk boxes. Test networks require an additional mainnet/test-coin box. +- Acceptance is a five-field canonical record (policy version plus four booleans), scoped to the + current browser tab and policy version, and cleared on rejection or socket disconnect. +- The server validates the exact current version and booleans before consuming a fairness offer, + opening an invoice, consuming a credit, or escrowing a paid PvP entry. A modified client cannot + bypass this check. +- Free entry remains available without a paid acknowledgement where the instance offers it. + +The acknowledgement is a player statement, not proof of identity, age, capacity, or location. + +## Mode language + +| Runtime mode | Player-facing contract | +|---|---| +| Free | No entry payment; free leaderboard; no free payout | +| Paid, payouts off | Entry/credits/product purchase and paid prestige leaderboard; no crypto prize | +| Paid, solo payouts on | Full entry can be lost; only pre-entry recorded outcomes/amounts qualify | +| Crypto PvP on | Paid ticket escrow and configured winner/fee contract; separate from free/prestige PvP | +| XMR stagenet/testnet | Valueless test currency; explicit warning never to send mainnet XMR | + +## Unresolved launch decisions + +Technical copy and checkboxes do not resolve these. Keep real-value modes gated until the responsible +operator records decisions for: + +1. entity/operator identity, licences/registrations, permitted and excluded locations; +2. age and location verification appropriate to those locations; +3. consumer terms: refunds, late/under/overpayments, complaints, dispute venue, and downtime; +4. privacy roles, processors, retention/deletion schedule, user-rights procedure, breach response, + and whether a privacy-minimized durable acknowledgement record is legally required; +5. responsible-play features such as account-level deposit/time/loss limits and durable self-exclusion; +6. tax, sanctions/AML, accounting, and cryptocurrency reporting obligations; +7. an independent review of game rules, advertised multipliers, house fee, bankroll, and provable-fair claims. + +Stagenet can be used for end-to-end mechanics without representing test coins as money. Mainnet +payouts remain a separate, explicit operational and legal decision. + +## Front-end dependency note + +The same-origin jQuery copy is 3.7.1 from the Ubuntu `libjs-jquery` package; exact provenance and +SHA-256 are in `html/js/lib/jquery-3.7.1.PROVENANCE.md`. The CSP still permits inline script/style +because current pages depend on them. A nonce migration must move every inline handler/script before +removing `unsafe-inline`; do not partially tighten it and strand payment/game controls. diff --git a/docs/PVP_CAPTURE.md b/docs/PVP_CAPTURE.md new file mode 100644 index 0000000..9974a0e --- /dev/null +++ b/docs/PVP_CAPTURE.md @@ -0,0 +1,150 @@ +# Deterministic PvP capture harness + +`src/scripts/pvp-capture.js` creates a clean, reproducible multiplayer match for trailers, ads, +camera work, and gameplay review. It supports the built-in Escape Race, Last Alive, Score Attack, +and Co-op Escape rulesets. Each take uses the authoritative `MatchRoom` and `MatchEngine`, drives +deterministic bots through the installed Socket.IO client, and records the existing browser render +kit with the installed Playwright Chromium. + +This is intentionally not a production feature or an admin endpoint. The harness: + +- starts a separate HTTP/Socket.IO process bound to `127.0.0.1` on an ephemeral port; +- accepts no remote target URL and cannot attach to a normal Wowngeon instance; +- creates one in-memory `free` match with zero entry fee, pot, house fee, or payout; +- imports no application, database, wallet, queue, provider, or payout service; +- requires `NODE_ENV=development` or `NODE_ENV=test` and the explicit + `--confirm-local-free-only` flag; +- refuses to run when a payment, credits, payout, crypto-race, mainnet-payout, or wallet endpoint + is enabled/configured; and +- refuses inherited crypto, game-mode, difficulty, dungeon, monster, treasure, or network-tuning + overrides so a committed seed cannot silently mean different footage in another shell; and +- loads only repository assets. CDN/3D renderers are unavailable in this tool. + +Those guards make setting an environment variable in the live service insufficient to enable the +harness. Someone must deliberately start this separate loopback-only CLI with its confirmation +flag. On a staging machine, run it as a separate development/test process; it never uses the +staging app or staging database. + +## Record a video + +From `src/`: + +```bash +NODE_ENV=development npm run capture:pvp -- \ + --confirm-local-free-only \ + --ruleset race \ + --players 4 \ + --mode tiles \ + --output /tmp/wowngeon-pvp-capture.webm \ + --screenshot /tmp/wowngeon-pvp-final.png +``` + +The repository already declares `playwright-core`; the command uses its existing local Chromium +and FFmpeg bundle. It never downloads a browser or package. If that browser bundle is missing, the +CLI fails with a direct explanation. The deterministic control harness remains usable without a +browser: + +```bash +NODE_ENV=test npm run capture:pvp -- \ + --confirm-local-free-only \ + --no-video \ + --ticks 120 \ + --trace /tmp/wowngeon-pvp-trace.json +``` + +Run `npm run capture:pvp -- --help` for every option. `--ruleset` accepts `race`, `last-alive`, +`score-attack`, or `coop-escape`; this is separate from `--mode`, whose offline renderer choices +are `tiles`, `ascii`, and `iso`. Every ruleset accepts 2–8 bots in this harness. + +The default 1280×720 spectator camera anchors on `bot-1`; choose another player with `--focus +bot-N`. In the default `--camera action` mode, deaths, treasure pickups, and exits temporarily take +focus before the camera returns to that anchor. `--camera focus` disables those cuts. Camera zoom +adjusts to the active pack: it pushes in for close action and pulls back as players spread out. +Portrait viewports begin with a tighter follow crop so 9:16 footage fills the frame. A separate +screen-space roster keeps all 2–8 player names and states legible without stacking labels on one +dungeon tile. + +## Capture each built-in mode + +Use the same safety acknowledgement for every take: + +| `--ruleset` | Bot objective | Result card | +|---|---|---| +| `race` | Treasure route plus staggered sprints to the exit | Competitive race winner | +| `last-alive` | Seek and strike the nearest active rival | Last survivor | +| `score-attack` | Build progress/treasure score, then bank an exit | Top score and points | +| `coop-escape` | Staggered team pathing to the exit | Team escape count, never a fake competitive winner | + +For example, record a Last Alive take: + +```bash +NODE_ENV=development npm run capture:pvp -- \ + --confirm-local-free-only \ + --ruleset last-alive \ + --players 6 \ + --camera action \ + --viewport 1920x1080 \ + --output /tmp/wowngeon-last-alive-ad.webm \ + --trace /tmp/wowngeon-last-alive-ad.json +``` + +Swap only `--ruleset` and output names to capture Score Attack or Co-op Escape. Headlines, active +player terminology, roster states, footer copy, and final results all follow the selected ruleset. +Score Attack closes the local exhibition deadline as soon as every bot has died or banked an exit, +avoiding a motionless tail without changing the authoritative scoring/finalization path. + +For a short 9:16 ad take, the curated seed produces a complete six-racer arc in about nine seconds. +Follow the eventual winner so the finish remains legible on a phone screen: + +```bash +NODE_ENV=development npm run capture:pvp -- \ + --confirm-local-free-only \ + --ruleset race \ + --players 6 \ + --focus bot-4 \ + --viewport 1080x1920 \ + --output /tmp/wowngeon-pvp-ad-vertical.webm \ + --screenshot /tmp/wowngeon-pvp-ad-vertical.png +``` + +Use the emitted JSON trace as the authoritative replay record. The video is marketing footage; it +does not create a leaderboard row, credit, payment, ticket, or payout. + +For a crowded 16:9 arena take, the same seed supports the built-in eight-player maximum: + +```bash +NODE_ENV=development npm run capture:pvp -- \ + --confirm-local-free-only \ + --ruleset race \ + --players 8 \ + --focus bot-4 \ + --viewport 1920x1080 \ + --output /tmp/wowngeon-pvp-ad-8player.webm \ + --screenshot /tmp/wowngeon-pvp-ad-8player.png \ + --trace /tmp/wowngeon-pvp-ad-8player.json +``` + +The recorded eight-player proof and its no-video control both ended with `bot-4` at tick 49 and +the same final state hash. This checks that browser recording does not alter the authoritative race. + +## Reproducibility + +The default seed is fixed. A custom seed must be exactly 64 hexadecimal characters: + +```bash +NODE_ENV=development npm run capture:pvp -- \ + --confirm-local-free-only \ + --seed beb270f3806a97e9ef73c8f83a6eae19a92f90ab38af9ad8b365cb74c41b2702 +``` + +Each bot receives a numbered tick and the same authoritative state over Socket.IO. The server waits +for every bot response, queues inputs in stable bot order, and lets the engine resolve them using its +committed per-tick action priority before advancing. Bot goals are ruleset-aware but contain no +randomness beyond the committed dungeon seed. Missing or malformed input aborts the run instead of +silently changing the replay. Every run writes a versioned JSON trace beside the video containing +the ruleset, presentation metadata, seed, exact inputs, events, camera configuration, state hashes, +placements, scores, and mode-correct outcome. + +The gameplay trace is deterministic, and records that gameplay environment overrides were refused. +Encoded WebM bytes can differ across Chromium/FFmpeg versions +or host timing, so use the JSON state hashes—not the video checksum—to compare replays. diff --git a/docs/RELEASE_ARTIFACT.md b/docs/RELEASE_ARTIFACT.md new file mode 100644 index 0000000..6e09faa --- /dev/null +++ b/docs/RELEASE_ARTIFACT.md @@ -0,0 +1,111 @@ +# Immutable release artifacts + +`scripts/deploy/build-release-artifact.sh` is the local, non-activating boundary between a reviewed +Git commit and the two production application instances. It produces a **runtime artifact**, not a +source or operations bundle. It does not read an environment file, install a package, connect to +`deb`, or move either `current` symlink. + +## Build gate + +Commit every intended source and documentation change first. Review that commit and confirm that a +secret scanner has not found credentials embedded in otherwise legitimate source files; filename +policy alone cannot detect a hard-coded secret. Then run: + +```bash +git status --short # must print nothing +./scripts/deploy/build-release-artifact.sh # defaults to git-<12-character HEAD prefix> +cd dist/releases +sha256sum --check .tar.gz.sha256 +``` + +The builder fails on any tracked or non-ignored untracked worktree change, tracked symlink/gitlink, +runtime `.env` or credential-capable dotfile, unsafe release ID, or existing output. Ignored local +files are never read because the archive comes only from the selected commit. The archive contains +only `LICENSE`, `html/**`, the runtime subset of `src/**`, and deterministic release metadata. +It intentionally contains no `docs/`, `test/`, root `scripts/`, deployment service units, wallet +migration tools, environment examples, ad-capture bot, stagenet transfer canary, or development +database/smoke helpers. Simulation/Jest sources are also excluded, and the staged package manifest +retains only the runnable `start`, `preflight`, and `db:migrate:clone` commands. The guarded +`src/scripts/preflight.js` and +`src/scripts/migrate-disposable-clone.js` remain available for staging checks. +`.release-source-sha256` identifies the prefix-free, +runtime-allowlisted `git archive`; the external sidecar identifies the final compressed artifact; +`.release-files.sha256` verifies every extracted runtime/metadata file without covering the later +`src/node_modules` install. + +Run the complete test suite from the clean source commit **before** building. Tests and operator +tools are source/control-plane inputs; they are not production runtime files: + +```bash +cd src +npm ci --ignore-scripts --no-audit --no-fund +npm test +cd .. +git status --short # must still print nothing +./scripts/deploy/build-release-artifact.sh +``` + +Then test the runtime bytes that will be shipped. In a disposable directory, verify the artifact +sidecar and manifest and install only the locked production dependency graph: + +```bash +cd +sha256sum --check .tar.gz.sha256 +tar -xzf .tar.gz -C +cd / +sha256sum --check .release-files.sha256 +cd src +npm ci --omit=dev --ignore-scripts --no-audit --no-fund +npm ls --omit=dev +``` + +Advisory lookup is a separate, explicitly authorized network operation. Do not use `npm audit fix` +to mutate a candidate or live release. + +## Production staging gate + +Use the same runtime artifact SHA-256 for `monerogue` and `wownerogue`. Transfer it into a +root-controlled staging path and verify the operator-pinned 64-hex digest before extraction. The +activation boundary must then: + +1. reject an existing release ID, links/special files, an unexpected top-level directory, or unsafe + ownership/path components; +2. install only locked production dependencies with + `npm ci --omit=dev --ignore-scripts --no-audit --no-fund` while staging is writable; +3. run `npm ls --omit=dev`, recheck `.release-files.sha256`, then make the complete release + `root:root` and non-writable before it can become `current`; +4. restore each fresh pre-deploy dump into a separately named clone database, run + `npm run db:migrate:clone` under the exact disposable-target contract in + [CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md), require its exact manifest proof (currently through + migration 040), and run the separately reviewed and hash-pinned fleet copy of + `financial-audit.sql` there—the SQL is intentionally not inside the runtime artifact; +5. run `npm run preflight` from the candidate with each final protected environment. Preflight is + intentionally non-mutating and does not replace database, daemon, or wallet health checks. + +The clone runner is deliberately non-activating; there is still no reviewed activation script. Do +not point `current` at a candidate until the clone proof and the complete +drain/audit/symlink/health/rollback sequence in `~/src/such-fleet/RUNBOOK-wowngeon.md` are satisfied. + +## Source and operations boundary + +The Git checkout remains the review/test source. `~/src/such-fleet` is the deployment control plane +for hash-pinned SQL, service units, backup/firewall jobs, clone validation, activation, and rollback. +Neither source-tree deployment scripts nor fleet tools are copied beneath the application release +directory. A runtime artifact hash does not attest a separately delivered operations file; record +and verify an independent hash for every such file in the fleet change plan. + +## Activation and rollback gate + +Drain public ingress and require zero active and queued games. Take and validate fresh dumps; run the +financial audit before and after stopping only the affected app. Preserve and validate the exact +predecessor target, switch `current` atomically, then start stagenet first. Require bounded local +`/health/live` and `/health/ready`, exact network/money-mode disclosures, public TLS, WebSocket, and +leaderboard/intake smoke tests before traffic is restored. The stagenet payout canary is a later, +one-shot gate and is never a mainnet command. + +Application rollback is safe only if the predecessor was tested against the *post-migration* clone +schema. Migrations are forward-only and run one transaction per file at application startup; a +mid-sequence failure can leave earlier files committed. On activation failure, stop the candidate, +atomically restore the validated predecessor symlink, start it, and require both the exact target and +readiness. Do not automatically restore the database after new writes; keep the dump for an +operator-reviewed recovery decision. diff --git a/docs/REMEDIATION_PLAN.md b/docs/REMEDIATION_PLAN.md index 6257a7b..862860f 100644 --- a/docs/REMEDIATION_PLAN.md +++ b/docs/REMEDIATION_PLAN.md @@ -46,7 +46,7 @@ Key: `S` ≤ half day · `M` ~1–3 days · `L` ~1 week+ · 🔴 launch-blocker - [x] **4.1 L — Break up `index.js` (substantially).** ✅ Extracted the verify HTML → `views/verifyPage.js` (now HTML-escaped), all 15 admin endpoints → `routes/admin.js`, and the 3 smirk endpoints → `routes/auth.js`, each as DI factories with smoke tests. **index.js: 2029 → 882 lines (-57%).** _Remaining user/public routes left inline — they're interleaved with bootstrap and have no route tests, so further slicing is diminishing-return/higher-risk; the structure + pattern are established for later._ - [~] **4.2 M — Kill duplicates (partial).** ✅ Deleted the dead `handlePlayerMove` handler (a footgun that double-stepped the monster + bypassed escape/collision detection) and its orphaned `playerMoveTimestamps` map/cleanup — the live path is `MovementManager`. _Remaining (lower-value/higher-effort): merge `rpcService`+`rpccalls`, unify `payout_address` column widths, address the `QueryValidator` false-positive — deferred._ - [⏸] **4.3 M — Config snapshots — DEFERRED (risk > value).** The `process.env` config write-back is ugly but **load-bearing**: it's how DB-persisted hot-reload config reaches the env-based readers — `difficultyConfig` selects the difficulty preset from `process.env.GAME_MODE`, and `debugManager`'s simulated-blocks **safety gate** reads it. A proper `ConfigService` means rewiring those sensitive readers; with no config-reload tests and zero correctness/safety upside, the risk isn't justified right now. Documented rather than rewritten. -- [~] **4.4 M — Frontend de-spaghetti (safe wins done).** ✅ Shared `escapeHtml` already added in 0.6. Removed the duplicate `_gameMode` declaration (the later `null` silently shadowed the earlier `'free'`). Removed the dead `SocketHandlers._appendMessage` calls in `smirkAuth.js` (the method never existed; always fell through to the working `.text()` append). Gated `broadcastManager`'s high-volume per-event logs (user-count, chat-message-with-PII, game-update, game-start) behind debug mode. _Deferred: jQuery 3.4.1 upgrade and the nested-`setTimeout` init-ordering rewrite (larger, no frontend tests, higher risk)._ +- [~] **4.4 M — Frontend de-spaghetti (safe wins done).** ✅ Shared `escapeHtml` already added in 0.6. Removed the duplicate `_gameMode` declaration (the later `null` silently shadowed the earlier `'free'`). Removed the dead `SocketHandlers._appendMessage` calls in `smirkAuth.js` (the method never existed; always fell through to the working `.text()` append). Gated `broadcastManager`'s high-volume per-event logs (user-count, chat-message-with-PII, game-update, game-start) behind debug mode. Upgraded the same-origin vendored jQuery from 3.4.1 to distro-sourced 3.7.1 with a recorded package version and SHA-256. _Deferred: the nested-`setTimeout` init-ordering rewrite and a complete nonce-based CSP migration (both require broader browser regression coverage)._ - [x] **4.5 S — Delete stale `test/` scratch files.** ✅ Removed 14 non-jest leftovers (5 `.js` scratch scripts + 9 `.html` debug pages) — confirmed they contained no jest constructs and were referenced nowhere. `test/` is now exclusively the 21 real jest suites. ## Phase 5 — Test & ops overhaul (🟠 — the launch gate) diff --git a/docs/RULESETS.md b/docs/RULESETS.md index e40c9e9..d59e9b1 100644 --- a/docs/RULESETS.md +++ b/docs/RULESETS.md @@ -31,7 +31,7 @@ three decision points: ``` onExit(room, id) a player reached the exit — end the match? set a winner? onDeath(room) a player died — is the match over? -rank(room) final ordering (best-first) for placement + winnerId +rank(room) final ordering (best-first) for placement; competitive modes also set winnerId ``` - **`FIRST_TO_EXIT`** is a *verbatim port* of the original race logic, so existing matches are @@ -39,8 +39,11 @@ rank(room) final ordering (best-first) for placement + winnerId - **`LAST_ALIVE`** (PvP): reaching the exit means you *survived* (safe, not an instant win); the match ends when ≤1 contender remains; `rank` orders last-standing → escaped → died-later. Combined with `entities.pvpCombat`, stepping onto a living rival **strikes them down** (attacker holds position). + Same-tick intents use a seed-derived priority over stable entrant slots, never packet arrival order; + an entrant eliminated earlier in that committed order cannot move or attack post-mortem. - **`HIGH_SCORE`**: no instant win; rank by score at the end. -- **`ALL_ESCAPE`** (co-op): ends when everyone alive has escaped. +- **`ALL_ESCAPE`** (co-op): ends when everyone alive has escaped, records placements as progress + metadata, and deliberately leaves the individual `winnerId` null. ## Where it plugs in @@ -51,9 +54,20 @@ and `entities.pvpCombat`, and takes timing from the ruleset. `MatchEngine` is a (`room.resolveTick()` on an interval). **Scope note:** the *live single-player* engine (`src/game/game.js`) is intentionally **not** refactored to -consume rulesets (too risky to touch the deployed path) — `solo-classic` is a catalog descriptor only. PvP -modes are opt-in via `rulesetId`; the match scheduler still builds `race`, and match mode is gated by -`MATCH_ENABLED` (inert by default), so nothing changes until wired. +consume rulesets (too risky to touch the deployed path) — `solo-classic` is a catalog descriptor only. +Multiplayer rulesets are selected by the server with `MATCH_RULESET_ID`; unknown or solo-only values fail +closed to `race`. The active ruleset and safe catalog metadata are advertised to clients, but clients cannot +select executable rules. Match mode remains gated by `MATCH_ENABLED` (inert by default). + +When `timing.blockDeadline` is true, `MatchScheduler` offers each advancing canonical header to +`MatchManager`. An active room expires only when that header is strictly later than its start +header and its active-play `minDurationMs` floor has elapsed. Duplicate polls and same-header +starts are idempotent; `blockDeadline: false` leaves the hard ceiling as the only clock-based end. + +For paid multiplayer, the selected ruleset ID is part of the durable entrant freeze at canonical +header H. The seed uses only the exact configured future-header hash after that freeze and its +required confirmation wait; changing the active +ruleset cannot reinterpret a pending entrant set or trigger a server-random fallback. ## Economy per mode @@ -73,8 +87,11 @@ game-balance change, not just wiring. ``` src/game/rulesets/Ruleset.js defineRuleset + WIN/ECONOMY enums src/game/rulesets/winConditions.js FIRST_TO_EXIT (race) / LAST_ALIVE / HIGH_SCORE / ALL_ESCAPE -src/game/rulesets/registry.js built-ins + getRuleset/listRulesets/rulesetFromMatchOpts +src/game/rulesets/registry.js built-ins + safe match resolver/catalog helpers src/multiplayer/MatchRoom.js consumes the ruleset; PvP combat; delegates win/lose/rank src/multiplayer/MatchEngine.js tick driver +src/network/matchScheduler.js advancing-header deadline dispatch +src/network/matchManager.js active-play floor + idempotent expiry src/game/difficultyConfig.js world presets (the ruleset's `world` half) +src/migrations/029_match_ruleset_id.sql persists the selected ruleset on each match ``` diff --git a/docs/STAGENET_FINANCIAL_CANARY.md b/docs/STAGENET_FINANCIAL_CANARY.md new file mode 100644 index 0000000..065e43e --- /dev/null +++ b/docs/STAGENET_FINANCIAL_CANARY.md @@ -0,0 +1,210 @@ +# XMR stagenet financial canary + +`src/scripts/stagenet-financial-canary.js` is the release-gate harness for Monerogue's two paid solo settlement paths. It is intentionally narrow: one new anonymous user, one invoice, at most one funding transfer, one bounded game, and one payout. It supports these scenarios: + +| `E2E_SCENARIO` | Entry | Required outcome | Exact payout | +|---|---|---|---| +| `direct-2x` | one `single_game` invoice | escape without treasure | invoice amount × 2 | +| `credits-3x` | one pure credits-package invoice, then one `PAID_CREDITS` entry | collect treasure, then escape | configured credits payout base × 3 | + +Stagenet XMR is valueless test currency. Never send mainnet XMR to a stagenet address. This harness refuses mainnet-facing application state and funding-wallet addresses, but that guard is not a substitute for checking the wallet and daemon commands yourself. + +## Hard safety properties + +The harness fails closed unless all of these are true: + +- The application URL explicitly names `127.0.0.1`, `localhost`, or `::1` on dedicated port `3102`. It cannot target the public router, LAN hostname, or Nebula address. +- Readiness, public disclosures, REST mode data, and Socket.IO mode data all identify real XMR stagenet with payments and payout dispatch enabled. +- Both paid solo modes advertise exact 2× escape and 3× treasure multipliers. +- The current `/api/disclosures` response requires the canonical five-field acknowledgement. The harness refetches it immediately before the value-bearing action and echoes exactly `policyVersion`, `ageEligible`, `termsRead`, `riskAccepted`, and `testnetUnderstood`. +- The selected PostgreSQL database name contains `canary`, `e2e`, and the selected scenario (`direct` or `credits`). Its connection must be localhost, the harness session is read-only, current financial migrations must exist, and the database must contain only the migration-seeded admin with no game or financial rows. +- Before any invoice, funding-wallet call, or transfer, a nonce challenge binds the application pool to the harness connection's exact PostgreSQL cluster identifier, database OID, and database name. A same-named database on another cluster does not pass. +- The funding wallet RPC is localhost-only and either digest-authenticated or enabled by a separate explicit unauthenticated-local-RPC acknowledgement. +- The funding wallet validates both its own address and the invoice as stagenet. `get_address_index` must prove the invoice does not belong to the funding wallet; this prevents a meaningless house-wallet self-payment. +- Live execution needs three exact confirmations plus an atomic-unit transfer ceiling. The transfer gate is marked used before the sole `transfer` RPC call, so an ambiguous transport failure is never retried. +- The bot is bounded to one dungeon depth, 3,000 moves, and eight minutes. The direct scenario treats treasure as blocked. The credits scenario treats the exit as blocked until treasure is collected. +- Wallet addresses, session tokens, proof seeds, transaction hashes, and credentials are never printed. Failure messages redact those shapes. + +The harness never creates, restores, exports, migrates, or backs up a wallet. It never deploys code, changes router state, or edits fleet configuration. + +## Required topology + +Use two different stagenet wallets: + +1. The canary application owns the normal house wallet used to create invoice subaddresses and dispatch payouts. +2. A separate, low-balance funding wallet sends the entry/package payment and receives the payout. + +Expose only the separate funding wallet RPC to the harness, on loopback. Do not give the harness the house-wallet RPC URL or credentials. Keep the funding balance no larger than needed for the selected invoice plus fees. + +Before live use, independently verify that both wallet files are recoverable according to the operator's wallet-backup procedure. Do not put seeds, private keys, wallet passwords, transaction keys, or mnemonic output in this runbook, shell history, screenshots, CI variables, or canary logs. This harness deliberately does not inspect or print seed material. + +## Build the disposable canary instance + +Run the exact release candidate intended for production, but bind it only to localhost port `3102`. Use a dedicated PostgreSQL database for each scenario; do not run both scenarios in the same database and never reuse a database after any live attempt. + +Recommended game-profile overrides for the one-shot financial check are: + +```dotenv +DIFFICULTY_PRESET=easy +MONSTER_SPEED=0 +DUNGEON_LEVELS=1 +``` + +`MONSTER_SPEED=0` leaves the verifiable monster placement in the committed dungeon but prevents movement during the canary. The harness verifies the one-level state; the exact `E2E_CANARY_PROFILE` confirmation below is the operator's assertion that the easy/static-monster configuration is the one actually running. + +The canary instance must otherwise use the intended production money configuration, including: + +- `CRYPTO_TYPE=XMR`, `MONERO_NETWORK=stagenet`, real stagenet daemon and house wallet RPC; +- direct and credits paid modes enabled together; +- direct and credits payouts enabled; +- 2× escape and 3× treasure multipliers for both modes; +- a positive `CREDITS_PAYOUT_BASE` whose 3× result is within the configured payout bounds; +- a credits-only package (default id `small`) with no race entries, packs, or premium grant; +- production paid-action acknowledgement enabled and current operator/legal disclosure metadata. + +Use a fresh database name such as: + +```text +monerogue_canary_direct_e2e +monerogue_canary_credits_e2e +``` + +First boot the release candidate against the new database with payout dispatch disabled solely to run startup migrations. Stop it after readiness. Then run database preflight. Finally boot the same release candidate and same database with the intended payout configuration on localhost port `3102`. Do not point a public router at this instance. + +Enable the identity endpoint only on that final isolated canary boot: + +```dotenv +NODE_ENV=production +PORT=3102 +CANARY_DATABASE_HANDSHAKE=I_AM_AN_ISOLATED_XMR_STAGENET_CANARY +CANARY_EXPECT_DATABASE=monerogue_canary_direct_e2e +CANARY_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce +``` + +Use the corresponding `credits` database name for that scenario. If none of the three +`CANARY_DATABASE_*` variables is set, the endpoint is not registered. A partial configuration, +non-XMR-stagenet process, non-production process, wrong port, or `DB_NAME` mismatch aborts startup. +The endpoint signs its database identity and a fresh caller challenge; it never returns the nonce. +It also rejects any TCP peer that is not loopback, without trusting proxy-forwarded client headers. + +## Protect local secret files + +Credential files read by the harness must be regular files, not symlinks, and have no group/world permission bits (for example mode `0600` or `0400`). Prefer file-backed variables: + +```text +E2E_FUNDING_RPC_USER_FILE=/run/credentials/canary/funding-rpc-user +E2E_FUNDING_RPC_PASSWORD_FILE=/run/credentials/canary/funding-rpc-password +E2E_DATABASE_URL_FILE=/run/credentials/canary/database-url +E2E_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce +``` + +Create a new random 32-byte nonce for each scenario and expose the same protected file to the +application (`CANARY_DATABASE_NONCE_FILE`) and harness (`E2E_DATABASE_NONCE_FILE`). Do not put its +contents in an environment variable, command argument, log, or screenshot. Delete the nonce after +the isolated canary is stopped; it is not a wallet key or recovery artifact. + +Alternatively, `E2E_DATABASE_ENV_FILE` may point to a protected dotenv file containing `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD`. Set exactly one database source. `DB_HOST` and database URLs must explicitly use loopback. + +## Read-only preflights + +The public preflight performs no Socket.IO session registration, invoice creation, database mutation, or wallet RPC call: + +```bash +E2E_MODE=preflight \ +E2E_TARGET=http://127.0.0.1:3102 \ +npm --prefix src run canary:stagenet +``` + +The database preflight opens only a read-only PostgreSQL session. Run it while the freshly migrated database still contains no canary session: + +```bash +E2E_MODE=database-preflight \ +E2E_SCENARIO=direct-2x \ +E2E_EXPECT_DATABASE=monerogue_canary_direct_e2e \ +E2E_DATABASE_URL_FILE=/run/credentials/canary/direct-database-url \ +npm --prefix src run canary:stagenet +``` + +For the credits scenario, use `E2E_SCENARIO=credits-3x`, a database name containing `credits`, and its separate protected connection file. + +## Live direct 2× escape + +Review the configured direct entry atomic amount before setting `E2E_MAX_TRANSFER_ATOMIC`. The ceiling must be at least the advertised invoice amount and should equal that amount when practical. + +```bash +E2E_MODE=live-stagenet \ +E2E_SCENARIO=direct-2x \ +E2E_TARGET=http://127.0.0.1:3102 \ +E2E_EXPECT_DATABASE=monerogue_canary_direct_e2e \ +E2E_DATABASE_URL_FILE=/run/credentials/canary/direct-database-url \ +E2E_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce \ +E2E_FUNDING_RPC_URL=http://127.0.0.1:38083 \ +E2E_FUNDING_RPC_USER_FILE=/run/credentials/canary/funding-rpc-user \ +E2E_FUNDING_RPC_PASSWORD_FILE=/run/credentials/canary/funding-rpc-password \ +E2E_MAX_TRANSFER_ATOMIC=REPLACE_WITH_REVIEWED_ATOMIC_CEILING \ +E2E_FEE_CUSHION_ATOMIC=1000000000 \ +E2E_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_ONE_XMR_STAGENET_TRANSFER \ +E2E_SCENARIO_CONFIRM=DIRECT_2X_ESCAPE \ +E2E_CANARY_PROFILE=EASY_STATIC_MONSTER_ONE_LEVEL \ +npm --prefix src run canary:stagenet +``` + +The harness binds a fresh fairness offer to the direct invoice before revealing its address, transfers the exact invoice amount, waits for receipt-backed confirmation and automatic game start, avoids treasure, escapes, and verifies an exact 2× payout. + +## Live credits-package then 3× treasure escape + +Review the selected package's atomic price and set the ceiling accordingly. The default package id is `small`; override it with `E2E_CREDITS_PACKAGE_ID` only after confirming it is a pure credits-only package. + +```bash +E2E_MODE=live-stagenet \ +E2E_SCENARIO=credits-3x \ +E2E_CREDITS_PACKAGE_ID=small \ +E2E_TARGET=http://127.0.0.1:3102 \ +E2E_EXPECT_DATABASE=monerogue_canary_credits_e2e \ +E2E_DATABASE_URL_FILE=/run/credentials/canary/credits-database-url \ +E2E_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce \ +E2E_FUNDING_RPC_URL=http://127.0.0.1:38083 \ +E2E_FUNDING_RPC_USER_FILE=/run/credentials/canary/funding-rpc-user \ +E2E_FUNDING_RPC_PASSWORD_FILE=/run/credentials/canary/funding-rpc-password \ +E2E_MAX_TRANSFER_ATOMIC=REPLACE_WITH_REVIEWED_ATOMIC_CEILING \ +E2E_FEE_CUSHION_ATOMIC=1000000000 \ +E2E_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_ONE_XMR_STAGENET_TRANSFER \ +E2E_SCENARIO_CONFIRM=CREDITS_PACKAGE_THEN_3X_TREASURE_ESCAPE \ +E2E_CANARY_PROFILE=EASY_STATIC_MONSTER_ONE_LEVEL \ +npm --prefix src run canary:stagenet +``` + +The harness purchases the package, verifies the exact confirmed grant and balance update, consumes a new fairness offer in `auto_start`, spends exactly one configured game credit, collects treasure before allowing the exit, and verifies an exact 3× payout based on `CREDITS_PAYOUT_BASE` (not the package price). + +## Exact settlement assertions + +After the funding wallet observes the incoming payout, the harness checks the read-only database connection. A passing run has exactly: + +- two users: the migration-seeded admin and one canary identity; +- one confirmed native-Monero payment with `received_amount == expected_amount` and valid confirmation evidence; +- one unique confirmed `chain_output` receipt whose amount exactly equals the invoice; +- one won/escaped game with consumed entry evidence, committed 2×/3× immutable payout terms, the selected paid mode, matching fairness v2 identity, and the expected treasure flag; +- one completed payout with the exact amount, multiplier, reason, payout address, and transaction evidence; +- no refunds, late reviews, matches, match queues, race-entry ledgers, or race-entry lots. + +The direct scenario additionally requires exactly the `direct_entry +1` and `game_entry -1` credit-ledger rows, a zero balance, unified purchase progress of one, the game linked to the fairness-bound payment, and no product-entitlement marker. + +The credits scenario additionally requires exactly one payment-linked `package_purchase` grant, one `game_entry` debit, the exact final balance, one active payment-entitlement marker matching the advertised grant, and a `PAID_CREDITS` game with no direct `payment_id`. + +## Failure handling + +Before the line `one exact, non-retriable XMR stagenet transfer was broadcast`, a failure means no funding transfer was attempted. Correct the configuration, recreate a fresh scenario database if any app session was made, and repeat the preflights. + +At or after that line, never rerun the command and never reuse the database. A transport timeout can be ambiguous even when the wallet accepted the transfer. Preserve the database and both wallet files, stop the isolated canary, and reconcile the invoice receipt, payment, game, payout, and wallet histories manually. The harness intentionally performs no automatic retry, refund, database cleanup, wallet cleanup, or destructive recovery. + +Only after both scenarios pass on separate databases should the release be considered financially canary-tested. Passing does not authorize deployment; deployment and router/fleet changes remain separate reviewed operations. + +## Local verification (no network or wallet use) + +```bash +node --check src/scripts/stagenet-financial-canary.js +npm --prefix src test -- --runTestsByPath ../test/canaryDatabaseIdentity.test.js ../test/stagenetFinancialCanary.test.js +npm --prefix src run canary:stagenet -- --help +``` + +These commands parse the harness, run its pure/static safety tests, and print help. They do not contact the application, database, or either wallet. diff --git a/docs/TAVERN_AND_MULTIPLAYER.md b/docs/TAVERN_AND_MULTIPLAYER.md index c0c3729..48932f3 100644 --- a/docs/TAVERN_AND_MULTIPLAYER.md +++ b/docs/TAVERN_AND_MULTIPLAYER.md @@ -1,18 +1,18 @@ # Tavern & Multiplayer — Design & Build Plan -Match mode (multiplayer races) now has its own dedicated design doc: see `docs/MATCH_MODE.md`. +Match mode has its own dedicated design doc: see `docs/MATCH_MODE.md`. -**Status:** Tavern is complete; multiplayer race mode is implemented (see `docs/MATCH_MODE.md`). -PvP combat remains a future extension. Both modes are disabled by default and gated by config, so -existing single-player play is unaffected. +**Status:** Tavern and server-selected multiplayer rulesets (`race`, `last-alive`, `score-attack`, +`coop-escape`) are implemented (see `docs/MATCH_MODE.md`). Both surfaces are disabled by default +and gated by config, so existing single-player play is unaffected. ## Shared engine: one Room, two modes Both modes are built on a single shared component — a real-time, multi-occupant `Room`: - **Tavern** — a peaceful Room: players pick an avatar, walk around, chat, and watch live games. -- **Multiplayer** — the same Room engine with a dungeon, monsters, hazards, and player-vs-player - combat added. +- **Multiplayer** — the same Room engine with a dungeon, monsters, and ruleset-selected + collision/combat behavior. The engine (`src/multiplayer/`) has no Socket.IO, database, or Wownerogue coupling. A manager layer owns transport (Socket.IO rooms, broadcasts) and the server-tick timer. Keeping the engine @@ -24,35 +24,36 @@ transport-agnostic makes it unit-testable in isolation and reusable as a standal choose an avatar, move around a shared map, and chat. A spectator camera lets them watch live single-player or multiplayer games from inside the room. -**Multiplayer Race** — a shared match Room. Players race to escape a dungeon before the next block -and contend with a shared monster. Match rooms enable occupant collision; the Tavern does not. -PvP combat is planned for a later milestone. +**Multiplayer Match** — a shared dungeon Room using the operator-selected ruleset. Players may race +to escape, fight to remain last alive, compete for score, or cooperate. Block-bounded rulesets end +on the first advancing header after their active-play duration floor; every match also has a hard +ceiling. Match rooms enable the collision/combat rules selected by the ruleset; the Tavern does not. ## Operator Policy The server operator controls which modes are enabled and, for multiplayer, the economic model. -Configuration is driven through the existing `config/` layer (DB-backed, hot-reloadable) rather -than static env vars alone, so operators can adjust settings without a restart. A later extension -allows per-room settings. +Mode activation, the multiplayer ruleset, and multiplayer economy switches are startup environment +configuration. Change `SOLO_ENABLED`, `TAVERN_ENABLED`, `MATCH_ENABLED`, `MATCH_RULESET_ID`, or the +`MATCH_*` economy settings in the deployment configuration and restart the service. The separate +database-backed `ConfigPersistence` allowlist covers a limited set of solo payment/difficulty +values; it does not hot-reload match modes or rulesets. There is no per-room ruleset editor today. Which modes are enabled is surfaced to clients in the `game_mode_info` event as -`modes: { solo, tavern, multiplayer }` (from `SOLO_ENABLED` / `TAVERN_ENABLED` / -`MULTIPLAYER_ENABLED`). The client shows or hides entry points from this, so any single mode can -run on its own — including a Tavern-only instance. Solo is on unless explicitly disabled; -Tavern and Multiplayer are opt-in. +`modes: { solo, tavern, match }` (from `SOLO_ENABLED`, `TAVERN_ENABLED`, and `MATCH_ENABLED`). +The client shows or hides entry points from this, so any single mode can run on its own — including +a Tavern-only instance. Solo is on unless explicitly disabled; Tavern and Match are opt-in. Multiplayer economic models (operator-selected per instance): | Model | Description | |---|---| -| Free | No entry cost or payout. | -| Entry fee, leaderboard only | Players pay to enter; results affect a leaderboard, no payout. | -| Winner-take-pot | Entrants stake into a pot awarded to the winner. | -| Even-return | Entrants recover their stake; only a flat fee is deducted. | +| `free` | No entry cost or payout; competitive results use the Free/Pleb board. | +| `credits_prestige` | Spend credits; competitive results use the separate PvP Prestige board, with no crypto payout. | +| `crypto_race` | Use a backed race-entry ticket; an eligible competitive winner receives the disclosed pot less the configured fee. | -The default configuration is conservative (free, no payout), matching the current wownerogue -instance. Operators are responsible for ensuring their chosen configuration complies with -applicable laws in their jurisdiction. +The baseline defaults are conservative (free, no payout). Instance deployment configuration may +enable additional economies; operators are responsible for ensuring their selected configuration +complies with applicable laws in their jurisdiction. ## Avatars @@ -147,7 +148,8 @@ can be swapped later without changing callers. The current backend remains the d Rendering goes through a shared, renderer-agnostic **scene model**: game/tavern state is adapted into a `Scene` (a tile grid + entities), and any renderer draws it. This lives in -`html/js/render/` and is used by both the Tavern and (planned) the main game. +`html/js/render/` and is used by the Tavern, multiplayer client, spectators, and the single-player +render bridge. Render tiers: - **ASCII** — monospace glyph grid (canvas). Always-available fallback; accessible. @@ -201,22 +203,21 @@ Tavern: - **T1** — `TavernManager`: Socket.IO room, server-tick timer, join/move/leave broadcasts; minimal client (`html/tavern.html`) rendering avatars; gated behind `TAVERN_ENABLED`. (Done) - **T2a** — `ChatProvider` seam + tavern chat. (Done) -- **T2b** — Spectator camera into live games from the Tavern. -- **T3** — Avatar picker with policy-gated unlocks. +- **T2b** — Spectator camera into live solo and multiplayer games from the Tavern. (Done) +- **T3** — Avatar picker with policy-gated unlocks. (Done) Multiplayer: -- **M0** — Match Room with a dungeon and shared monsters; players race the block. Includes a load - test with many simulated clients to measure server-tick performance under concurrency. -- **M1** — Player-vs-player combat. +- **M0** — Match Room with a dungeon, shared monster, server ticks, and bounded lifecycle. (Done) +- **M1** — Player-vs-player combat for the `last-alive` ruleset. (Done) - **M2** — Environmental hazards. -- **M3** — Operator Policy economic models wired in. +- **M3** — Free, credit-prestige, and gated crypto-race economies wired in. (Done) Rendering: - **R0** — Shared render kit: scene model + ASCII / Tiled / Fancy (PixiJS) renderers + mode switch, wired to the Tavern. Premium-mode gating scaffolded. (Done) -- **R1** — Wire the main game to the render kit (refactor the `display/` layer behind it). +- **R1** — Wire the main game to the render kit behind the existing display layer. (Done) - **R2** — Real tilesets / sprite sheets and the Fancy-ASCII tier. -- **R3** — Wire premium render modes to credit entitlements (Operator Policy). +- **R3** — Wire premium render modes to credit entitlements (Operator Policy). (Done) All milestones are additive and config-gated; the single-player path is unchanged. @@ -232,18 +233,18 @@ All milestones are additive and config-gated; the single-player path is unchange and Socket.IO room broadcasts. Wired into `SocketHandlers` (`tavern_join` / `tavern_move` / `tavern_leave`, plus disconnect cleanup and shutdown). Inert unless `TAVERN_ENABLED=true`. -`html/tavern.html` — a minimal self-contained client: connect, choose a name/avatar, enter the -room, walk with arrow keys / WASD, and chat. Renders the map and occupants on a canvas. +`html/tavern.html` — the browser client: connect, choose a name/avatar, enter the room, walk with +keyboard or on-screen controls, chat, watch solo/multiplayer games, or join a match. `src/network/chat/` — the `ChatProvider` seam. `ChatProvider` (interface) + `SocketChatProvider` (default: Socket.IO delivery + Postgres history). Both the global chat (`ChatHandler`) and tavern chat deliver through it, so the backend can be swapped (e.g. a Nostr channel) without changing callers. Tavern chat is room-scoped and ephemeral. -`html/js/render/` — the render kit: `sceneModel.js` (renderer-agnostic scene + tavern adapter), -`asciiRenderer.js`, `tileRenderer.js`, `fancyRenderer.js` (PixiJS), and `renderModes.js` -(registry, factory with graceful fallback, entitlements, persistence). The tavern client uses it -with a mode toolbar; premium modes are marked and gated by `RK.entitlements`. +`html/js/render/` — the render kit: `sceneModel.js` (renderer-agnostic adapters), ASCII, tiled, +Fancy (PixiJS), isometric, and Three.js renderers, plus `renderModes.js` (registry, factory with +graceful fallback, entitlements, persistence). Clients expose a mode toolbar where applicable; +premium modes are marked and gated by `RK.entitlements`. Tests: `test/tavernRoom.test.js` (engine), `test/tavernManager.test.js` (manager incl. chat), `test/chatProvider.test.js`, `test/renderScene.test.js` (scene adapter), diff --git a/docs/TODO.md b/docs/TODO.md index 1840d4c..5ecaea1 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -28,8 +28,11 @@ Done: Pending: - [ ] **Multi-level calibration run**: lock per-network level counts against a target house-win (sim multi-level support is in; counts are currently starting points). -- [ ] **Block-bounded pot race** (flagship): consume `Ruleset.timing.blockDeadline`; add pot+rake economy (house = zero-risk rake-taker). Decisions pending: empty-pot rule, prize split. -- [ ] **PvP wiring**: connect the already-built `last-alive`/`score-attack` rulesets to a real queue. +- [x] **Block-bounded pot race** (flagship): `Ruleset.timing.blockDeadline` expires active matches + on the first advancing header after the play-time floor; winner-take-pot-minus-fee admission + is durable and co-op is excluded until split-payout semantics exist. +- [x] **PvP wiring**: the server selects `race`, `last-alive`, `score-attack`, or `coop-escape` + with `MATCH_RULESET_ID`; match state and persistence record the active ruleset. - [ ] **Hazard gameplay**: generator places lava/poison/spikes (FX already renders them) + damage stakes; escalate per level. - [ ] **Client polish**: "Level N/M" indicator + "descended" toast (server sends `depth`/`maxDepth` + `game_event: descend`). diff --git a/html/admin.html b/html/admin.html index fe023db..5bf43c4 100644 --- a/html/admin.html +++ b/html/admin.html @@ -4,7 +4,6 @@ Admin Dashboard - Wowngeon - + + + + + + +
-

🏁 Race Queue

-

Multiplayer dungeon race. A new race starts every block when at least two players are queued.

+
+
WOWNEROGUE / ARENA
+ +
+ +
+
Operator-selected live mode
+

Dungeon Race

+

Outrun the other delvers, survive the shared dungeon, and reach the exit first.

+
FIRST TO EXIT
+
-
Select economy:
-
-
Connect to server to see available economies.
+

Choose your entry mode

+
Free, Prestige, and Champions results remain on separate boards.
+
+
Connecting to the arena…
-
+
+ diff --git a/html/styles/commerce-consent.css b/html/styles/commerce-consent.css new file mode 100644 index 0000000..3c5a9f0 --- /dev/null +++ b/html/styles/commerce-consent.css @@ -0,0 +1,13 @@ +.commerce-consent-overlay { position:fixed; inset:0; z-index:100000; display:grid; place-items:center; padding:16px; background:rgba(0,0,0,.82); } +.commerce-consent-dialog { width:min(620px,100%); max-height:calc(100vh - 32px); overflow:auto; padding:20px; border:1px solid #9a6b25; border-radius:10px; background:#11161b; color:#d7dde5; box-shadow:0 24px 80px #000; font:14px/1.45 system-ui,-apple-system,sans-serif; } +.commerce-consent-dialog h2 { margin:0 0 10px; color:#fff; font-size:1.4rem; } +.commerce-summary { padding:10px 12px; border-left:3px solid #efb64b; background:#211a0f; } +.commerce-check { display:flex; align-items:flex-start; gap:10px; margin:10px 0; padding:10px; border:1px solid #303944; border-radius:6px; background:#171d24; cursor:pointer; } +.commerce-check input { flex:0 0 auto; width:18px; height:18px; margin-top:1px; } +.commerce-links,.commerce-links a { color:#99e9a8; } +.commerce-status { color:#96a0ad; font-size:12px; } +.commerce-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:14px; } +.commerce-actions button,.commerce-consent-dialog>button { padding:8px 12px; border:1px solid #46515e; border-radius:6px; font:inherit; cursor:pointer; } +.commerce-cancel { background:#202730; color:#d7dde5; } +.commerce-accept { background:#55d96b; color:#071209; font-weight:700; } +.commerce-accept:disabled { opacity:.45; cursor:not-allowed; } diff --git a/html/styles/legal.css b/html/styles/legal.css new file mode 100644 index 0000000..d793f3e --- /dev/null +++ b/html/styles/legal.css @@ -0,0 +1,18 @@ +:root { color-scheme: dark; --bg:#090b0f; --panel:#14191f; --line:#303944; --text:#d7dbe0; --muted:#96a0ad; --accent:#64db79; --gold:#efb64b; } +* { box-sizing: border-box; } +body { margin:0; min-height:100vh; background:radial-gradient(circle at 50% -15%,#25352a 0,transparent 38%),var(--bg); color:var(--text); font:16px/1.55 system-ui,-apple-system,sans-serif; } +main { width:min(860px,calc(100% - 28px)); margin:0 auto; padding:24px 0 48px; } +nav { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:28px; } +nav a,a { color:#9eeaac; } +nav a { padding:6px 10px; border:1px solid var(--line); border-radius:6px; text-decoration:none; } +nav a:hover,nav a:focus-visible { border-color:var(--accent); } +header { margin-bottom:20px; } +h1 { margin:.15em 0; color:#fff; font-size:clamp(2rem,6vw,3.4rem); line-height:1; } +h2 { color:#fff; font-size:1.15rem; } +.eyebrow { margin:0; color:var(--accent); font-size:.75rem; font-weight:700; letter-spacing:.15em; text-transform:uppercase; } +header>p:last-child,footer { color:var(--muted); } +section { margin:12px 0; padding:18px clamp(16px,4vw,24px); border:1px solid var(--line); border-radius:10px; background:rgba(20,25,31,.96); } +.notice { border-color:#7a5c28; background:rgba(78,55,16,.24); } +li+li { margin-top:.45rem; } +footer { padding:12px 4px; } +strong { color:#fff; } diff --git a/html/tavern.html b/html/tavern.html index 287d71f..6a40464 100644 --- a/html/tavern.html +++ b/html/tavern.html @@ -3,11 +3,12 @@ - Tavern - Wownerogue - + Wownerogue — Tavern + + + - + @@ -26,35 +27,47 @@ + +
-

Tavern — early preview

+
+

🔥 The Tavern social hall

+ Responsible Play ← Lobby +
@@ -78,9 +108,15 @@ - -

-

Move with arrow keys or WASD. ★ modes are premium (credit unlock).

+ + +

+

Move with arrow keys or WASD · drag/pinch to explore · ★ may require credits · ⛔ is unavailable on this server.

- +
+ + +
- @@ -131,6 +176,7 @@ } catch (_) {} var socket = io(socketOpts); var RK = window.RK; + var MatchCopy = window.MatchUiCopy; var statusEl = document.getElementById('status'); var joinPanel = document.getElementById('joinPanel'); @@ -141,6 +187,18 @@ var chatLog = document.getElementById('chatLog'); var chatForm = document.getElementById('chatForm'); var chatInput = document.getElementById('chatInput'); + var chatPanel = document.getElementById('chatPanel'); + var movementHint = document.getElementById('movementHint'); + var tavernDpad = document.getElementById('tavernDpad'); + var watchGamesBtn = document.getElementById('watchGamesBtn'); + var spectatorPanel = document.getElementById('spectatorPanel'); + var gameList = document.getElementById('gameList'); + var closeGameListBtn = document.getElementById('closeGameListBtn'); + var spectatorStage = document.getElementById('spectatorStage'); + var spectatorLabel = document.getElementById('spectatorLabel'); + var spectatorRender = document.getElementById('spectatorRender'); + var spectatorDpad = document.getElementById('spectatorDpad'); + var leaveSpectateBtn = document.getElementById('leaveSpectateBtn'); // Character customization — persisted appearance identity, chosen before entering. var customizeBtn = document.getElementById('customizeBtn'); @@ -165,6 +223,19 @@ var currentScene = null; var tavernLife = null; var rafActive = false; + var mainCamera = null; + var rendererRequest = 0; + var cryptoType = 'WOW'; + var lastGameModeInfo = null; + var spectatorKind = null; // solo | match | player-match + var spectatorRenderer = null; + var spectatorCamera = null; + var spectatorPendingState = null; + var spectatorRequest = 0; + var currentSpectatingMatchId = null; + var activeMatchSnapshots = {}; + var activeMatchTickMs = 250; + var currentMatchRuleset = MatchCopy.normalize({ id: 'race' }); function setStatus(msg) { statusEl.textContent = msg; } function applyEntitlements(data) { @@ -185,7 +256,10 @@ if (!currentScene) return; // Inject client-side NPCs (bartender, patrons, cat) into the scene. if (tavernLife) tavernLife.injectInto(currentScene); - if (currentRenderer) currentRenderer.render(currentScene); + if (currentRenderer) { + currentRenderer.render(currentScene); + if (mainCamera) mainCamera.update(currentRenderer); + } } // rAF loop for smooth NPC animation + speech bubbles. @@ -199,13 +273,16 @@ if (!currentScene._npcInjected) tavernLife.injectInto(currentScene); tavernLife.update(currentScene, currentRenderer && currentRenderer.cell || 24, currentMode || 'tiles'); } - root.requestAnimationFrame(loop); + window.requestAnimationFrame(loop); } - root.requestAnimationFrame(loop); + window.requestAnimationFrame(loop); } function lockedModeMessage(mode) { var meta = RK.modeMeta ? RK.modeMeta(mode) : null; var label = (meta && meta.label) || mode; + if (RK.isModeRuntimeAvailable && !RK.isModeRuntimeAvailable(mode)) { + return label + ' is unavailable on this server because its optional renderer dependency is disabled.'; + } if (meta && meta.pack && RK.pack) { var pack = RK.pack(meta.pack); return label + ' is locked. Buy any credits or unlock ' + ((pack && pack.label) || 'the pack') + '.'; @@ -214,6 +291,7 @@ } function setMode(mode) { if (!RK.canUseMode(mode)) { setStatus(lockedModeMessage(mode)); return; } + var request = ++rendererRequest; if (currentRenderer) { currentRenderer.destroy(); currentRenderer = null; } currentMode = mode; if (tavernLife) tavernLife.setMode(mode); @@ -222,12 +300,21 @@ if (mode === 'fancy' && !RK.pixiReady()) setStatus('Loading Fancy renderer…'); // Async: Fancy lazy-loads PixiJS on first use. RK.createRendererAsync(mode, stageEl, {}, function (r) { - if (currentMode !== mode) { r.destroy(); return; } // switched again while loading + if (currentMode !== mode || request !== rendererRequest) { r.destroy(); return; } // switched while loading currentRenderer = r; draw(); - if (RK.attachZoom) { RK.attachZoom(stageEl); RK.applyZoom(stageEl); } // wheel/pinch zoom, all modes + var activeMode = r.name || mode; + var defaultZoom = activeMode === 'iso' ? 0.8 : (activeMode === '3d' ? 1.05 : 1.25); + if (!mainCamera && RK.attachCamera) { + mainCamera = RK.attachCamera(stageEl, { zoom: defaultZoom, fitRatio: 0.75, min: 0.5, max: 3.2 }); + } + if (mainCamera) { + if (mainCamera.setDefaultZoom) mainCamera.setDefaultZoom(defaultZoom); + if (activeMode === '3d' || !mainCamera.fit) mainCamera.setZoom(defaultZoom); + else mainCamera.fit(r, 0.75); + } buildPackbar(); // show pack choices for this mode's projection - var active = r.name || mode; + var active = activeMode; setStatus(active === mode ? 'Render mode: ' + active : '“' + mode + '” unavailable here — using “' + active + '”.'); @@ -236,10 +323,11 @@ function buildModebar() { modebar.innerHTML = ''; RK.RENDER_MODES.forEach(function (m) { + var runtimeAvailable = !RK.isModeRuntimeAvailable || RK.isModeRuntimeAvailable(m.id); var b = document.createElement('button'); b.className = 'modebtn'; b.dataset.mode = m.id; - b.textContent = m.label + (m.premium ? ' ★' : ''); + b.textContent = m.label + (!runtimeAvailable ? ' ⛔' : (m.premium ? ' ★' : '')); if (!RK.canUseMode(m.id)) { b.classList.add('locked'); b.title = lockedModeMessage(m.id); } b.addEventListener('click', function () { setMode(m.id); }); modebar.appendChild(b); @@ -299,8 +387,9 @@ setStatus((e && e.message) || 'Could not enter the tavern.'); }); socket.on('game_mode_info', function (data) { + lastGameModeInfo = data || null; if (data && data.entitlements) applyEntitlements(data.entitlements); - if (data && data.cryptoType) SocketHandlers_cryptoType = data.cryptoType; + if (data && data.cryptoType) cryptoType = data.cryptoType; }); socket.on('credits_update', applyEntitlements); socket.on('session_token', function (data) { @@ -329,7 +418,8 @@ joinPanel.classList.add('hidden'); modebar.classList.remove('hidden'); stageEl.classList.remove('hidden'); - document.getElementById('chatPanel').classList.remove('hidden'); + chatPanel.classList.remove('hidden'); + tavernDpad.classList.remove('hidden'); rebuildScene(); buildModebar(); setMode(RK.loadMode('tiles')); @@ -435,20 +525,171 @@ ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', w: 'up', s: 'down', a: 'left', d: 'right', W: 'up', S: 'down', A: 'left', D: 'right' }; + function sendDirection(dir) { + if (spectatorKind === 'player-match') { + var delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[dir]; + if (delta) socket.emit('match_move', { dx: delta[0], dy: delta[1] }); + return; + } + if (!spectatorKind && state.you) socket.emit('tavern_move', { dir: dir }); + } window.addEventListener('keydown', function (e) { if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return; var dir = KEYS[e.key]; - if (!dir || !state.you) return; + if (!dir || (!state.you && spectatorKind !== 'player-match')) return; e.preventDefault(); - socket.emit('tavern_move', { dir: dir }); + sendDirection(dir); }); + function bindDpad(el) { + if (!el) return; + Array.prototype.forEach.call(el.querySelectorAll('[data-dir]'), function (b) { + // Native click includes touch/mouse plus Enter and Space for keyboard and AT users. + b.addEventListener('click', function () { sendDirection(b.dataset.dir); }); + }); + } + bindDpad(tavernDpad); + bindDpad(spectatorDpad); setStatus('Connecting…'); socket.on('connect', function () { socket.emit('register_client', { clientId: socket.id, userAgent: navigator.userAgent }); socket.emit('identity:get'); + socket.emit('match_reconnect', {}); setStatus('Connected. Choose a name and enter.'); }); + + // ---- Shared spectator viewport (solo runs + public PvP races) ----------------- + function spectatorZoom(mode) { return mode === 'iso' ? 0.78 : (mode === '3d' ? 1.05 : 1.3); } + function destroySpectatorRenderer() { + spectatorRequest++; + if (spectatorRenderer && spectatorRenderer.destroy) { + try { spectatorRenderer.destroy(); } catch (_) {} + } + spectatorRenderer = null; + } + function renderSpectatorState(gameState) { + if (!gameState) return; + spectatorPendingState = gameState; + if (!spectatorRenderer) return; + var safeState = Object.assign({}, gameState, { isSpectating: spectatorKind !== 'player-match' }); + var scene = RK.sceneFromGameState ? RK.sceneFromGameState(safeState, { + viewerId: spectatorKind === 'player-match' ? socket.id : null, + cryptoType: cryptoType + }) : null; + if (!scene) return; + spectatorRenderer.render(scene); + if (spectatorCamera) spectatorCamera.update(spectatorRenderer); + } + function openSpectatorView(kind, label, initialState) { + spectatorKind = kind; + spectatorPendingState = initialState || null; + joinPanel.classList.add('hidden'); + stageEl.classList.add('hidden'); + modebar.classList.add('hidden'); + packbar.classList.add('hidden'); + tavernDpad.classList.add('hidden'); + movementHint.classList.add('hidden'); + spectatorPanel.classList.add('hidden'); + matchPanel.classList.add('hidden'); + spectatorStage.classList.remove('hidden'); + spectatorDpad.classList.toggle('hidden', kind !== 'player-match'); + spectatorLabel.textContent = label || 'Live dungeon'; + leaveSpectateBtn.textContent = kind === 'player-match' ? 'Forfeit match' : '← Back to tavern'; + destroySpectatorRenderer(); + var request = ++spectatorRequest; + var renderMode = currentMode || (RK.loadMode && RK.loadMode('tiles')) || 'tiles'; + RK.createRendererAsync(renderMode, spectatorRender, {}, function (r) { + if (request !== spectatorRequest || !spectatorKind) { r.destroy(); return; } + spectatorRenderer = r; + if (!spectatorCamera && RK.attachCamera) { + spectatorCamera = RK.attachCamera(spectatorRender, { zoom: spectatorZoom(r.name || renderMode), min: 0.5, max: 3.2 }); + } + if (spectatorCamera) spectatorCamera.setZoom(spectatorZoom(r.name || renderMode)); + renderSpectatorState(spectatorPendingState); + }); + } + function closeSpectatorView(notifyServer) { + var previous = spectatorKind; + if (notifyServer && previous === 'solo') socket.emit('leave_spectate'); + spectatorKind = null; + currentSpectatingMatchId = null; + spectatorPendingState = null; + destroySpectatorRenderer(); + spectatorStage.classList.add('hidden'); + spectatorDpad.classList.add('hidden'); + if (state.you) { + stageEl.classList.remove('hidden'); + modebar.classList.remove('hidden'); + tavernDpad.classList.remove('hidden'); + movementHint.classList.remove('hidden'); + buildPackbar(); + draw(); + } else { + joinPanel.classList.remove('hidden'); + } + leaveSpectateBtn.textContent = '← Back to tavern'; + } + + function populateSoloGames(data) { + gameList.innerHTML = ''; + var games = data && Array.isArray(data.games) ? data.games : []; + if (!games.length) { + gameList.textContent = 'No live solo runs right now.'; + return; + } + games.forEach(function (game) { + var row = document.createElement('div'); + row.className = 'gamerow'; + var text = document.createElement('span'); + var seconds = Number(game.durationSeconds) || 0; + text.textContent = 'Delver ' + (game.playerId || '??????') + ' · ' + (game.difficulty || 'normal') + ' · ' + seconds + 's'; + var watch = document.createElement('button'); + watch.type = 'button'; watch.textContent = 'Watch'; watch.className = 'secondary'; + watch.addEventListener('click', function () { + setStatus('Joining live solo run…'); + socket.emit('spectate_game', { gameId: game.gameId }); + }); + row.appendChild(text); row.appendChild(watch); gameList.appendChild(row); + }); + } + + watchGamesBtn.addEventListener('click', function () { + spectatorPanel.classList.toggle('hidden'); + if (!spectatorPanel.classList.contains('hidden')) { + gameList.textContent = 'Loading live runs…'; + socket.emit('get_active_games', { page: 1, pageSize: 20 }); + } + }); + closeGameListBtn.addEventListener('click', function () { spectatorPanel.classList.add('hidden'); }); + socket.on('active_games', populateSoloGames); + socket.on('spectate_start', function (data) { + if (!data || !data.initialState) return; + openSpectatorView('solo', '👁 Watching delver ' + (data.playerId || 'live'), data.initialState); + setStatus('Watching a live solo run.'); + }); + socket.on('spectator_update', function (data) { + if (spectatorKind === 'solo' && data && data.gameState) renderSpectatorState(data.gameState); + }); + socket.on('spectate_ended', function (data) { + if (spectatorKind !== 'solo') return; + closeSpectatorView(false); + setStatus(data && data.reason === 'game_over' ? 'That run has ended.' : 'Back in the tavern.'); + }); + socket.on('spectate_error', function (data) { + setStatus((data && data.message) || 'Could not watch that run.'); + }); + leaveSpectateBtn.addEventListener('click', function () { + if (spectatorKind === 'player-match') { + if (!window.confirm('Forfeit this match and return to the tavern?')) return; + socket.emit('match_leave'); + closeSpectatorView(false); + setStatus('You forfeited the match.'); + return; + } + closeSpectatorView(spectatorKind === 'solo'); + setStatus('Back in the tavern.'); + }); + // ---- Tavern Match spectator / queue UI ------------------------------- var matchQueueBtn = document.getElementById('matchQueueBtn'); var matchPanel = document.getElementById('matchPanel'); @@ -458,13 +699,24 @@ var leaveMatchQueueBtn = document.getElementById('leaveMatchQueueBtn'); var activeMatchList = document.getElementById('activeMatchList'); var closeMatchPanelBtn = document.getElementById('closeMatchPanelBtn'); + var tavernMatchTitle = document.getElementById('tavernMatchTitle'); + var tavernMatchDescription = document.getElementById('tavernMatchDescription'); var selectedMatchEconomy = 'free'; var inMatchQueue = false; + var matchEndTimer = null; + + function activeRulesetFrom(info) { + var match = info && info.modes && info.modes.match || {}; + var raw = match.activeRuleset || { id: match.rulesetId || 'race' }; + return MatchCopy.normalize(raw); + } function buildMatchEconomies(info) { if (!info || !info.modes || !info.modes.match || !info.modes.match.enabled) return; var economies = info.modes.match.economies || {}; - var labels = { free: 'Free', credits_prestige: 'Prestige', crypto_race: 'Crypto' }; + var labels = { free: 'Pleb · Free', credits_prestige: 'Prestige · Credits', crypto_race: 'Stagenet · Stakes' }; + var available = Object.keys(labels).filter(function (key) { return !!economies[key]; }); + if (!economies[selectedMatchEconomy] && available.length) selectedMatchEconomy = available[0]; matchEconomies.innerHTML = ''; Object.keys(labels).forEach(function (key) { if (!economies[key]) return; @@ -478,20 +730,29 @@ }); matchEconomies.appendChild(b); }); + joinMatchQueueBtn.disabled = available.length === 0; } socket.on('game_mode_info', function (data) { - // Extend existing handler: also build match economies. - if (data && data.entitlements) applyEntitlements(data.entitlements); - if (data && data.cryptoType) SocketHandlers_cryptoType = data.cryptoType; + lastGameModeInfo = data || lastGameModeInfo; + if (data && data.cryptoType) cryptoType = data.cryptoType; if (data && data.modes && data.modes.match && data.modes.match.enabled) { + var ruleset = activeRulesetFrom(data); + tavernMatchTitle.textContent = '🎮 ' + ruleset.label; + tavernMatchDescription.textContent = ruleset.description + (ruleset.cooperative + ? ' This is a team result, not an individual leaderboard win.' + : ' Choose a separate leaderboard below.'); + matchQueueBtn.textContent = '🎮 ' + ruleset.label; matchQueueBtn.classList.remove('hidden'); buildMatchEconomies(data); } }); socket.on('match_queue_joined', function (res) { + res = res || {}; + joinMatchQueueBtn.disabled = false; if (res.success) { + selectedMatchEconomy = res.economy || selectedMatchEconomy; inMatchQueue = true; joinMatchQueueBtn.classList.add('hidden'); leaveMatchQueueBtn.classList.remove('hidden'); @@ -502,6 +763,7 @@ }); socket.on('match_queue_left', function (res) { + res = res || {}; inMatchQueue = false; joinMatchQueueBtn.classList.remove('hidden'); leaveMatchQueueBtn.classList.add('hidden'); @@ -510,39 +772,134 @@ socket.on('tavern_match_list', function (list) { activeMatchList.innerHTML = ''; + activeMatchSnapshots = {}; if (!list || list.length === 0) { - activeMatchList.textContent = 'No active races right now.'; + activeMatchList.textContent = 'No active multiplayer matches right now.'; return; } list.forEach(function (m) { + if (!m || !m.matchId) return; + activeMatchSnapshots[m.matchId] = m; var row = document.createElement('div'); row.className = 'gamerow'; - row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--line);'; - row.innerHTML = '' + (m.economy || 'race') + ' — ' + (m.players ? m.players.length : 0) + ' players '; + var summary = document.createElement('span'); + var mode = MatchCopy.fromPayload(m, { id: m.rulesetId || m.variant || 'race' }); + var count = m.players ? m.players.length : 0; + summary.textContent = mode.label + ' · ' + (m.economy || 'free') + ' · ' + count + ' ' + MatchCopy.plural(mode, count); + var watch = document.createElement('button'); + watch.type = 'button'; watch.className = 'watchMatchBtn secondary'; watch.dataset.matchid = m.matchId; watch.textContent = 'Watch'; + row.appendChild(summary); row.appendChild(watch); activeMatchList.appendChild(row); }); Array.prototype.forEach.call(activeMatchList.querySelectorAll('.watchMatchBtn'), function (b) { b.addEventListener('click', function () { var matchId = b.dataset.matchid; - // Switch spectator renderer to the public match state stream. - enterSpectatorMode(matchId); + var snap = activeMatchSnapshots[matchId]; + currentSpectatingMatchId = matchId; + currentMatchRuleset = MatchCopy.fromPayload(snap, { id: snap && (snap.rulesetId || snap.variant) || 'race' }); + var count = (snap && snap.players && snap.players.length) || 0; + openSpectatorView('match', '👁 ' + currentMatchRuleset.label + ' · ' + count + ' ' + MatchCopy.plural(currentMatchRuleset, count), snap); }); }); }); socket.on('tavern_match_tick', function (state) { if (currentSpectatingMatchId && state.matchId === currentSpectatingMatchId) { - var scene = RK.sceneFromGameState ? RK.sceneFromGameState(state, null) : null; - if (scene && spectatorRenderer) spectatorRenderer.render(scene); + renderSpectatorState(state); + currentMatchRuleset = MatchCopy.fromPayload(state, currentMatchRuleset); + spectatorLabel.textContent = '👁 ' + currentMatchRuleset.label + ' · ' + + MatchCopy.liveSummary(state, null, currentMatchRuleset) + ' · tick ' + (state.tick || 0); } }); socket.on('tavern_match_end', function (data) { if (currentSpectatingMatchId && data.matchId === currentSpectatingMatchId) { - spectatorLabel.textContent = 'Race ended. Winner: ' + (data.winnerId ? data.winnerId.slice(0, 6) : 'none'); + var priorPlayers = spectatorPendingState && spectatorPendingState.players || []; + var namedPlayers = (data.players || []).map(function (player) { + var prior = priorPlayers.filter(function (candidate) { return candidate.id === player.id; })[0]; + return Object.assign({}, prior || {}, player); + }); + var result = MatchCopy.finalResult(Object.assign({}, data, { players: namedPlayers }), null, currentMatchRuleset); + spectatorLabel.textContent = result.text; } }); + // A player can queue without leaving the tavern. When that queue becomes a match, reuse the + // same viewport as a first-class playable arena (local camera + movement), not a blind queue. + socket.on('match_joined', function (data) { + if (!data) return; + selectedMatchEconomy = data.economy || selectedMatchEconomy; + var ruleset = activeRulesetFrom({ modes: { match: { activeRuleset: data.ruleset || (lastGameModeInfo && lastGameModeInfo.modes && lastGameModeInfo.modes.match.activeRuleset) } } }); + currentMatchRuleset = ruleset; + var count = data.players ? data.players.length : 0; + matchQueueStatus.textContent = 'Match found: ' + ruleset.label + ' · ' + count + ' ' + MatchCopy.plural(ruleset, count) + ' · starting in ' + ((Number(data.countdownMs) || 0) / 1000).toFixed(1) + 's.'; + setStatus(ruleset.objective + ' Get ready…'); + }); + socket.on('match_start', function (data) { + data = data || {}; + if (matchEndTimer) clearTimeout(matchEndTimer); + activeMatchTickMs = Math.max(50, Number(data.tickMs) || 250); + currentSpectatingMatchId = data.matchId || null; + var ruleset = activeRulesetFrom({ modes: { match: { activeRuleset: data.ruleset || (lastGameModeInfo && lastGameModeInfo.modes && lastGameModeInfo.modes.match.activeRuleset) } } }); + currentMatchRuleset = ruleset; + openSpectatorView('player-match', '⚔️ ' + ruleset.label + ' · GO', null); + setStatus('Match live. ' + ruleset.objective); + }); + socket.on('match_rejoined', function (data) { + if (!data || !data.state) return; + currentSpectatingMatchId = data.matchId || data.state.matchId || null; + selectedMatchEconomy = data.state.economy || selectedMatchEconomy; + var ruleset = activeRulesetFrom({ modes: { match: { activeRuleset: data.state.ruleset || data.ruleset || (lastGameModeInfo && lastGameModeInfo.modes && lastGameModeInfo.modes.match.activeRuleset) } } }); + currentMatchRuleset = ruleset; + openSpectatorView('player-match', '⚔️ Rejoined ' + ruleset.label, data.state); + setStatus('Rejoined your active multiplayer match.'); + }); + socket.on('match_tick', function (data) { + if (!data || !data.state) return; + if (spectatorKind !== 'player-match') { + currentSpectatingMatchId = data.matchId || data.state.matchId || null; + openSpectatorView('player-match', '⚔️ Multiplayer match', data.state); + } else { + renderSpectatorState(data.state); + } + currentMatchRuleset = MatchCopy.fromPayload(data, currentMatchRuleset); + var elapsed = ((Number(data.tick) || Number(data.state.tick) || 0) * activeMatchTickMs / 1000).toFixed(1); + spectatorLabel.textContent = '⚔️ ' + currentMatchRuleset.label + ' · ' + elapsed + 's · ' + + MatchCopy.liveSummary(data.state, socket.id, currentMatchRuleset); + }); + socket.on('match_end', function (data) { + if (spectatorKind !== 'player-match') return; + data = data || {}; + var result = MatchCopy.finalResult(data, socket.id, currentMatchRuleset); + currentMatchRuleset = result.mode; + spectatorKind = 'match-result'; + spectatorDpad.classList.add('hidden'); + leaveSpectateBtn.textContent = '← Back to tavern'; + spectatorLabel.textContent = result.text; + setStatus(result.mode.cooperative + ? 'Team result recorded; co-op does not create an individual leaderboard winner.' + : 'Competitive result recorded on its separate leaderboard.'); + matchEndTimer = setTimeout(function () { + if (spectatorKind === 'match-result') closeSpectatorView(false); + }, 5000); + }); + socket.on('match_error', function (data) { + var message = data && (data.message || data.reason) || 'Could not update the multiplayer match.'; + matchQueueStatus.textContent = message; + setStatus(message); + }); + socket.on('commerce_ack_required', function (data) { + if (!window.CommerceConsent || !window.CommerceConsent.require) return; + var refreshed = window.CommerceConsent.reject + ? window.CommerceConsent.reject(data) + : Promise.resolve(); + refreshed.catch(function () { return null; }).then(function () { + window.CommerceConsent.require(function () { + matchQueueStatus.textContent = 'Disclosures acknowledged. Retry the paid queue when ready.'; + }); + }); + }); + matchQueueBtn.addEventListener('click', function () { matchPanel.classList.toggle('hidden'); if (!matchPanel.classList.contains('hidden')) socket.emit('tavern_match_list'); @@ -551,7 +908,19 @@ joinMatchQueueBtn.addEventListener('click', function () { if (inMatchQueue) return; - socket.emit('match_queue', { economy: selectedMatchEconomy, action: 'join' }); + function join(acknowledgement) { + joinMatchQueueBtn.disabled = true; + matchQueueStatus.textContent = 'Joining queue…'; + var payload = { economy: selectedMatchEconomy, action: 'join' }; + if (acknowledgement) payload.legalAcknowledgement = acknowledgement; + socket.emit('match_queue', payload); + setTimeout(function () { if (!inMatchQueue) joinMatchQueueBtn.disabled = false; }, 1500); + } + if (selectedMatchEconomy !== 'free' && window.CommerceConsent) { + window.CommerceConsent.require(join); + } else { + join(null); + } }); leaveMatchQueueBtn.addEventListener('click', function () { if (!inMatchQueue) return; @@ -560,7 +929,10 @@ // ---- End match UI ---------------------------------------------------- - socket.on('disconnect', function () { setStatus('Disconnected.'); }); + socket.on('disconnect', function () { + if (window.CommerceConsent && window.CommerceConsent.clear) window.CommerceConsent.clear(); + setStatus('Disconnected.'); + }); })(); diff --git a/scripts/deploy/README-monero-stagenet-parallel-candidate.md b/scripts/deploy/README-monero-stagenet-parallel-candidate.md new file mode 100644 index 0000000..c7de12e --- /dev/null +++ b/scripts/deploy/README-monero-stagenet-parallel-candidate.md @@ -0,0 +1,239 @@ +# Parallel Monero stagenet wallet candidate + +> **STATUS: DESIGN / NO-GO FOR HOST MUTATION OR PRODUCTION DEPLOYMENT.** +> +> The read-only preflight and disposable fixture test in this change are executable. The candidate +> unit is an inert, manual-only template. There is intentionally no password-capture, wallet-copy, +> credential-bundle, installer, activation, or cutover program yet. Do not improvise those steps +> from shell commands. No in-place destructive migration is shipped with this design; deleting an +> abandoned draft does not make the parallel candidate production-deployable. + +This is the smaller alternative to replacing the current wallet in place. It is fixed to the +Monero **stagenet** wallet used by `monerogue.app`. It has no mainnet or Wownero mode. + +The safety property is simple: the current wallet, unit, and application stay exactly as they are. +A stopped, consistent copy is opened under a new dedicated identity on a different loopback port. +Nothing can use that copy until one complete directory containing the wallet, protected +credentials, configuration, and readiness marker has been validated and atomically published. + +## What is safe to run now + +The local fixture uses only synthetic bytes below a temporary directory: + +```bash +python3 scripts/deploy/monero-stagenet-parallel-fixture-test.py +``` + +The fixed-path preflight is read-only. It deliberately does not read unit text, process argv, +wallet bytes, keys, passwords, or mnemonics. After independent review, it can check the current +legacy RPC: + +```bash +sudo python3 scripts/deploy/monero-stagenet-parallel-preflight.py legacy +``` + +Its `candidate-prereqs` scope additionally requires the new system identity, free candidate ports, +an unused candidate path, the root-owned candidate binary, and enough local space. Provisioning +that identity is a future approved change; the preflight does not create it. + +Neither command proves that secret capture, bundle creation, activation, backup recovery, or an +application cutover is safe. The service template must not be installed yet. + +## Fixed boundary + +| Purpose | Fixed value | +| --- | --- | +| Legacy unit | `monero-wallet-rpc.service` | +| Legacy wallet | `/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test` + `.keys` | +| Legacy RPC | `127.0.0.1:38083` | +| Network/daemon | Monero stagenet / `127.0.0.1:38081` | +| Candidate identity | `monerogue-wallet-candidate:monerogue-wallet-candidate` | +| Staging generation | `/var/lib/.monerogue-stagenet-wallet-candidate.staging` | +| Published generation | `/var/lib/monerogue-stagenet-wallet-candidate` | +| Candidate RPC | `127.0.0.1:38084`, Digest authentication required | +| Offline verification RPC | `127.0.0.1:39084`, temporary only | +| Candidate unit | `monerogue-stagenet-wallet-candidate.service`, manual only | + +The final bundle must be one directory on the same filesystem as its staging generation: + +```text +/var/lib/monerogue-stagenet-wallet-candidate/ root:root 0711 +├── READY root:root 0600, written last +├── STATE.json root:root 0600, non-secret phase/address metadata +├── expected-address root:root 0600, public address only +├── wallet/ candidate:candidate 0700 +│ ├── stagenet candidate:candidate 0600 +│ └── stagenet.keys candidate:candidate 0600 +├── config/ root:candidate 0710 +│ └── wallet-rpc.conf root:candidate 0640 +├── secrets/ root:root 0711 +│ ├── wallet-password root:candidate 0640 +│ └── rpc-auth.netrc root:root 0600 +└── integration/ root:root 0711 + └── candidate-app.env root:monerogue 0640 +``` + +Execute-only directory traversal permits each identity to open its one reviewed fixed path without +listing the directory. No file may be a symlink or have a link count other than one. The `READY` +marker contains only version, stagenet, `127.0.0.1:38084`, the public address, and a +`manual-only` flag—never a password, hash of a seed, private key, or wallet bytes. + +## Required staged procedure + +These are acceptance requirements for small future helpers, not commands to run today. + +### 1. Read-only inventory + +The preflight must pass immediately before a maintenance window. Separately record the public +wallet address without recording credentials. Confirm the current application and wallet remain +healthy and that `38084` and `39084` have no listener. + +The preflight intentionally does not inspect `/proc//cmdline`, because the current process +contains a password. That work belongs only in the bounded capture helper described next. + +### 2. Capture the already-exposed wallet password + +A future fixed-path helper may read the password only from the validated live process. It must: + +- run as root with `RLIMIT_CORE=0`, `PR_SET_DUMPABLE=0`, `umask 077`, and no tracing/logging; +- obtain only `MainPID` from systemd, then validate PID start time, `/proc//exe`, UID, cgroup, + and the sole PID-owned `127.0.0.1:38083` listener before and after capture; +- read NUL-delimited `/proc//cmdline` directly, never systemd unit text or `ExecStart` output; +- accept exactly one bounded `--password VALUE` or `--password=VALUE`, reject duplicates, + `--password-file`, NUL/newline data, or a process change; +- write only a new `root:root 0600` file below the unpublished staging generation using + `openat(O_NOFOLLOW|O_CREAT|O_EXCL)`, `fsync`, and retained directory descriptors; +- print only success/refusal, never arguments, password length, content, hash, or exception data; +- zero mutable buffers before exit; and +- leave an incomplete stage inert and refuse automatic overwrite or cleanup after any failure. + +The helper needs executable fixture coverage for both argv forms, duplicates, process replacement, +short writes, fsync failures, interruption, and restart with every possible partial file/state. A +static source test is not sufficient. + +### 3. One bounded stopped-wallet copy window + +Only a narrow wrapper may stop the legacy unit. It must acquire a fixed root-only lock and install +an `EXIT`, signal, and error trap that always attempts to restart the exact legacy unit. It must not +read or alter the unit. + +After stop, require `MainPID=0`, no listener on `38083`, and no other process descriptor open on +either source inode. Open every source/path component with retained directory descriptors and +`O_NOFOLLOW`; require `jw:jw 0600`, a single link, and stable inode/size/timestamps. Copy wallet and +keys with `O_EXCL` temporary files, `fsync`, byte-compare through retained descriptors, publish each +inside the unpublished stage with `renameat2(RENAME_NOREPLACE)`, and fsync its directory. + +The wrapper must restart the legacy unit whether the copy succeeds, fails, or is interrupted. A +successful window is not complete until the restarted RPC has the same PID ownership/listener +properties and returns the exact public address captured before stop. At no point may a helper +move, rename, chmod, chown, truncate, erase, or unlink the legacy wallet, keys, directory, or unit. + +### 4. Build and verify one unused bundle + +Generate a new RPC credential in a non-dumpable process. The wallet password and RPC credential +must be distinct. Keep all copies in the one unpublished generation; no `/etc` credential file or +application drop-in is needed. + +The protected wallet config must contain exactly these non-secret semantics plus one Digest login: + +```text +stagenet=1 +wallet-file=/var/lib/monerogue-stagenet-wallet-candidate/wallet/stagenet +password-file=/var/lib/monerogue-stagenet-wallet-candidate/secrets/wallet-password +rpc-bind-port=38084 +rpc-bind-ip=127.0.0.1 +daemon-address=127.0.0.1:38081 +rpc-login=monerogue-candidate: +non-interactive=1 +``` + +Before publication, open the copied wallet offline on temporary port `39084` as the candidate UID, +with supplementary groups and all capabilities cleared, `no_new_privs`, core dumps disabled, and +logs directed to `/dev/null`. Prove all of the following without printing response data: + +- the verifier PID owns the sole exact loopback listener; +- unauthenticated JSON-RPC returns HTTP 401; +- Digest-authenticated `get_address` returns HTTP 200 and the captured stagenet address; and +- termination removes the verifier and listener cleanly. + +Then verify every directory/file owner, mode, type, and link count. Write and fsync `STATE.json`, +write `READY` **last**, fsync every file and directory, atomically rename the whole staging +directory to the unused final name with `RENAME_NOREPLACE`, and fsync `/var/lib`. A crash before the +single directory rename leaves no final candidate. An existing final candidate is never replaced. + +### 5. Manual candidate validation + +Only after an independent reviewer accepts the helper and crash evidence may the inert service +template be installed as `/etc/systemd/system/monerogue-stagenet-wallet-candidate.service`. It has +no `[Install]` section, no boot target, no restart loop, no relationship to `monerogue.service`, and +requires the exact readiness marker. + +Start it manually, then prove systemd MainPID/executable/UID/cgroup agreement, the sole +`127.0.0.1:38084` listener owned by that PID, HTTP 401 without auth, HTTP 200 with the protected +netrc, and the sealed public address. The legacy RPC and live app must remain healthy throughout. + +### 6. Candidate application canary + +The bundle may contain one `root:monerogue 0640` environment override with candidate endpoint and +Digest credentials. It must also force: + +```text +HOST=127.0.0.1 +PORT=3103 +PAYMENTS_ENABLED=false +PAYOUTS_ENABLED=false +DIRECT_PAYMENT_ENABLED=false +DIRECT_PAYOUTS_ENABLED=false +CREDITS_PAYOUTS_ENABLED=false +MATCH_ENABLED=false +MATCH_CRYPTO_RACE_ENABLED=false +MATCH_PAYOUTS_ENABLED=false +``` + +Run the exact release only against a fresh, isolated canary database. Do not attach port 3103 to +the NPM/router, public ingress, production database, payment intake, payout dispatchers, or the +live service. Readiness and a non-money wallet query are the only allowed checks. Stop both canary +app and candidate wallet afterward. + +Promotion of the production app from `38083` to `38084` is explicitly out of scope. It requires a +separate reviewed maintenance change and rollback plan. + +## Rollback and partial failures + +There is no destructive wallet rollback because this path never alters the legacy artifacts. + +- During the copy window, always restart and validate the legacy unit from the trap. +- Before candidate activation, leave any partial staging generation root-only and inert. Do not + delete, merge, resume, or overwrite it automatically. +- After candidate activation, stop the candidate unit and prove port `38084` is closed. Restart and + validate the legacy unit if needed. The live application still points to `38083`. +- Preserve the unused candidate bundle for incident review; cleanup is a later exact-target, + separately approved operation. + +If the legacy unit cannot restart or its address differs, keep the application stopped/ingress +held under the existing incident runbook. Do not attempt an app cutover to rescue the ceremony. + +## GO criteria + +All are mandatory before any host mutation beyond provisioning the unused candidate identity: + +1. Small fixed-path capture, copy-window, bundle, and validation helpers exist; no general source, + destination, network, unit, port, user, or overwrite arguments exist. +2. Disposable tests inject failure before/after every create, write, chmod/chown, file fsync, + directory fsync, state write, readiness write, stop, start, address check, and final rename. +3. Kill/restart tests prove every partial stage remains inert and every stopped-wallet failure + makes a bounded restart attempt without touching legacy bytes or metadata. +4. Symlink, hardlink, inode replacement, open-writer, wrong UID/cgroup/PID, wildcard/IPv6 listener, + port collision, duplicate password option, and existing-final fixtures all fail closed. +5. A reviewer verifies secrets never enter argv, environment, stdout/stderr, journald, unit text, + tracebacks, Git, ordinary docs, or the non-secret readiness marker. +6. The restored wallet backup plus its password has been opened offline and address-matched, and + mnemonic/off-host recovery custody is resolved separately. A `.keys` file alone is not mnemonic + custody evidence. +7. The complete process succeeds, rolls back, and succeeds again on an isolated stagenet host with + synthetic/unfunded material before touching the funded stagenet wallet. +8. An independent reviewer records GO against an immutable release hash. + +Until every item passes, the production-deployable answer is **no**. Only the read-only preflight +and disposable fixture test are approved by this document; even the candidate service template is +not approved for installation. diff --git a/scripts/deploy/build-release-artifact.sh b/scripts/deploy/build-release-artifact.sh new file mode 100755 index 0000000..e411ea7 --- /dev/null +++ b/scripts/deploy/build-release-artifact.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Build a deterministic, runtime-only Wowngeon release artifact from one clean Git commit. +# This script deliberately does not install dependencies, read environment files, contact a +# deployment host, or activate a release. See --help and docs/DEPLOY.md for the operator gates. + +umask 077 + +die() { + printf 'build-release-artifact: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: scripts/deploy/build-release-artifact.sh [options] + +Options: + --ref REF Git commit-ish to archive (default: HEAD) + --release-id ID Destination directory name inside the archive + (default: git-<12-character commit prefix>) + --output-dir DIR Existing or new non-symlink output directory + (default: /dist/releases) + -h, --help Show this help + +The builder fails unless tracked files and non-ignored untracked files are clean. It archives only +the runtime allowlist (LICENSE, html/**, and the guarded runtime subset of src/**) from the selected +commit, rejects symlinks/gitlinks and non-example .env files anywhere in that commit, and refuses +to overwrite an artifact or checksum. Repeating the command for the same commit, release ID, and +toolchain produces byte-identical .tar.gz output. +EOF +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "required command is unavailable: $1" +} + +for required_command in git tar gzip sha256sum mktemp install ln rm chmod cat dirname basename find sort xargs node; do + require_command "$required_command" +done + +script_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +repo_root=$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null) \ + || die 'script is not inside a Git worktree' +repo_root=$(CDPATH= cd -- "$repo_root" && pwd -P) + +ref=HEAD +release_id='' +output_dir="$repo_root/dist/releases" + +while (($# > 0)); do + case "$1" in + --ref) + (($# >= 2)) || die '--ref requires a value' + ref=$2 + shift 2 + ;; + --release-id) + (($# >= 2)) || die '--release-id requires a value' + release_id=$2 + shift 2 + ;; + --output-dir) + (($# >= 2)) || die '--output-dir requires a value' + output_dir=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac +done + +# A commit name cannot describe uncommitted candidate content. Refuse both tracked changes and +# non-ignored untracked additions instead of silently producing an artifact different from the +# reviewed tree. Ignored files such as local .env and node_modules are never read or archived. +if [[ -n "$(git -C "$repo_root" status --porcelain=v1 --untracked-files=all)" ]]; then + die 'worktree is dirty; commit or remove every tracked/untracked candidate change first' +fi + +commit=$(git -C "$repo_root" rev-parse --verify "${ref}^{commit}" 2>/dev/null) \ + || die "ref does not resolve to a commit: $ref" +[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || die 'resolved commit is not a full SHA-1 object ID' + +if [[ -z "$release_id" ]]; then + release_id="git-${commit:0:12}" +fi +[[ "$release_id" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$ ]] \ + || die 'release ID must be 1-96 safe characters and start with an alphanumeric character' +[[ "$release_id" != '.' && "$release_id" != '..' ]] || die 'release ID cannot be dot or dot-dot' + +if [[ -e "$output_dir" || -L "$output_dir" ]]; then + [[ -d "$output_dir" && ! -L "$output_dir" ]] \ + || die 'output directory must be a real directory, not a symlink' +else + install -d -m 0700 -- "$output_dir" +fi +output_dir=$(CDPATH= cd -- "$output_dir" && pwd -P) + +artifact="$output_dir/${release_id}.tar.gz" +checksum="$artifact.sha256" +[[ ! -e "$artifact" && ! -L "$artifact" ]] || die "refusing to overwrite: $artifact" +[[ ! -e "$checksum" && ! -L "$checksum" ]] || die "refusing to overwrite: $checksum" + +work_dir=$(mktemp -d "${TMPDIR:-/tmp}/wowngeon-release.XXXXXXXX") +artifact_tmp=$(mktemp "$output_dir/.${release_id}.artifact.XXXXXXXX") +checksum_tmp=$(mktemp "$output_dir/.${release_id}.checksum.XXXXXXXX") +published=0 +cleanup() { + local status=$? + if [[ "$published" != '1' ]]; then + # Another same-ID builder may have won publication after both processes passed the + # initial existence check. Remove a destination only while it is still our own hard link; + # a losing builder must never unlink the winning builder's artifact or checksum. + if [[ -e "$artifact" && "$artifact" -ef "$artifact_tmp" ]]; then + rm -f -- "$artifact" + fi + if [[ -e "$checksum" && "$checksum" -ef "$checksum_tmp" ]]; then + rm -f -- "$checksum" + fi + fi + rm -f -- "$artifact_tmp" "$checksum_tmp" + rm -rf -- "$work_dir" + return "$status" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +source_tar="$work_dir/runtime-source.tar" +stage_root="$work_dir/stage" +release_root="$stage_root/$release_id" +install -d -m 0700 -- "$release_root" + +# Git trees can encode regular files, executables, symlinks, and gitlinks. Releases accept only +# the first two. This also validates path policy before tar is allowed to extract anything. +while IFS= read -r -d '' tree_entry; do + metadata=${tree_entry%%$'\t'*} + path=${tree_entry#*$'\t'} + mode=${metadata%% *} + + [[ "$metadata" != "$path" ]] || die 'malformed Git tree entry' + [[ "$mode" == '100644' || "$mode" == '100755' ]] \ + || die "tracked symlink, gitlink, or unsafe file mode is forbidden: $path" + [[ "$path" != *$'\n'* && "$path" != *$'\r'* ]] \ + || die 'tracked filenames containing newline or carriage return are forbidden' + [[ "$path" != /* && "$path" != ../* && "$path" != */../* && "$path" != */.. ]] \ + || die "unsafe tracked path: $path" + [[ "/$path/" != *'/.git/'* && "/$path/" != *'/node_modules/'* ]] \ + || die "tracked VCS/dependency state is forbidden: $path" + + filename=$(basename -- "$path") + case "$filename" in + .env) + die "tracked runtime environment file is forbidden: $path" + ;; + .env.*) + [[ "$filename" == *.example ]] \ + || die "tracked non-example environment file is forbidden: $path" + ;; + .npmrc|.yarnrc|.netrc) + die "tracked credential-capable configuration file is forbidden: $path" + ;; + esac +done < <(git -C "$repo_root" ls-tree -rz --full-tree "$commit") + +# Production releases intentionally exclude source documentation, tests, root-level operator +# tooling/service units, and environment examples. Operational artifacts remain in the reviewed +# source/fleet repositories and must have their own pinned provenance; they are never made part of +# the application runtime merely because they share a Git commit. +runtime_pathspecs=( + 'LICENSE' + 'html' + 'src' + ':(exclude)src/.env*' + ':(glob,exclude)src/**/.env*' + ':(exclude)src/scripts/pvp-capture.js' + ':(exclude)src/scripts/stagenet-financial-canary.js' + ':(exclude)src/scripts/setup_db.js' + ':(exclude)src/scripts/smoke.js' + ':(exclude)src/jest.config.js' + ':(exclude)src/sim' +) +git -C "$repo_root" archive --format=tar "$commit" -- "${runtime_pathspecs[@]}" >"$source_tar" +source_hash=$(sha256sum "$source_tar") +source_hash=${source_hash%% *} +[[ "$source_hash" =~ ^[0-9a-f]{64}$ ]] || die 'could not calculate source archive SHA-256' + +tar --extract --file="$source_tar" --directory="$release_root" --no-same-owner --no-same-permissions + +runtime_roots=$(find "$release_root" -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort) +[[ "$runtime_roots" == $'LICENSE\nhtml\nsrc' ]] \ + || die 'runtime archive contains an unexpected or missing top-level path' +[[ -z "$(find "$release_root/src" -type f -name '.env*' -print -quit)" ]] \ + || die 'runtime archive contains a forbidden environment file or example' + +for required_file in LICENSE src/index.js src/package.json src/package-lock.json \ + src/scripts/preflight.js src/scripts/migrate-disposable-clone.js html/index.html; do + [[ -f "$release_root/$required_file" && ! -L "$release_root/$required_file" ]] \ + || die "required release file is absent or unsafe: $required_file" +done + +# Keep the source package manifest useful to developers while exposing only runnable commands in +# the runtime artifact. This deterministic metadata-only rewrite does not change dependencies or +# lockfile resolution; the final runtime bytes are covered by .release-files.sha256 below. +node - "$release_root/src/package.json" <<'NODE' +'use strict'; +const fs = require('fs'); +const manifestPath = process.argv[2]; +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +const sourceScripts = manifest.scripts && typeof manifest.scripts === 'object' + ? manifest.scripts + : {}; +const runtimeScripts = {}; +for (const name of ['start', 'preflight', 'db:migrate:clone']) { + if (typeof sourceScripts[name] !== 'string' || sourceScripts[name].trim().length === 0) { + throw new Error(`required runtime package script is absent or empty: ${name}`); + } + runtimeScripts[name] = sourceScripts[name]; +} +manifest.scripts = runtimeScripts; +fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 }); +NODE + +lock_hash=$(sha256sum "$release_root/src/package-lock.json") +lock_hash=${lock_hash%% *} +commit_epoch=$(git -C "$repo_root" show -s --format=%ct "$commit") +[[ "$commit_epoch" =~ ^[0-9]+$ ]] || die 'commit timestamp is invalid' + +printf '%s\n' "$source_hash" >"$release_root/.release-source-sha256" +printf '%s\n' "$commit" >"$release_root/.release-commit" +printf '%s\n' "$release_id" >"$release_root/.release-id" +printf '%s\n' "$lock_hash" >"$release_root/.release-package-lock-sha256" +( + cd "$release_root" + find . -type f ! -path './.release-files.sha256' -print0 \ + | LC_ALL=C sort -z \ + | xargs -0 -r sha256sum -- +) >"$release_root/.release-files.sha256" + +# Normalize ownership, modes, ordering, timestamps, and gzip headers. Metadata files deliberately +# identify the prefix-free, runtime-allowlisted `git archive` hash; the sidecar identifies the +# final blob. +tar \ + --sort=name \ + --format=posix \ + --pax-option=delete=atime,delete=ctime \ + --mtime="@$commit_epoch" \ + --owner=0 --group=0 --numeric-owner \ + --mode='u+rwX,go+rX,go-w' \ + --directory="$stage_root" \ + --create --file=- "$release_id" \ + | gzip -n >"$artifact_tmp" + +artifact_hash=$(sha256sum "$artifact_tmp") +artifact_hash=${artifact_hash%% *} +printf '%s %s\n' "$artifact_hash" "$(basename -- "$artifact")" >"$checksum_tmp" + +# Hard-link publication is atomic and refuses an existing destination even under a concurrent run. +ln -- "$artifact_tmp" "$artifact" || die "could not publish artifact without overwrite: $artifact" +if ! ln -- "$checksum_tmp" "$checksum"; then + rm -f -- "$artifact" + die "could not publish checksum without overwrite: $checksum" +fi +chmod 0600 -- "$artifact" "$checksum" +published=1 + +printf 'release_id=%s\ncommit=%s\nsource_sha256=%s\nartifact_sha256=%s\nartifact=%s\nchecksum=%s\n' \ + "$release_id" "$commit" "$source_hash" "$artifact_hash" "$artifact" "$checksum" diff --git a/scripts/deploy/financial-audit.sql b/scripts/deploy/financial-audit.sql new file mode 100644 index 0000000..dfae58c --- /dev/null +++ b/scripts/deploy/financial-audit.sql @@ -0,0 +1,103 @@ +\set ON_ERROR_STOP on +\pset pager off + +BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY; + +SELECT current_database() AS database, + COUNT(*) AS applied_migrations, + MAX(filename) AS latest_migration +FROM schema_migrations; + +WITH audit(category, row_count, atomic_amount) AS ( + SELECT 'payout_nonterminal', COUNT(*), COALESCE(SUM(amount), 0)::numeric + FROM payouts + WHERE status IS DISTINCT FROM 'completed' + UNION ALL + SELECT 'payout_completed_missing_tx', COUNT(*), COALESCE(SUM(amount), 0)::numeric + FROM payouts + WHERE status = 'completed' AND NULLIF(BTRIM(COALESCE(tx_hash, '')), '') IS NULL + UNION ALL + SELECT 'refund_unsettled', COUNT(*), COALESCE(SUM(amount), 0)::numeric + FROM payment_refunds + WHERE status IS DISTINCT FROM 'completed' + UNION ALL + SELECT 'solo_orphan', COUNT(*), 0::numeric + FROM games + WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired') + UNION ALL + SELECT 'match_orphan', COUNT(*), COALESCE(SUM(payout_liability_amount_atomic), 0)::numeric + FROM matches + WHERE status IS NULL OR status NOT IN ('finished', 'cancelled') + UNION ALL + SELECT 'match_queue_outstanding', COUNT(*), COALESCE(SUM(escrow_value_atomic), 0)::numeric + FROM match_queue_entries + WHERE status IS NULL OR status NOT IN ('consumed', 'cancelled') + UNION ALL + SELECT 'solo_liability_without_payout', COUNT(*), + COALESCE(SUM(CASE + WHEN treasure_found THEN payout_treasure_amount + ELSE payout_escape_amount + END), 0)::numeric + FROM games g + WHERE g.status = 'won' + AND g.payout_eligible = TRUE + AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.game_id = g.id) + UNION ALL + SELECT 'match_liability_without_payout', COUNT(*), + COALESCE(SUM(m.payout_liability_amount_atomic), 0)::numeric + FROM matches m + WHERE m.economy = 'crypto_race' + AND m.status = 'finished' + AND m.winner_user_id IS NOT NULL + AND m.payout_liability_accepted_at IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.match_id = m.id) + UNION ALL + SELECT 'late_payment_manual_review', COUNT(*), COALESCE(SUM(observed_amount), 0)::numeric + FROM payment_late_reviews + WHERE status IS NULL OR status NOT IN ('resolved', 'ignored') +) +SELECT category, row_count, atomic_amount +FROM audit +ORDER BY category; + +WITH ledger AS ( + SELECT COUNT(*) = 40 + AND MAX(filename) = '040_paid_match_entropy_precommit.sql' AS valid + FROM schema_migrations +), audit(row_count) AS ( + SELECT COUNT(*) FROM payouts WHERE status IS DISTINCT FROM 'completed' + UNION ALL + SELECT COUNT(*) FROM payouts + WHERE status = 'completed' AND NULLIF(BTRIM(COALESCE(tx_hash, '')), '') IS NULL + UNION ALL + SELECT COUNT(*) FROM payment_refunds + WHERE status IS DISTINCT FROM 'completed' + UNION ALL + SELECT COUNT(*) FROM games WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired') + UNION ALL + SELECT COUNT(*) FROM matches WHERE status IS NULL OR status NOT IN ('finished', 'cancelled') + UNION ALL + SELECT COUNT(*) FROM match_queue_entries WHERE status IS NULL OR status NOT IN ('consumed', 'cancelled') + UNION ALL + SELECT COUNT(*) FROM games g + WHERE g.status = 'won' AND g.payout_eligible = TRUE + AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.game_id = g.id) + UNION ALL + SELECT COUNT(*) FROM matches m + WHERE m.economy = 'crypto_race' AND m.status = 'finished' + AND m.winner_user_id IS NOT NULL + AND m.payout_liability_accepted_at IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.match_id = m.id) + UNION ALL + SELECT COUNT(*) FROM payment_late_reviews WHERE status IS NULL OR status NOT IN ('resolved', 'ignored') +) +SELECT ((SELECT valid FROM ledger) AND COALESCE(SUM(row_count), 0) = 0) AS financial_audit_clean FROM audit \gset + +\if :financial_audit_clean +\echo 'PASS: no dispatchable/ambiguous financial state or orphaned gameplay state' +\else +\echo 'FAIL: reconcile every nonzero category before any payout-capable boot' +\quit 3 +\endif + +ROLLBACK; diff --git a/scripts/deploy/financial-constraint-audit.sql b/scripts/deploy/financial-constraint-audit.sql new file mode 100644 index 0000000..0340607 --- /dev/null +++ b/scripts/deploy/financial-constraint-audit.sql @@ -0,0 +1,212 @@ +-- Read-only release gate for every CHECK constraint introduced with NOT VALID in migrations. +-- +-- Usage (connection arguments deliberately omitted): +-- psql -X -v ON_ERROR_STOP=1 \ +-- --set=expected_database=EXACT_DATABASE_NAME \ +-- --file=scripts/deploy/financial-constraint-audit.sql +-- +-- This script is safe to run against a live database: it opens one repeatable-read, read-only +-- transaction, takes no explicit table lock, and makes no catalog or data change. + +\set ON_ERROR_STOP on +\pset pager off + +\if :{?expected_database} +\else +\echo 'FAIL: pass --set=expected_database=EXACT_DATABASE_NAME' +\quit 2 +\endif + +SELECT current_database() = :'expected_database' AS expected_database_matches \gset +\if :expected_database_matches +\else +\echo 'FAIL: connected database does not exactly match expected_database' +\quit 2 +\endif + +BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY; +SET LOCAL statement_timeout = '10min'; +SET LOCAL lock_timeout = '5s'; + +-- Names are intentionally explicit. A missing/renamed constraint is a release-gate failure even +-- if the equivalent predicate happens to be implemented elsewhere. +WITH expected(table_name, constraint_name) AS ( + VALUES + ('payments', 'payments_status_check'), + ('payments', 'payments_payment_type_check'), + ('payouts', 'payouts_status_check'), + ('games', 'games_payout_commitment_complete'), + ('games', 'games_layout_fingerprints_array_check'), + ('race_entry_lots', 'race_entry_lots_remaining_within_original'), + ('match_queue_entries', 'match_queue_entries_status_check'), + ('match_queue_entries', 'match_queue_entries_escrow_nonnegative'), + ('match_queue_entries', 'match_queue_committed_escrow_complete'), + ('matches', 'matches_payout_liability_amount_nonnegative'), + ('matches', 'matches_payout_liability_cap_positive'), + ('matches', 'matches_payout_liability_complete'), + ('matches', 'matches_payout_liability_economics_consistent'), + ('race_entry_lots', 'race_entry_lots_refund_shape'), + ('payment_refunds', 'payment_refunds_progress_nonnegative'), + ('games', 'games_entry_consumption_shape'), + ('payments', 'payments_fairness_binding_complete'), + ('payouts', 'payouts_no_address_review_shape') +), actual AS ( + SELECT classes.relname AS table_name, + constraints.conname AS constraint_name, + constraints.convalidated + FROM pg_catalog.pg_constraint AS constraints + JOIN pg_catalog.pg_class AS classes ON classes.oid = constraints.conrelid + JOIN pg_catalog.pg_namespace AS namespaces ON namespaces.oid = classes.relnamespace + WHERE namespaces.nspname = 'public' + AND constraints.contype = 'c' +) +SELECT COUNT(*) = 18 + AND COUNT(actual.constraint_name) = 18 AS constraint_inventory_ok, + COUNT(*) FILTER (WHERE actual.convalidated IS FALSE) AS unvalidated_constraints, + COALESCE(string_agg(expected.table_name || '.' || expected.constraint_name, ', ' + ORDER BY expected.table_name, expected.constraint_name) + FILTER (WHERE actual.constraint_name IS NULL), '') AS missing_constraints + FROM expected + LEFT JOIN actual USING (table_name, constraint_name) +\gset + +\if :constraint_inventory_ok +\echo 'Constraint inventory present; currently unvalidated:' :unvalidated_constraints +\else +ROLLBACK; +\echo 'FAIL: required financial CHECK constraint inventory is incomplete:' :missing_constraints +\quit 3 +\endif + +-- These predicates mirror PostgreSQL CHECK semantics exactly: only FALSE is a violation; NULL is +-- accepted by CHECK. Counts diagnose historical rows that predate NOT VALID constraint creation. +WITH violations(constraint_name, row_count) AS ( + SELECT 'payments_status_check', COUNT(*)::bigint + FROM payments + WHERE (status IN ('pending', 'confirmed', 'expired', 'refunded')) IS FALSE + UNION ALL + SELECT 'payments_payment_type_check', COUNT(*)::bigint + FROM payments + WHERE (payment_type IN ('single_game', 'credits_package', 'cosmetic_pack')) IS FALSE + UNION ALL + SELECT 'payouts_status_check', COUNT(*)::bigint + FROM payouts + WHERE (status IN ('pending', 'processing', 'completed', 'failed', 'needs_review', + 'permanently_failed', 'batched')) IS FALSE + UNION ALL + SELECT 'games_payout_commitment_complete', COUNT(*)::bigint + FROM games + WHERE (payout_committed_at IS NULL + OR (payout_eligible IS NOT NULL AND payout_terms IS NOT NULL)) IS FALSE + UNION ALL + SELECT 'games_layout_fingerprints_array_check', COUNT(*)::bigint + FROM games + WHERE (layout_fingerprints IS NULL OR jsonb_typeof(layout_fingerprints) = 'array') IS FALSE + UNION ALL + SELECT 'race_entry_lots_remaining_within_original', COUNT(*)::bigint + FROM race_entry_lots + WHERE (remaining_entries >= 0 AND remaining_entries <= original_entries) IS FALSE + UNION ALL + SELECT 'match_queue_entries_status_check', COUNT(*)::bigint + FROM match_queue_entries + WHERE (status IN ('queued', 'matched', 'consumed', 'cancelled')) IS FALSE + UNION ALL + SELECT 'match_queue_entries_escrow_nonnegative', COUNT(*)::bigint + FROM match_queue_entries + WHERE (escrow_amount >= 0 AND escrow_value_atomic >= 0) IS FALSE + UNION ALL + SELECT 'match_queue_committed_escrow_complete', COUNT(*)::bigint + FROM match_queue_entries + WHERE ( + status NOT IN ('matched', 'consumed') + OR economy = 'free' + OR (economy = 'credits_prestige' AND escrow_amount > 0) + OR (economy = 'crypto_race' AND escrow_amount = 1 + AND escrow_value_atomic > 0 AND race_entry_lot_id IS NOT NULL) + ) IS FALSE + UNION ALL + SELECT 'matches_payout_liability_amount_nonnegative', COUNT(*)::bigint + FROM matches + WHERE (payout_liability_amount_atomic IS NULL + OR payout_liability_amount_atomic >= 0) IS FALSE + UNION ALL + SELECT 'matches_payout_liability_cap_positive', COUNT(*)::bigint + FROM matches + WHERE (payout_liability_cap_atomic IS NULL OR payout_liability_cap_atomic > 0) IS FALSE + UNION ALL + SELECT 'matches_payout_liability_complete', COUNT(*)::bigint + FROM matches + WHERE ( + payout_liability_accepted_at IS NULL + OR (economy = 'crypto_race' + AND payout_liability_amount_atomic IS NOT NULL + AND payout_liability_amount_atomic > 0 + AND payout_liability_cap_atomic IS NOT NULL + AND payout_liability_amount_atomic <= payout_liability_cap_atomic + AND payout_liability_terms IS NOT NULL) + ) IS FALSE + UNION ALL + SELECT 'matches_payout_liability_economics_consistent', COUNT(*)::bigint + FROM matches + WHERE ( + payout_liability_accepted_at IS NULL + OR (entry_fee_atomic > 0 + AND pot_atomic > 0 + AND house_fee_atomic >= 0 + AND house_fee_atomic < pot_atomic + AND payout_liability_amount_atomic = pot_atomic - house_fee_atomic) + ) IS FALSE + UNION ALL + SELECT 'race_entry_lots_refund_shape', COUNT(*)::bigint + FROM race_entry_lots + WHERE (refunded_at IS NULL OR remaining_entries = 0) IS FALSE + UNION ALL + SELECT 'payment_refunds_progress_nonnegative', COUNT(*)::bigint + FROM payment_refunds + WHERE (purchase_progress_deducted >= 0) IS FALSE + UNION ALL + SELECT 'games_entry_consumption_shape', COUNT(*)::bigint + FROM games + WHERE (entry_credits_spent IS NULL OR entry_credits_spent > 0) IS FALSE + UNION ALL + SELECT 'payments_fairness_binding_complete', COUNT(*)::bigint + FROM payments + WHERE ( + fairness_bound_at IS NULL + OR (payment_type = 'single_game' + AND fairness_proof_version = 2 + AND fairness_offer_id IS NOT NULL + AND fairness_offer_issued_at IS NOT NULL + AND fairness_commitment ~ '^[0-9a-f]{64}$' + AND fairness_server_seed ~ '^[0-9a-f]{64}$' + AND fairness_client_seed IS NOT NULL) + ) IS FALSE + UNION ALL + SELECT 'payouts_no_address_review_shape', COUNT(*)::bigint + FROM payouts + WHERE ( + payout_address <> 'PENDING_NO_ADDRESS' + OR (status = 'needs_review' + AND reason IN ( + 'match_winner_no_address', + 'solo_winner_no_address', + 'solo_winner_identity_review' + ) + AND tx_hash IS NULL) + ) IS FALSE +) +SELECT COALESCE(SUM(row_count), 0) = 0 AS historical_constraints_clean, + jsonb_object_agg(constraint_name, row_count ORDER BY constraint_name)::text + AS violation_counts + FROM violations +\gset + +\echo 'Historical NOT VALID constraint violation counts:' :violation_counts +\if :historical_constraints_clean +ROLLBACK; +\echo 'PASS: every historical row satisfies all 18 NOT VALID financial/fairness constraints' +\else +ROLLBACK; +\echo 'FAIL: reconcile every nonzero historical constraint category before release' +\quit 3 +\endif diff --git a/scripts/deploy/financial-constraint-validate-restore.sql b/scripts/deploy/financial-constraint-validate-restore.sql new file mode 100644 index 0000000..04aa37a --- /dev/null +++ b/scripts/deploy/financial-constraint-validate-restore.sql @@ -0,0 +1,103 @@ +-- Prove PostgreSQL can VALIDATE every historical NOT VALID constraint on a disposable restore. +-- Every catalog update is rolled back, but ALTER TABLE VALIDATE CONSTRAINT still scans and locks; +-- this script therefore refuses databases whose names do not contain "restore" or "scratch". +-- Never run it against a live application database. +-- +-- Usage (connection arguments deliberately omitted): +-- psql -X -v ON_ERROR_STOP=1 \ +-- --set=expected_database=EXACT_RESTORE_DATABASE \ +-- --set=confirm_disposable=I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE \ +-- --file=scripts/deploy/financial-constraint-validate-restore.sql + +\set ON_ERROR_STOP on +\pset pager off + +\if :{?expected_database} +\else +\echo 'FAIL: pass --set=expected_database=EXACT_RESTORE_DATABASE' +\quit 2 +\endif +\if :{?confirm_disposable} +\else +\echo 'FAIL: pass the documented confirm_disposable value' +\quit 2 +\endif + +SELECT current_database() = :'expected_database' + AND current_database() ~* '(restore|scratch)' + AND :'confirm_disposable' = 'I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE' + AS disposable_restore_confirmed +\gset +\if :disposable_restore_confirmed +\else +\echo 'FAIL: VALIDATE is restricted to the exact, explicitly confirmed restore/scratch database' +\quit 2 +\endif + +-- First provide named historical violation counts under a read-only snapshot. The included audit +-- exits nonzero before any ALTER if the inventory is incomplete or any old row violates a CHECK. +\ir financial-constraint-audit.sql + +BEGIN; +SET LOCAL statement_timeout = '10min'; +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE public.payments VALIDATE CONSTRAINT payments_status_check; +ALTER TABLE public.payments VALIDATE CONSTRAINT payments_payment_type_check; +ALTER TABLE public.payouts VALIDATE CONSTRAINT payouts_status_check; +ALTER TABLE public.games VALIDATE CONSTRAINT games_payout_commitment_complete; +ALTER TABLE public.games VALIDATE CONSTRAINT games_layout_fingerprints_array_check; +ALTER TABLE public.race_entry_lots VALIDATE CONSTRAINT race_entry_lots_remaining_within_original; +ALTER TABLE public.match_queue_entries VALIDATE CONSTRAINT match_queue_entries_status_check; +ALTER TABLE public.match_queue_entries VALIDATE CONSTRAINT match_queue_entries_escrow_nonnegative; +ALTER TABLE public.match_queue_entries VALIDATE CONSTRAINT match_queue_committed_escrow_complete; +ALTER TABLE public.matches VALIDATE CONSTRAINT matches_payout_liability_amount_nonnegative; +ALTER TABLE public.matches VALIDATE CONSTRAINT matches_payout_liability_cap_positive; +ALTER TABLE public.matches VALIDATE CONSTRAINT matches_payout_liability_complete; +ALTER TABLE public.matches VALIDATE CONSTRAINT matches_payout_liability_economics_consistent; +ALTER TABLE public.race_entry_lots VALIDATE CONSTRAINT race_entry_lots_refund_shape; +ALTER TABLE public.payment_refunds VALIDATE CONSTRAINT payment_refunds_progress_nonnegative; +ALTER TABLE public.games VALIDATE CONSTRAINT games_entry_consumption_shape; +ALTER TABLE public.payments VALIDATE CONSTRAINT payments_fairness_binding_complete; +ALTER TABLE public.payouts VALIDATE CONSTRAINT payouts_no_address_review_shape; + +WITH expected(table_name, constraint_name) AS ( + VALUES + ('payments', 'payments_status_check'), + ('payments', 'payments_payment_type_check'), + ('payouts', 'payouts_status_check'), + ('games', 'games_payout_commitment_complete'), + ('games', 'games_layout_fingerprints_array_check'), + ('race_entry_lots', 'race_entry_lots_remaining_within_original'), + ('match_queue_entries', 'match_queue_entries_status_check'), + ('match_queue_entries', 'match_queue_entries_escrow_nonnegative'), + ('match_queue_entries', 'match_queue_committed_escrow_complete'), + ('matches', 'matches_payout_liability_amount_nonnegative'), + ('matches', 'matches_payout_liability_cap_positive'), + ('matches', 'matches_payout_liability_complete'), + ('matches', 'matches_payout_liability_economics_consistent'), + ('race_entry_lots', 'race_entry_lots_refund_shape'), + ('payment_refunds', 'payment_refunds_progress_nonnegative'), + ('games', 'games_entry_consumption_shape'), + ('payments', 'payments_fairness_binding_complete'), + ('payouts', 'payouts_no_address_review_shape') +) +SELECT COUNT(*) = 18 AND BOOL_AND(constraints.convalidated) AS all_constraints_validated + FROM expected + JOIN pg_catalog.pg_namespace AS namespaces ON namespaces.nspname = 'public' + JOIN pg_catalog.pg_class AS classes + ON classes.relnamespace = namespaces.oid AND classes.relname = expected.table_name + JOIN pg_catalog.pg_constraint AS constraints + ON constraints.conrelid = classes.oid + AND constraints.conname = expected.constraint_name + AND constraints.contype = 'c' +\gset + +\if :all_constraints_validated +ROLLBACK; +\echo 'PASS: PostgreSQL validated all 18 constraints on the disposable restore; catalog changes rolled back' +\else +ROLLBACK; +\echo 'FAIL: explicit VALIDATE CONSTRAINT coverage was incomplete' +\quit 3 +\endif diff --git a/scripts/deploy/monero-stagenet-parallel-fixture-test.py b/scripts/deploy/monero-stagenet-parallel-fixture-test.py new file mode 100755 index 0000000..93b97a3 --- /dev/null +++ b/scripts/deploy/monero-stagenet-parallel-fixture-test.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Disposable fixture proof for the parallel-wallet filesystem contract. + +This test uses only synthetic bytes below a fresh TemporaryDirectory. It never imports or invokes +the host preflight, systemd, a wallet binary, a wallet file, or a credential. Run directly with: + + python3 scripts/deploy/monero-stagenet-parallel-fixture-test.py +""" + +from __future__ import annotations + +import ctypes +import errno +import os +import pathlib +import secrets +import stat +import tempfile +import unittest +from collections.abc import Callable + + +O_DIRECTORY = getattr(os, "O_DIRECTORY", 0) +O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0) +RENAME_NOREPLACE = 1 +LIBC = ctypes.CDLL(None, use_errno=True) + + +class Refusal(RuntimeError): + pass + + +def rename_noreplace(source_fd: int, source: str, destination_fd: int, destination: str) -> None: + result = LIBC.renameat2( + ctypes.c_int(source_fd), + ctypes.c_char_p(os.fsencode(source)), + ctypes.c_int(destination_fd), + ctypes.c_char_p(os.fsencode(destination)), + ctypes.c_uint(RENAME_NOREPLACE), + ) + if result != 0: + problem = ctypes.get_errno() + raise OSError(problem, os.strerror(problem), destination) + + +def open_directory_nofollow(path: pathlib.Path) -> int: + if not path.is_absolute() or pathlib.Path(os.path.normpath(path)) != path: + raise Refusal("path is not absolute and normalized") + descriptor = os.open("/", os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW) + try: + for component in path.parts[1:]: + following = os.open( + component, + os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW, + dir_fd=descriptor, + ) + os.close(descriptor) + descriptor = following + return descriptor + except Exception: + os.close(descriptor) + raise + + +def file_identity(details: os.stat_result) -> tuple[int, ...]: + return ( + details.st_dev, + details.st_ino, + details.st_uid, + details.st_gid, + stat.S_IMODE(details.st_mode), + details.st_nlink, + details.st_size, + details.st_mtime_ns, + details.st_ctime_ns, + ) + + +def open_source(directory_fd: int, name: str) -> tuple[int, tuple[int, ...]]: + descriptor = os.open(name, os.O_RDONLY | O_NOFOLLOW, dir_fd=directory_fd) + details = os.fstat(descriptor) + if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1: + os.close(descriptor) + raise Refusal("source is not a single-linked regular file") + if stat.S_IMODE(details.st_mode) != 0o600: + os.close(descriptor) + raise Refusal("source mode differs from the contract") + return descriptor, file_identity(details) + + +def copy_one( + source_fd: int, + expected: tuple[int, ...], + destination_fd: int, + name: str, + after_copy: Callable[[], None] | None = None, +) -> None: + temporary = f".{name}.pending.{secrets.token_hex(8)}" + output = os.open( + temporary, + os.O_RDWR | os.O_CREAT | os.O_EXCL | O_NOFOLLOW, + 0o600, + dir_fd=destination_fd, + ) + published = False + try: + os.lseek(source_fd, 0, os.SEEK_SET) + while True: + block = os.read(source_fd, 1024 * 1024) + if not block: + break + view = memoryview(block) + while view: + written = os.write(output, view) + if written <= 0: + raise OSError("short synthetic copy write") + view = view[written:] + os.fsync(output) + if after_copy is not None: + after_copy() + if file_identity(os.fstat(source_fd)) != expected: + raise Refusal("source metadata changed during copy") + os.lseek(source_fd, 0, os.SEEK_SET) + os.lseek(output, 0, os.SEEK_SET) + while True: + source_block = os.read(source_fd, 1024 * 1024) + output_block = os.read(output, 1024 * 1024) + if source_block != output_block: + raise Refusal("copy differs from retained source") + if not source_block: + break + os.close(output) + output = -1 + rename_noreplace(destination_fd, temporary, destination_fd, name) + published = True + os.fsync(destination_fd) + finally: + if output >= 0: + os.close(output) + if not published: + try: + os.unlink(temporary, dir_fd=destination_fd) + except FileNotFoundError: + pass + + +def copy_pair(source: pathlib.Path, wallet_destination: pathlib.Path) -> None: + source_fd = open_directory_nofollow(source) + destination_fd = open_directory_nofollow(wallet_destination) + try: + opened: list[tuple[int, tuple[int, ...], str]] = [] + try: + for source_name, destination_name in ( + ("test", "stagenet"), + ("test.keys", "stagenet.keys"), + ): + descriptor, expected = open_source(source_fd, source_name) + opened.append((descriptor, expected, destination_name)) + for descriptor, expected, destination_name in opened: + copy_one(descriptor, expected, destination_fd, destination_name) + finally: + for descriptor, _, _ in opened: + os.close(descriptor) + finally: + os.close(source_fd) + os.close(destination_fd) + + +def require_ready(stage: pathlib.Path) -> None: + descriptor = open_directory_nofollow(stage) + try: + marker = os.open("READY", os.O_RDONLY | O_NOFOLLOW, dir_fd=descriptor) + try: + details = os.fstat(marker) + if ( + not stat.S_ISREG(details.st_mode) + or details.st_nlink != 1 + or stat.S_IMODE(details.st_mode) != 0o600 + ): + raise Refusal("readiness marker metadata is invalid") + finally: + os.close(marker) + except FileNotFoundError: + raise Refusal("readiness marker is absent") + finally: + os.close(descriptor) + + +def publish(stage: pathlib.Path, final: pathlib.Path) -> None: + require_ready(stage) + if stage.parent != final.parent: + raise Refusal("publication must be one same-filesystem directory rename") + parent = open_directory_nofollow(stage.parent) + try: + rename_noreplace(parent, stage.name, parent, final.name) + os.fsync(parent) + finally: + os.close(parent) + + +class BundleFixture(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="monero-parallel-fixture-") + self.root = pathlib.Path(self.temporary.name) + self.source = self.root / "legacy" + self.stage = self.root / ".candidate.staging" + self.final = self.root / "candidate" + self.source.mkdir(mode=0o700) + self.stage.mkdir(mode=0o700) + (self.stage / "wallet").mkdir(mode=0o700) + for name, content in (("test", b"synthetic-wallet\0bytes"), ("test.keys", b"synthetic-key\0bytes")): + path = self.source / name + path.write_bytes(content) + path.chmod(0o600) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def ready(self) -> None: + marker = self.stage / "READY" + descriptor = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL | O_NOFOLLOW, 0o600) + try: + os.write(descriptor, b"network=stagenet\nrpc_bind=127.0.0.1:38084\n") + os.fchmod(descriptor, 0o600) + os.fsync(descriptor) + finally: + os.close(descriptor) + stage_fd = open_directory_nofollow(self.stage) + try: + os.fsync(stage_fd) + finally: + os.close(stage_fd) + + def test_partial_stage_is_not_a_candidate(self) -> None: + copy_pair(self.source, self.stage / "wallet") + with self.assertRaises(Refusal): + publish(self.stage, self.final) + self.assertFalse(self.final.exists()) + self.assertTrue(self.stage.exists()) + + def test_ready_bundle_is_one_atomic_nonreplacing_publish(self) -> None: + copy_pair(self.source, self.stage / "wallet") + self.ready() + publish(self.stage, self.final) + self.assertFalse(self.stage.exists()) + self.assertEqual((self.final / "wallet" / "stagenet").read_bytes(), b"synthetic-wallet\0bytes") + self.assertEqual((self.final / "wallet" / "stagenet.keys").read_bytes(), b"synthetic-key\0bytes") + + def test_existing_final_is_never_replaced(self) -> None: + self.ready() + self.final.mkdir(mode=0o700) + sentinel = self.final / "sentinel" + sentinel.write_text("older generation", encoding="ascii") + with self.assertRaises(OSError) as problem: + publish(self.stage, self.final) + self.assertEqual(problem.exception.errno, errno.EEXIST) + self.assertEqual(sentinel.read_text(encoding="ascii"), "older generation") + self.assertTrue(self.stage.exists()) + + def test_symlink_component_is_refused(self) -> None: + linked = self.root / "linked-source" + linked.symlink_to(self.source, target_is_directory=True) + with self.assertRaises(OSError): + copy_pair(linked, self.stage / "wallet") + + def test_hard_linked_key_is_refused(self) -> None: + os.link(self.source / "test.keys", self.root / "second-link") + with self.assertRaises(Refusal): + copy_pair(self.source, self.stage / "wallet") + self.assertFalse((self.stage / "wallet" / "stagenet.keys").exists()) + + def test_source_change_is_detected_before_publication(self) -> None: + source_fd = open_directory_nofollow(self.source) + destination_fd = open_directory_nofollow(self.stage / "wallet") + descriptor, expected = open_source(source_fd, "test") + try: + def mutate() -> None: + with open(self.source / "test", "ab") as output: + output.write(b"changed") + + with self.assertRaises(Refusal): + copy_one(descriptor, expected, destination_fd, "stagenet", mutate) + self.assertFalse((self.stage / "wallet" / "stagenet").exists()) + finally: + os.close(descriptor) + os.close(source_fd) + os.close(destination_fd) + + def test_marker_contains_no_synthetic_secret(self) -> None: + self.ready() + self.assertNotIn(b"synthetic-key", (self.stage / "READY").read_bytes()) + self.assertNotIn(b"synthetic-wallet", (self.stage / "READY").read_bytes()) + + def test_linked_readiness_marker_is_refused(self) -> None: + external = self.root / "external-marker" + external.write_text("network=stagenet\n", encoding="ascii") + external.chmod(0o600) + os.link(external, self.stage / "READY") + with self.assertRaises(Refusal): + publish(self.stage, self.final) + self.assertFalse(self.final.exists()) + + +if __name__ == "__main__": + os.umask(0o077) + unittest.main(verbosity=2) diff --git a/scripts/deploy/monero-stagenet-parallel-preflight.py b/scripts/deploy/monero-stagenet-parallel-preflight.py new file mode 100755 index 0000000..b68620e --- /dev/null +++ b/scripts/deploy/monero-stagenet-parallel-preflight.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""Read-only preflight for a parallel Monero stagenet wallet-RPC candidate. + +This script does not read systemd unit text, process arguments, wallet bytes, keys, passwords, or +mnemonics. It does not create, modify, move, or delete anything. It validates only public RPC +identity, process/listener identity, path metadata, capacity, and inert candidate prerequisites. +""" + +from __future__ import annotations + +import argparse +import ctypes +import grp +import http.client +import json +import os +import pathlib +import pwd +import resource +import socket +import stat +import subprocess +import sys +import urllib.error +import urllib.request + + +LEGACY_UNIT = "monero-wallet-rpc.service" +LEGACY_USER = "jw" +LEGACY_GROUP = "jw" +LEGACY_BINARY = pathlib.Path("/usr/bin/monero-wallet-rpc") +LEGACY_DIR = pathlib.Path("/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4") +LEGACY_WALLET = LEGACY_DIR / "test" +LEGACY_KEYS = LEGACY_DIR / "test.keys" +LEGACY_RPC_PORT = 38083 +LEGACY_RPC_URL = "http://127.0.0.1:38083/json_rpc" + +CANDIDATE_UNIT = "monerogue-stagenet-wallet-candidate.service" +CANDIDATE_USER = "monerogue-wallet-candidate" +CANDIDATE_GROUP = "monerogue-wallet-candidate" +CANDIDATE_HOME = pathlib.Path("/var/lib/monerogue-stagenet-wallet-candidate") +CANDIDATE_STAGE = pathlib.Path("/var/lib/.monerogue-stagenet-wallet-candidate.staging") +CANDIDATE_BINARY = pathlib.Path("/usr/bin/monero-wallet-rpc") +CANDIDATE_RPC_PORT = 38084 +VERIFY_RPC_PORT = 39084 + +ADDRESS_REQUEST = { + "jsonrpc": "2.0", + "id": "parallel-preflight", + "method": "get_address", + "params": {"account_index": 0}, +} +O_DIRECTORY = getattr(os, "O_DIRECTORY", 0) +O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0) +LIBC = ctypes.CDLL(None, use_errno=True) + + +class Refusal(RuntimeError): + pass + + +def refuse(message: str) -> None: + raise Refusal(message) + + +def harden_process() -> None: + os.umask(0o077) + resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + try: + if LIBC.prctl(4, 0, 0, 0, 0) != 0: # PR_SET_DUMPABLE + refuse("could not disable process dumps") + except AttributeError: + refuse("could not disable process dumps") + + +def identity(user: str, group: str) -> tuple[int, int]: + try: + return pwd.getpwnam(user).pw_uid, grp.getgrnam(group).gr_gid + except KeyError: + refuse(f"required identity is missing: {user}:{group}") + + +def open_directory_nofollow(path: pathlib.Path) -> int: + if not path.is_absolute() or pathlib.Path(os.path.normpath(path)) != path: + refuse("path is not absolute and normalized") + descriptor = os.open("/", os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW) + try: + for component in path.parts[1:]: + following = os.open( + component, + os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW, + dir_fd=descriptor, + ) + os.close(descriptor) + descriptor = following + if not stat.S_ISDIR(os.fstat(descriptor).st_mode): + refuse("opened path is not a directory") + return descriptor + except Exception: + os.close(descriptor) + raise + + +def require_directory(path: pathlib.Path, user: str, group: str, mode: int) -> os.stat_result: + try: + descriptor = open_directory_nofollow(path) + except (FileNotFoundError, NotADirectoryError, OSError): + refuse(f"required real directory is unavailable: {path}") + try: + details = os.fstat(descriptor) + expected = (*identity(user, group), mode) + actual = (details.st_uid, details.st_gid, stat.S_IMODE(details.st_mode)) + if actual != expected: + refuse(f"directory metadata mismatch: {path}") + return details + finally: + os.close(descriptor) + + +def require_regular(path: pathlib.Path, user: str, group: str, mode: int) -> os.stat_result: + parent = open_directory_nofollow(path.parent) + try: + try: + descriptor = os.open(path.name, os.O_RDONLY | O_NOFOLLOW, dir_fd=parent) + except OSError: + refuse(f"required real file is unavailable: {path}") + try: + details = os.fstat(descriptor) + expected = (*identity(user, group), mode) + actual = (details.st_uid, details.st_gid, stat.S_IMODE(details.st_mode)) + if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1 or actual != expected: + refuse(f"file metadata mismatch or hard link refused: {path}") + if details.st_size <= 0: + refuse(f"required file is empty: {path}") + return details + finally: + os.close(descriptor) + finally: + os.close(parent) + + +def systemctl(*arguments: str) -> subprocess.CompletedProcess[bytes]: + try: + return subprocess.run( + ["/usr/bin/systemctl", *arguments], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired): + refuse("systemd state check failed") + + +def main_pid(unit: str) -> int: + result = systemctl("show", unit, "--property=MainPID", "--value") + if result.returncode != 0: + refuse("systemd MainPID check failed") + try: + return int(result.stdout.strip()) + except ValueError: + refuse("systemd returned an invalid MainPID") + + +def process_uid(pid: int) -> int: + try: + with open(f"/proc/{pid}/status", "r", encoding="ascii") as source: + for line in source: + if line.startswith("Uid:"): + values = line.split()[1:] + if len(values) == 4 and len(set(values)) == 1: + return int(values[0]) + except (OSError, UnicodeDecodeError, ValueError): + pass + refuse("legacy process UID is unavailable") + + +def process_start_time(pid: int) -> str: + try: + raw = pathlib.Path(f"/proc/{pid}/stat").read_text(encoding="ascii") + fields = raw[raw.rfind(")") + 2 :].split() + return fields[19] + except (OSError, UnicodeDecodeError, IndexError): + refuse("legacy process start time is unavailable") + + +def process_has_unit_cgroup(pid: int, unit: str) -> bool: + try: + lines = pathlib.Path(f"/proc/{pid}/cgroup").read_text(encoding="ascii").splitlines() + except (OSError, UnicodeDecodeError): + refuse("legacy process cgroup is unavailable") + return any(line.rsplit(":", 1)[-1].endswith(f"/{unit}") for line in lines) + + +def parse_listener_table(path: str, port: int, ipv6: bool) -> list[tuple[str, int]]: + found: list[tuple[str, int]] = [] + try: + with open(path, "r", encoding="ascii") as source: + next(source, None) + for line in source: + fields = line.split() + if len(fields) < 10 or fields[3] != "0A": + continue + address_hex, port_hex = fields[1].split(":") + if int(port_hex, 16) != port: + continue + if ipv6: + address = socket.inet_ntop(socket.AF_INET6, bytes.fromhex(address_hex)) + else: + address = socket.inet_ntoa(bytes.fromhex(address_hex)[::-1]) + found.append((address, int(fields[9]))) + except (OSError, UnicodeDecodeError, ValueError, IndexError): + refuse("kernel listener state is unavailable") + return found + + +def listeners(port: int) -> list[tuple[str, int]]: + return parse_listener_table("/proc/net/tcp", port, False) + parse_listener_table( + "/proc/net/tcp6", port, True + ) + + +def process_owns_socket(pid: int, inode: int) -> bool: + expected = f"socket:[{inode}]" + try: + for entry in os.scandir(f"/proc/{pid}/fd"): + try: + if os.readlink(entry.path) == expected: + return True + except OSError: + continue + except OSError: + refuse("legacy process descriptors are unavailable") + return False + + +def require_exact_listener(pid: int, port: int) -> None: + active = listeners(port) + if len(active) != 1 or active[0][0] != "127.0.0.1": + refuse(f"port {port} is not one exact IPv4 loopback listener") + if not process_owns_socket(pid, active[0][1]): + refuse(f"port {port} is not owned by the expected process") + + +def require_unused_port(port: int) -> None: + if listeners(port): + refuse(f"candidate port {port} is already in use") + + +def validate_public_address() -> None: + body = json.dumps(ADDRESS_REQUEST, separators=(",", ":")).encode("ascii") + request = urllib.request.Request( + LEGACY_RPC_URL, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + # Never honor ambient proxy settings for a loopback identity check. + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(request, timeout=5) as response: + if response.status != 200: + refuse("legacy wallet RPC returned an unexpected status") + raw = response.read(1024 * 1024 + 1) + except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException): + refuse("legacy wallet RPC public address check failed") + if len(raw) > 1024 * 1024: + refuse("legacy wallet RPC response is oversized") + try: + payload = json.loads(raw.decode("utf-8")) + address = payload["result"]["address"] + except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError): + refuse("legacy wallet RPC returned an invalid public address response") + alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + if ( + not isinstance(address, str) + or len(address) != 95 + or not address.startswith("5") + or any(character not in alphabet for character in address) + ): + refuse("legacy wallet RPC address is not a primary stagenet address") + + +def validate_legacy() -> tuple[os.stat_result, os.stat_result]: + if os.geteuid() != 0: + refuse("run as root so process ownership checks cannot be hidden") + if systemctl("is-active", "--quiet", LEGACY_UNIT).returncode != 0: + refuse("legacy wallet service must be active") + pid = main_pid(LEGACY_UNIT) + if pid <= 1: + refuse("legacy wallet service has no stable MainPID") + started = process_start_time(pid) + expected_uid, _ = identity(LEGACY_USER, LEGACY_GROUP) + if process_uid(pid) != expected_uid: + refuse("legacy wallet process UID is unexpected") + try: + executable = pathlib.Path(os.readlink(f"/proc/{pid}/exe")) + except OSError: + refuse("legacy wallet process executable is unavailable") + if executable != LEGACY_BINARY: + refuse("legacy wallet process executable is unexpected") + if not process_has_unit_cgroup(pid, LEGACY_UNIT): + refuse("legacy wallet process cgroup is unexpected") + require_exact_listener(pid, LEGACY_RPC_PORT) + require_directory(LEGACY_DIR, LEGACY_USER, LEGACY_GROUP, 0o775) + wallet = require_regular(LEGACY_WALLET, LEGACY_USER, LEGACY_GROUP, 0o600) + keys = require_regular(LEGACY_KEYS, LEGACY_USER, LEGACY_GROUP, 0o600) + validate_public_address() + if main_pid(LEGACY_UNIT) != pid or process_start_time(pid) != started: + refuse("legacy wallet process changed during preflight") + require_exact_listener(pid, LEGACY_RPC_PORT) + return wallet, keys + + +def validate_candidate_prerequisites(wallet: os.stat_result, keys: os.stat_result) -> None: + try: + record = pwd.getpwnam(CANDIDATE_USER) + group = grp.getgrnam(CANDIDATE_GROUP) + except KeyError: + refuse("parallel candidate service identity is not provisioned") + if ( + record.pw_uid >= 1000 + or record.pw_gid != group.gr_gid + or os.getgrouplist(CANDIDATE_USER, record.pw_gid) != [record.pw_gid] + or record.pw_dir != os.fspath(CANDIDATE_HOME) + or record.pw_shell not in ("/usr/sbin/nologin", "/bin/false") + ): + refuse("parallel candidate identity is not a dedicated system account") + require_regular(CANDIDATE_BINARY, "root", "root", 0o755) + require_directory(CANDIDATE_HOME.parent, "root", "root", 0o755) + if os.path.lexists(CANDIDATE_STAGE) or os.path.lexists(CANDIDATE_HOME): + refuse("candidate staging/final path already exists; do not overwrite it") + if systemctl("is-active", "--quiet", CANDIDATE_UNIT).returncode == 0: + refuse("candidate unit must be inactive") + require_unused_port(CANDIDATE_RPC_PORT) + require_unused_port(VERIFY_RPC_PORT) + capacity = os.statvfs(CANDIDATE_HOME.parent) + available = capacity.f_bavail * capacity.f_frsize + required = wallet.st_size + keys.st_size + 64 * 1024 * 1024 + if available < required: + refuse("insufficient space for a non-replacing candidate wallet copy") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Read-only Monero stagenet parallel preflight") + parser.add_argument( + "scope", + choices=("legacy", "candidate-prereqs"), + help="legacy checks the current RPC; candidate-prereqs also checks inert new-path gates", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + options = parse_args(sys.argv[1:] if argv is None else argv) + harden_process() + wallet, keys = validate_legacy() + if options.scope == "candidate-prereqs": + validate_candidate_prerequisites(wallet, keys) + print(f"PASSED: read-only {options.scope} preflight") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Refusal as problem: + print(f"REFUSED: {problem}", file=sys.stderr) + raise SystemExit(1) + except Exception: + # Do not render process or RPC data in an unexpected exception. + print("REFUSED: unexpected read-only preflight failure", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/deploy/monero-wallet-rpc.conf.example b/scripts/deploy/monero-wallet-rpc.conf.example new file mode 100644 index 0000000..a72fdd1 --- /dev/null +++ b/scripts/deploy/monero-wallet-rpc.conf.example @@ -0,0 +1,11 @@ +# Install as /etc/monerogue-wallet/wallet-rpc.conf, owned root:monerogue-wallet, mode 0640. +# Replace CHANGE_ME with an independently generated password. The same rpc-login credentials +# must be set as WALLET_RPC_USER/WALLET_RPC_PASSWORD in the app's protected .env file. +stagenet=1 +wallet-file=/var/lib/monerogue-wallet/stagenet +password-file=/etc/monerogue-wallet/wallet-password +rpc-bind-port=38083 +rpc-bind-ip=127.0.0.1 +daemon-address=127.0.0.1:38081 +rpc-login=monerogue:CHANGE_ME_STRONG_RPC_PASSWORD +non-interactive=1 diff --git a/scripts/deploy/monerogue-stagenet-wallet-candidate.service b/scripts/deploy/monerogue-stagenet-wallet-candidate.service new file mode 100644 index 0000000..dcfa8f4 --- /dev/null +++ b/scripts/deploy/monerogue-stagenet-wallet-candidate.service @@ -0,0 +1,54 @@ +# Manual-only, parallel Monero STAGENET wallet-RPC candidate. +# +# REVIEW STATUS: INERT TEMPLATE. Do not install until the bundle producer and its crash-injection +# evidence satisfy README-monero-stagenet-parallel-candidate.md. This unit intentionally has no +# installation stanza, so it cannot be enabled into a boot target. +[Unit] +Description=Monerogue parallel wallet RPC candidate (stagenet, manual only) +After=network-online.target monerod.service +Wants=network-online.target monerod.service +ConditionPathExists=/var/lib/monerogue-stagenet-wallet-candidate/READY + +[Service] +Type=simple +User=monerogue-wallet-candidate +Group=monerogue-wallet-candidate +SupplementaryGroups= +WorkingDirectory=/var/lib/monerogue-stagenet-wallet-candidate/wallet + +# The protected config fixes stagenet, wallet/password paths, daemon 127.0.0.1:38081, +# RPC 127.0.0.1:38084, and Digest rpc-login. No secret belongs in this unit or argv. +ExecStart=/usr/bin/monero-wallet-rpc --config-file /var/lib/monerogue-stagenet-wallet-candidate/config/wallet-rpc.conf --log-file /dev/null + +# A candidate must be restarted deliberately and revalidated; it is not a production dependency. +Restart=no +TimeoutStopSec=60 +UMask=0077 +LimitCORE=0 + +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +PrivateUsers=no +RemoveIPC=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=invisible +ProcSubset=pid +RestrictSUIDSGID=yes +RestrictRealtime=yes +CapabilityBoundingSet= +AmbientCapabilities= +LockPersonality=yes +MemoryDenyWriteExecute=yes +KeyringMode=private +RestrictNamespaces=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +ReadWritePaths=/var/lib/monerogue-stagenet-wallet-candidate/wallet diff --git a/scripts/deploy/monerogue.service b/scripts/deploy/monerogue.service index f4ea139..6103fb2 100644 --- a/scripts/deploy/monerogue.service +++ b/scripts/deploy/monerogue.service @@ -5,18 +5,45 @@ # two are fully independent. NPM (Nginx Proxy Manager) already forwards monerogue.app -> :3001. [Unit] Description=Monerogue Game Server (Monero Stagenet) -After=network.target postgresql.service monero-wallet-rpc.service +After=network-online.target postgresql.service monero-wallet-rpc.service +Wants=network-online.target Wants=monero-wallet-rpc.service [Service] Type=simple User=monerogue Group=monerogue -WorkingDirectory=/var/www/monerogue/app/src -EnvironmentFile=/var/www/monerogue/app/src/.env +WorkingDirectory=/var/www/monerogue/current/src +EnvironmentFile=/etc/monerogue/app.env +EnvironmentFile=/etc/monerogue/wallet-rpc.env +ExecStartPre=/usr/bin/node scripts/preflight.js ExecStart=/usr/bin/node index.js Restart=on-failure RestartSec=10 +TimeoutStopSec=30 +KillSignal=SIGTERM +UMask=0077 + +# Process/filesystem hardening. Application state lives in PostgreSQL, so the checkout is +# intentionally read-only; PrivateTmp provides the only disposable writable filesystem. +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectProc=invisible +ProcSubset=pid +RestrictSUIDSGID=yes +CapabilityBoundingSet= +AmbientCapabilities= +LockPersonality=yes +RestrictNamespaces=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native [Install] WantedBy=multi-user.target diff --git a/scripts/deploy/wownero-wallet-rpc.conf.example b/scripts/deploy/wownero-wallet-rpc.conf.example new file mode 100644 index 0000000..3075285 --- /dev/null +++ b/scripts/deploy/wownero-wallet-rpc.conf.example @@ -0,0 +1,10 @@ +# Install as /etc/wownerogue/wallet-rpc.conf, owned root:wownerogue-wallet, mode 0640. +# Replace CHANGE_ME with an independently generated password. Configure the same credentials +# as WALLET_RPC_USER/WALLET_RPC_PASSWORD in the app's protected .env file. +wallet-file=/var/lib/wownerogue-wallet/mainnet +password-file=/etc/wownerogue/wallet-password +rpc-bind-port=34570 +rpc-bind-ip=127.0.0.1 +daemon-address=127.0.0.1:34568 +rpc-login=wownerogue:CHANGE_ME_STRONG_RPC_PASSWORD +non-interactive=1 diff --git a/scripts/deploy/wownero-wallet-rpc.service b/scripts/deploy/wownero-wallet-rpc.service new file mode 100644 index 0000000..168efce --- /dev/null +++ b/scripts/deploy/wownero-wallet-rpc.service @@ -0,0 +1,42 @@ +# Wownero MAINNET wallet RPC for play.wowne.ro. +# Install to /etc/systemd/system/wownero-wallet-rpc.service. +[Unit] +Description=Wownero Wallet RPC (Mainnet) - wownerogue +After=network-online.target wownerod.service +Wants=network-online.target +Wants=wownerod.service + +[Service] +User=wownerogue-wallet +Group=wownerogue-wallet +Type=simple + +ExecStart=/usr/bin/wownero-wallet-rpc --config-file /etc/wownerogue/wallet-rpc.conf + +Restart=always +RestartSec=10 +TimeoutStopSec=60 +UMask=0077 + +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectProc=invisible +ProcSubset=pid +RestrictSUIDSGID=yes +CapabilityBoundingSet= +AmbientCapabilities= +LockPersonality=yes +RestrictNamespaces=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +ReadWritePaths=/var/lib/wownerogue-wallet + +[Install] +WantedBy=multi-user.target diff --git a/scripts/deploy/wownerogue.service b/scripts/deploy/wownerogue.service new file mode 100644 index 0000000..3f4e111 --- /dev/null +++ b/scripts/deploy/wownerogue.service @@ -0,0 +1,44 @@ +# play.wowne.ro game instance (Wownero mainnet, free + paid prestige, no payouts). +# Install to /etc/systemd/system/wownerogue.service. +[Unit] +Description=Wownerogue Game Server (Wownero Mainnet) +After=network-online.target postgresql.service wownero-wallet-rpc.service +Wants=network-online.target +Wants=wownero-wallet-rpc.service + +[Service] +Type=simple +User=wownerogue +Group=wownerogue +WorkingDirectory=/var/www/wownerogue/current/src +EnvironmentFile=/etc/wownerogue/app.env +EnvironmentFile=/etc/wownerogue/wallet-rpc.env +ExecStartPre=/usr/bin/node scripts/preflight.js +ExecStart=/usr/bin/node index.js +Restart=on-failure +RestartSec=10 +TimeoutStopSec=30 +KillSignal=SIGTERM +UMask=0077 + +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectProc=invisible +ProcSubset=pid +RestrictSUIDSGID=yes +CapabilityBoundingSet= +AmbientCapabilities= +LockPersonality=yes +RestrictNamespaces=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native + +[Install] +WantedBy=multi-user.target diff --git a/scripts/deploy/wowngeon-db-backup.service b/scripts/deploy/wowngeon-db-backup.service new file mode 100644 index 0000000..c3abb59 --- /dev/null +++ b/scripts/deploy/wowngeon-db-backup.service @@ -0,0 +1,33 @@ +[Unit] +Description=Validated PostgreSQL backups for Wowngeon instances +After=postgresql.service +Requires=postgresql.service + +[Service] +Type=oneshot +User=postgres +Group=postgres +Environment=BACKUP_ROOT=/var/backups/wowngeon/daily +Environment=BACKUP_RETENTION_DAYS=14 +ExecStart=/usr/local/sbin/wowngeon-db-backup +UMask=0077 + +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectProc=invisible +ProcSubset=pid +RestrictSUIDSGID=yes +CapabilityBoundingSet= +AmbientCapabilities= +LockPersonality=yes +RestrictNamespaces=yes +RestrictAddressFamilies=AF_UNIX +SystemCallArchitectures=native +ReadWritePaths=/var/backups/wowngeon/daily diff --git a/scripts/deploy/wowngeon-db-backup.sh b/scripts/deploy/wowngeon-db-backup.sh new file mode 100644 index 0000000..d8a076a --- /dev/null +++ b/scripts/deploy/wowngeon-db-backup.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu + +# Atomic daily custom-format dumps for both production instances. Run as the postgres OS user; +# the service unit grants write access only to this dedicated directory. +backup_root=${BACKUP_ROOT:-/var/backups/wowngeon/daily} +retention_days=${BACKUP_RETENTION_DAYS:-14} +stamp=$(/usr/bin/date -u +%Y%m%dT%H%M%SZ) + +[ "$backup_root" = /var/backups/wowngeon/daily ] || { + echo "Refusing an unapproved backup root: $backup_root" >&2 + exit 64 +} +case "$retention_days" in + ''|*[!0-9]*) echo "BACKUP_RETENTION_DAYS must be a positive integer" >&2; exit 64 ;; +esac +[ "$retention_days" -gt 0 ] || { echo "BACKUP_RETENTION_DAYS must be positive" >&2; exit 64; } +[ -d "$backup_root" ] && [ ! -L "$backup_root" ] || { + echo "Backup root is missing or is a symlink" >&2 + exit 66 +} + +for database_name in wownerogue monerogue; do + final_path="${backup_root}/${database_name}-${stamp}.dump" + temporary_path="${backup_root}/.${database_name}-${stamp}.dump.tmp" + checksum_path="${final_path}.sha256" + checksum_temporary="${checksum_path}.tmp" + + /usr/bin/pg_dump --format=custom --no-owner --file="$temporary_path" "$database_name" + /usr/bin/pg_restore --list "$temporary_path" >/dev/null + /usr/bin/chmod 0600 "$temporary_path" + /usr/bin/mv "$temporary_path" "$final_path" + + ( + cd "$backup_root" + /usr/bin/sha256sum "$(/usr/bin/basename "$final_path")" + ) >"$checksum_temporary" + /usr/bin/chmod 0600 "$checksum_temporary" + /usr/bin/mv "$checksum_temporary" "$checksum_path" +done + +# Local copies are an operational convenience, not the retention archive. The independently +# encrypted restic vault keeps GFS history; bound this same-disk directory so it cannot fill `/`. +/usr/bin/find "$backup_root" -maxdepth 1 -type f \ + \( -name 'monerogue-*.dump' -o -name 'monerogue-*.dump.sha256' \ + -o -name 'wownerogue-*.dump' -o -name 'wownerogue-*.dump.sha256' \) \ + -mtime "+$retention_days" -delete +/usr/bin/find "$backup_root" -maxdepth 1 -type f -name '.*.tmp' -mtime +1 -delete diff --git a/scripts/deploy/wowngeon-db-backup.timer b/scripts/deploy/wowngeon-db-backup.timer new file mode 100644 index 0000000..cc5d037 --- /dev/null +++ b/scripts/deploy/wowngeon-db-backup.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Daily Wowngeon PostgreSQL backups + +[Timer] +OnCalendar=*-*-* 03:20:00 +RandomizedDelaySec=20m +Persistent=true +Unit=wowngeon-db-backup.service + +[Install] +WantedBy=timers.target diff --git a/scripts/deploy/wowngeon-firewall.service b/scripts/deploy/wowngeon-firewall.service new file mode 100644 index 0000000..93e4d17 --- /dev/null +++ b/scripts/deploy/wowngeon-firewall.service @@ -0,0 +1,30 @@ +[Unit] +Description=Wowngeon application and wallet RPC firewall +After=network.target +Before=wownerogue.service monerogue.service wownero-wallet-rpc.service monero-wallet-rpc.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/wowngeon/firewall.env +ExecStart=/usr/local/sbin/wowngeon-firewall +RemainAfterExit=yes + +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=no +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectProc=invisible +ProcSubset=pid +RestrictSUIDSGID=yes +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW +LockPersonality=yes +RestrictNamespaces=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +SystemCallArchitectures=native + +[Install] +WantedBy=multi-user.target diff --git a/scripts/deploy/wowngeon-firewall.sh b/scripts/deploy/wowngeon-firewall.sh new file mode 100644 index 0000000..ac4fb38 --- /dev/null +++ b/scripts/deploy/wowngeon-firewall.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +# Idempotent application/RPC isolation. Install mode 0755 at +# /usr/local/sbin/wowngeon-firewall and provide NPM_SOURCE_IPV4 through the unit's EnvironmentFile. +: "${NPM_SOURCE_IPV4:?Set NPM_SOURCE_IPV4 to the reverse proxy address}" + +ensure_rule() { + command_name=$1 + shift + if ! "$command_name" -C INPUT "$@" 2>/dev/null; then + "$command_name" -I INPUT 1 "$@" + fi +} + +# Only the reverse proxy (and local probes) may reach either Node listener. +ensure_rule /usr/sbin/iptables ! -i lo ! -s "$NPM_SOURCE_IPV4" \ + -p tcp -m multiport --dports 3000,3001 -j REJECT +ensure_rule /usr/sbin/ip6tables ! -i lo \ + -p tcp -m multiport --dports 3000,3001 -j REJECT + +# Wallet and daemon JSON-RPC are local control planes. P2P ports are intentionally untouched. +ensure_rule /usr/sbin/iptables ! -i lo \ + -p tcp -m multiport --dports 34568,34570,38081,38083 -j REJECT +ensure_rule /usr/sbin/ip6tables ! -i lo \ + -p tcp -m multiport --dports 34568,34570,38081,38083 -j REJECT diff --git a/src/.env.example b/src/.env.example index 8015e85..176febc 100644 --- a/src/.env.example +++ b/src/.env.example @@ -2,7 +2,8 @@ # Wownerogue Server Configuration # ============================================================================= # Copy this file to .env and customize for your environment. -# The server validates these settings at startup and warns about misconfigurations. +# The server validates these settings at startup. Production fails closed on missing secrets, +# ambiguous payout flags, simulated blocks, or missing wallet/daemon RPC endpoints. # # QUICK START CONFIGURATIONS: # --------------------------- @@ -51,6 +52,9 @@ PORT=3000 # - development: verbose logging, can use simulated blocks # - debug: maximum logging, debug hotkeys enabled NODE_ENV=production +# External renderer downloads are disabled by default. When true, pinned Pixi/Three +# bundles may be loaded from jsDelivr; vendored, self-hosted assets are preferred. +RENDERER_CDN_ENABLED=false # Network selection (only matters for Monero - Wownero only has mainnet) MONERO_NETWORK=mainnet @@ -166,7 +170,7 @@ PAYMENTS_ENABLED=true # DIRECT_REQUIRES_ADDRESS=false CREDITS_REQUIRES_ADDRESS=false (no payout = no address) # • Test/stagenet (fake money): full pay-to-play WITH payouts is fine. # PAYMENTS_ENABLED=true PAYOUTS_ENABLED=true *_PAYOUTS_ENABLED=true -FREE_PLAY_ENABLED=false +FREE_PLAY_ENABLED=true # Which payment modes to enable (comma-separated) PAYMENT_MODES=direct,credits @@ -185,14 +189,14 @@ DIRECT_GAME_PRICE=100000000000 # Must user provide a payout address before playing? # true = User must set address first (ensures they can receive winnings) # false = User can play without address (but can't receive payouts!) -DIRECT_REQUIRES_ADDRESS=true +DIRECT_REQUIRES_ADDRESS=false # Allow guest play without any address? (Usually false for paid modes) DIRECT_ALLOW_GUEST_PLAY=false # Payout multipliers for direct mode wins: # If user pays 1 WOW and ESCAPE multiplier is 2.0, they get 2 WOW on escape. -DIRECT_PAYOUTS_ENABLED=true +DIRECT_PAYOUTS_ENABLED=false DIRECT_PAYOUT_ESCAPE=2.0 # Escape dungeon = 2x payment DIRECT_PAYOUT_TREASURE=3.0 # Escape WITH treasure = 3x payment @@ -208,7 +212,7 @@ CREDITS_ENABLED=true CREDITS_PER_GAME=1 # Must user provide address for credits mode? -CREDITS_REQUIRES_ADDRESS=true +CREDITS_REQUIRES_ADDRESS=false # When both modes enabled: ALLOW_MIXED_MODE=true # Show both options to user @@ -219,6 +223,10 @@ CREDITS_PAYOUTS_ENABLED=false CREDITS_PAYOUT_BASE=50000000000 # Base amount for credit wins CREDITS_PAYOUT_ESCAPE=2.0 # Same as direct mode CREDITS_PAYOUT_TREASURE=3.0 # Same as direct mode +# Optional credit-payout bounds. The global PAYOUT_MAX_PER_GAME below is always +# the outer safety cap, even when this mode-specific maximum is larger. +# CREDITS_PAYOUT_MIN=10000000000 +# CREDITS_PAYOUT_MAX=10000000000000 # Credit packages - JSON array of available bundles # Fields: @@ -245,9 +253,11 @@ CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"900000000000","bonus":0}, # a "grants" field. Example: # CREDITS_PACKAGES='[{"id":"supporter_25","credits":20,"bonus":5,"price":"1000000000000","grants":{"packs":["generated-skins","kenney-3d-characters"],"premiumLevel":"supporter"}}]' -# Standalone cosmetic products (no game entry or credits required). Each product -# may grant packs and/or a premium tier via "grants". JSON array; empty by default. +# Standalone products (no game entry or credits required). Each product may grant packs, +# a premium tier, and/or funded race tickets. Crypto-match tickets must declare an atomic +# value exactly equal to MATCH_ENTRY_FEE_ATOMIC, and the product price must cover every ticket. # COSMETIC_PRODUCTS='[{"id":"pack_3d","label":"3D Character Pack","price":"250000000000","grants":{"packs":["kenney-3d-characters"]}}]' +# COSMETIC_PRODUCTS='[{"id":"race_2","label":"Two race entries","price":"10000000000","grants":{"race_entries":2,"race_entry_value_atomic":"5000000000"}}]' # ============================================================================= # SECTION 6b: EARLY ENTRY @@ -273,8 +283,14 @@ EARLY_ENTRY_CREDITS_MODE=true # Controls how winnings are sent back to players. # Payouts are batched for efficiency and to reduce transaction fees. -# Master payout switch (disable to accept payments but never pay out - EVIL!) -PAYOUTS_ENABLED=true +# Master payout switch. The safe mainnet product sells entry/credits for leaderboard prestige +# without crypto winnings. The separate XMR stagenet instance enables this for 2x/3x demo payouts. +# This switch suppresses payout creation, block/batch dispatch, and retry workers. +PAYOUTS_ENABLED=false + +# Production mainnet payouts are additionally fail-closed. Only set this true after legal review, +# bankroll/risk review, and a real wallet-RPC integration test. It is not needed on stagenet. +ALLOW_MAINNET_PAYOUTS=false # Minimum payout amount (atomic units). Prevents dust transactions. # 10000000000 = 0.1 WOW @@ -287,6 +303,10 @@ PAYOUT_MAX_PER_GAME=10000000000000 # How often to process payout queue (seconds). 300 = every 5 minutes. PAYOUT_BATCH_INTERVAL=300 +# Confirmed incoming outputs must reach at least this many chain confirmations before granting +# a game, credits, cosmetics, or race tickets. +PAYMENT_CONFIRMATIONS=1 + # Retry failed payouts this many times before marking as permanently failed. # Failed payouts are logged with error details. Check logs or query: # SELECT * FROM payouts WHERE status = 'failed'; @@ -322,18 +342,23 @@ GAME_COOLDOWN_SECONDS=5 # Minimum seconds between games # # wownero-wallet-rpc \ # --wallet-file /path/to/wallet \ -# --password "wallet-password" \ +# --password-file /path/to/root-readable-wallet-password \ +# --rpc-bind-ip 127.0.0.1 \ # --rpc-bind-port 34570 \ -# --rpc-login user:password \ -# --disable-rpc-login # OR use rpc-login for security +# --rpc-login user:strong-random-rpc-password +# +# Production requires rpc-login even on loopback. The application deliberately proves that an +# unauthenticated request receives a Digest challenge; configured credentials do not make an +# unauthenticated wallet daemon safe. Never combine rpc-login with --disable-rpc-login, and never +# expose wallet RPC beyond the local host. # # The wallet must be synced with a daemon to see incoming transactions. PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570 -# If your wallet-rpc uses --rpc-login, set credentials here: -# WALLET_RPC_USER=user -# WALLET_RPC_PASSWORD=password +# Required together in production: +# WALLET_RPC_USER=wownerogue +# WALLET_RPC_PASSWORD=replace-with-a-distinct-strong-rpc-password # ============================================================================= # SECTION 10: BLOCKCHAIN DAEMON RPC @@ -355,9 +380,21 @@ RPC_POLL_INTERVAL=2000 # Secure endpoints for refunds, credit adjustments, and user management. # Generate a strong random key and keep it secret! -# Generate with: openssl rand -hex 32 +# Required in production. Generate with: openssl rand -hex 32 # ADMIN_API_KEY=your-64-character-hex-key-here +# Public operator and paid-play disclosure controls. Production paid instances fail startup +# until these are explicitly set to truthful, non-placeholder values. +# OPERATOR_NAME=Responsible operator name +# OPERATOR_CONTACT_URL=mailto:support@example.com +# OPERATOR_CONTACT_LABEL=Support +# LEGAL_POLICY_VERSION=2026-07-21-v1 +# TERMS_EFFECTIVE_DATE=2026-07-21 +# MINIMUM_AGE=18 +# PAID_ACKNOWLEDGEMENT_REQUIRED=true +# Optional plain-language location restriction notice supplied after legal review: +# RESTRICTED_LOCATIONS_NOTICE= + # Use in requests: # curl -H "X-Admin-Key: your-key" http://localhost:3000/api/admin/... @@ -369,6 +406,7 @@ RPC_POLL_INTERVAL=2000 # true, the real client IP is read from the X-Forwarded-For header for rate limiting and # req.ip. Leave unset/false for direct exposure, otherwise clients could spoof their IP. # TRUST_PROXY=true +# TRUST_PROXY_HOPS=1 # NPM directly in front of Node # Anti-instant-death grace (ms) before a block-timeout can end a game. This is NOT a # fairness floor — random block timing is the core mechanic. It only prevents the @@ -376,13 +414,6 @@ RPC_POLL_INTERVAL=2000 # renders / before the first move is possible). Default 2000 (2s); set 0 to disable. # GAME_START_GRACE_MS=2000 -# ============================================================================= -# SECTION 12: SERVER & MULTI-INSTANCE -# ============================================================================= -# Port for HTTP server (default 3000) -# Use different ports for multiple instances (e.g., 3000 for WOW, 3001 for XMR) -PORT=3000 - # ============================================================================= # SECTION 13: ADMIN ALERTS (Resend) # ============================================================================= @@ -421,6 +452,11 @@ BALANCE_WARN=100000000000 # Critical threshold - halts new games (default: 0.01 WOW/XMR) BALANCE_CRITICAL=10000000000 +# Scan recently expired invoices for transfers that mined after the 30-minute watcher stopped. +# Valid receipts create manual-review records only; they never auto-grant stale entries/products. +LATE_PAYMENT_RECONCILE_HOURS=72 +LATE_PAYMENT_RECONCILE_BATCH=100 + # Legacy alias (superseded by BALANCE_WARN, kept for backwards compatibility) # LOW_BALANCE_THRESHOLD=100000000000 @@ -431,6 +467,10 @@ BALANCE_CRITICAL=10000000000 # Alert when pending/failed payouts exceed this count HIGH_PENDING_THRESHOLD=10 +# Alert when payout processing or refund requested/processing remains nonterminal this long. +# These alerts never retry a transfer. Default: 900000 (15 minutes). +FINANCIAL_REVIEW_STALE_MS=900000 + # Alert cooldown in milliseconds (avoid spam) # Default: 3600000 (1 hour) ALERT_COOLDOWN_MS=3600000 @@ -484,7 +524,7 @@ CRYPTO_TYPE=WOW # Tavern & Multiplayer modes (optional) # ============================================================================= # Tavern: a walkable social room (chat, avatars, spectator camera). -TAVERN_ENABLED=false +TAVERN_ENABLED=true # Server-tick / snapshot broadcast cadence in ms (default 100 = 10 Hz) # TAVERN_TICK_MS=100 @@ -492,14 +532,23 @@ TAVERN_ENABLED=false # Match mode (multiplayer races) # ============================================================================= # Enable real-time multiplayer races. Solo/Tavern are unaffected. -MATCH_ENABLED=false +MATCH_ENABLED=true -# Max players per race (2–32). Default 4. -# MATCH_MAX_PLAYERS=4 +# Max players per match, also bounded by the selected ruleset (built-ins max at 8). +MATCH_MAX_PLAYERS=4 # Server tick interval in ms (default 250 = 4 Hz). # MATCH_TICK_MS=250 +# Server-selected match ruleset. Clients cannot choose this value. +# Built-ins: race, last-alive, score-attack, coop-escape. Default: race. +MATCH_RULESET_ID=race + +# Paid matches freeze entrants at H, target H+2, and activate at target+1 by default. +# Production requires explicit safe integers from 2 through 100 for both settings. +MATCH_PAID_ENTROPY_DELAY_BLOCKS=2 +MATCH_PAID_ENTROPY_CONFIRMATIONS=2 + # Minimum race duration in ms before the next block can end it (default 20s). # MATCH_MIN_DURATION_MS=20000 @@ -509,12 +558,15 @@ MATCH_ENABLED=false # Credits cost for prestige-only races (default 1). # MATCH_CREDITS_COST=1 -# Crypto race house fee percent (default 5). -# MATCH_HOUSE_FEE_PERCENT=5 +# Crypto race house fee percent. Production crypto matches require an explicit value from +# 0 (inclusive) through 100 (exclusive). +MATCH_HOUSE_FEE_PERCENT=5 -# Optional override for the crypto-race per-player entry fee in atomic units. -# If unset, the existing per-game price (DIRECT_GAME_PRICE / SINGLE_GAME_PRICE) is used. -# MATCH_ENTRY_FEE_ATOMIC=5000000000 +# Required crypto-race per-player entry fee in atomic units when payout matches are enabled. +MATCH_ENTRY_FEE_ATOMIC=5000000000 -# Enable crypto race-entry ticket economy (requires paid instance + ticket products). -# MATCH_CRYPTO_RACE_ENABLED=false +# Crypto admission requires all three explicit gates plus a worst-case winner cap. Co-op has no +# winner-takes-pot split contract and is therefore rejected for crypto matches. +MATCH_CRYPTO_RACE_ENABLED=false +MATCH_PAYOUTS_ENABLED=false +MATCH_PAYOUT_MAX=50000000000 diff --git a/src/.env.mainnet.example b/src/.env.mainnet.example new file mode 100644 index 0000000..f97508c --- /dev/null +++ b/src/.env.mainnet.example @@ -0,0 +1,86 @@ +# play.wowne.ro production profile: Wownero mainnet, free + paid prestige boards, no payouts. +# Copy to .env, replace every CHANGE_ME value, then run `npm run preflight`. + +NODE_ENV=production +RENDERER_CDN_ENABLED=false +HOST=0.0.0.0 +PORT=3000 +TRUST_PROXY=true +TRUST_PROXY_HOPS=1 +BLOCK_SOURCE=daemon + +CRYPTO_TYPE=WOW +MONERO_NETWORK=mainnet +SMIRK_ENABLED=false +HOSTED_BY=https://play.wowne.ro + +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_NAME=wownerogue +DB_USER=wownerogue +DB_PASSWORD=CHANGE_ME_STRONG_DATABASE_PASSWORD +ADMIN_API_KEY=CHANGE_ME_WITH_OPENSSL_RAND_HEX_32 + +# Public operator/disclosure metadata. These are startup-required for production payments. +# Do not copy placeholder identity/contact values into a live instance. +OPERATOR_NAME=CHANGE_ME_RESPONSIBLE_OPERATOR +OPERATOR_CONTACT_URL=mailto:CHANGE_ME_SUPPORT_ADDRESS +OPERATOR_CONTACT_LABEL=Support +LEGAL_POLICY_VERSION=2026-07-21-v1 +TERMS_EFFECTIVE_DATE=2026-07-21 +MINIMUM_AGE=18 +PAID_ACKNOWLEDGEMENT_REQUIRED=true +# Optional plain-language notice supplied after jurisdiction-specific legal review. +# RESTRICTED_LOCATIONS_NOTICE= + +# Free runs rank on the Pleb board; direct/credit runs rank on the paid prestige board. +PAYMENTS_ENABLED=true +FREE_PLAY_ENABLED=true +PAYMENT_MODES=direct,credits +ALLOW_MIXED_MODE=true +PREFER_CREDITS_FIRST=true + +DIRECT_PAYMENT_ENABLED=true +DIRECT_GAME_PRICE=100000000000 +DIRECT_REQUIRES_ADDRESS=false +DIRECT_ALLOW_GUEST_PLAY=false +DIRECT_PAYOUTS_ENABLED=false + +CREDITS_ENABLED=true +CREDITS_PER_GAME=1 +CREDITS_REQUIRES_ADDRESS=false +CREDITS_PAYOUTS_ENABLED=false +CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"900000000000","bonus":0}]' + +# Master interlock: the mainnet prestige profile must never create or dispatch winnings. +PAYOUTS_ENABLED=false +ALLOW_MAINNET_PAYOUTS=false +PAYMENT_CONFIRMATIONS=1 +LATE_PAYMENT_RECONCILE_HOURS=72 +LATE_PAYMENT_RECONCILE_BATCH=100 + +PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570 +WALLET_RPC_USER=wownerogue +WALLET_RPC_PASSWORD=CHANGE_ME_STRONG_RPC_PASSWORD +PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568 +FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568 +RPC_POLL_INTERVAL=2000 + +DIFFICULTY_PRESET=normal +EARLY_ENTRY_ENABLED=true +EARLY_ENTRY_FREE_MODE=true +EARLY_ENTRY_CREDITS_MODE=true + +TAVERN_ENABLED=true +TAVERN_TICK_MS=100 +MATCH_ENABLED=true +MATCH_RULESET_ID=race +MATCH_MAX_PLAYERS=4 +MATCH_PAID_ENTROPY_DELAY_BLOCKS=2 +MATCH_PAID_ENTROPY_CONFIRMATIONS=2 +MATCH_TICK_MS=250 +MATCH_MIN_DURATION_MS=20000 +MATCH_HARD_CEILING_MS=240000 +MATCH_CREDITS_COST=1 +MATCH_CRYPTO_RACE_ENABLED=false +MATCH_PAYOUTS_ENABLED=false diff --git a/src/.env.match.example b/src/.env.match.example index e66f3b3..4e0f966 100644 --- a/src/.env.match.example +++ b/src/.env.match.example @@ -10,6 +10,15 @@ MATCH_MAX_PLAYERS=4 # Server tick interval in milliseconds (default 250ms = 4 Hz) MATCH_TICK_MS=250 +# Server-selected ruleset. Built-ins: race, last-alive, score-attack, coop-escape. +# Unknown or solo-only values fail closed to race. +MATCH_RULESET_ID=race + +# Paid entrant freezes target H+2 and activate only after the target plus one confirmation. +# Production requires explicit safe integers from 2 through 100 for both settings. +MATCH_PAID_ENTROPY_DELAY_BLOCKS=2 +MATCH_PAID_ENTROPY_CONFIRMATIONS=2 + # Minimum duration in ms before a race can be ended by the next block (default 20s) MATCH_MIN_DURATION_MS=20000 @@ -21,3 +30,17 @@ MATCH_CREDITS_COST=1 # Enable crypto race-entry ticket economy (requires a paid instance) MATCH_CRYPTO_RACE_ENABLED=false + +# Independently stop new on-chain payout matches without affecting accepted liabilities. +MATCH_PAYOUTS_ENABLED=false + +# Required outer winner-payout cap (atomic units) when crypto payouts are enabled. +MATCH_PAYOUT_MAX=50000000000 + +# Explicit crypto economics (required for production preflight when enabled). +MATCH_ENTRY_FEE_ATOMIC=5000000000 +MATCH_HOUSE_FEE_PERCENT=5 + +# Also add a separately priced funded ticket product to COSMETIC_PRODUCTS. Its per-ticket +# backing must equal MATCH_ENTRY_FEE_ATOMIC and its price must cover the full grant. +# COSMETIC_PRODUCTS='[{"id":"race_2","label":"Two race entries","price":"10000000000","grants":{"race_entries":2,"race_entry_value_atomic":"5000000000"}}]' diff --git a/src/.env.stagenet.example b/src/.env.stagenet.example new file mode 100644 index 0000000..76bf579 --- /dev/null +++ b/src/.env.stagenet.example @@ -0,0 +1,107 @@ +# Monerogue production profile: XMR stagenet, free + paid solo, Tavern, and PvP race. +# Copy to .env, replace every CHANGE_ME value, then run `npm run preflight`. + +NODE_ENV=production +RENDERER_CDN_ENABLED=false +HOST=0.0.0.0 +PORT=3001 +TRUST_PROXY=true +TRUST_PROXY_HOPS=1 +BLOCK_SOURCE=daemon + +CRYPTO_TYPE=XMR +MONERO_NETWORK=stagenet +SMIRK_ENABLED=false +HOSTED_BY=https://monerogue.app +EXPLORER_TX_URL= + +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_NAME=monerogue +DB_USER=monerogue +DB_PASSWORD=CHANGE_ME_STRONG_DATABASE_PASSWORD +ADMIN_API_KEY=CHANGE_ME_WITH_OPENSSL_RAND_HEX_32 + +# Public operator/disclosure metadata. These are startup-required for production payments. +# Do not copy placeholder identity/contact values into a live instance. +OPERATOR_NAME=CHANGE_ME_RESPONSIBLE_OPERATOR +OPERATOR_CONTACT_URL=mailto:CHANGE_ME_SUPPORT_ADDRESS +OPERATOR_CONTACT_LABEL=Support +LEGAL_POLICY_VERSION=2026-07-21-v1 +TERMS_EFFECTIVE_DATE=2026-07-21 +MINIMUM_AGE=18 +PAID_ACKNOWLEDGEMENT_REQUIRED=true +# Optional plain-language notice supplied after jurisdiction-specific legal review. +# RESTRICTED_LOCATIONS_NOTICE= + +# The original solo game remains available for free and through both paid entry paths. +PAYMENTS_ENABLED=true +FREE_PLAY_ENABLED=true +PAYMENT_MODES=direct,credits +ALLOW_MIXED_MODE=true +PREFER_CREDITS_FIRST=true + +# 0.01 stagenet XMR per direct run; escape pays 2x, treasure escape pays 3x. +DIRECT_PAYMENT_ENABLED=true +DIRECT_GAME_PRICE=10000000000 +DIRECT_REQUIRES_ADDRESS=true +DIRECT_ALLOW_GUEST_PLAY=false +DIRECT_PAYOUTS_ENABLED=true +DIRECT_PAYOUT_ESCAPE=2.0 +DIRECT_PAYOUT_TREASURE=3.0 + +CREDITS_ENABLED=true +CREDITS_PER_GAME=1 +CREDITS_REQUIRES_ADDRESS=true +CREDITS_PAYOUTS_ENABLED=true +CREDITS_PAYOUT_BASE=10000000000 +CREDITS_PAYOUT_ESCAPE=2.0 +CREDITS_PAYOUT_TREASURE=3.0 +CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"90000000000","bonus":0},{"id":"medium","credits":20,"price":"170000000000","bonus":2}]' +# Provision this product before enabling the crypto-match gates below. The full price is +# allocated as two independently tracked 0.01 XMR race-ticket backing lots. +COSMETIC_PRODUCTS='[{"id":"race_2","label":"Two stagenet race entries","price":"20000000000","grants":{"race_entries":2,"race_entry_value_atomic":"10000000000"}}]' + +# Stagenet-only money switch and bankroll caps. Mainnet acknowledgement deliberately stays off. +PAYOUTS_ENABLED=true +ALLOW_MAINNET_PAYOUTS=false +PAYOUT_MIN_AMOUNT=1000000000 +PAYOUT_MAX_PER_GAME=50000000000 +PAYOUT_BATCH_INTERVAL=300 +PAYMENT_CONFIRMATIONS=1 +PAYOUT_MAX_RETRIES=3 +MAX_PAYOUT_BATCH_SIZE=50 +BALANCE_WARN=100000000000 +BALANCE_CRITICAL=50000000000 +LATE_PAYMENT_RECONCILE_HOURS=72 +LATE_PAYMENT_RECONCILE_BATCH=100 + +PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:38083 +WALLET_RPC_USER=monerogue +WALLET_RPC_PASSWORD=CHANGE_ME_STRONG_RPC_PASSWORD +PRIMARY_RPC_ENDPOINT=http://127.0.0.1:38081 +FALLBACK_RPC_ENDPOINT=http://127.0.0.1:38081 +RPC_POLL_INTERVAL=2000 + +DIFFICULTY_PRESET=casino +EARLY_ENTRY_ENABLED=true +EARLY_ENTRY_FREE_MODE=true +EARLY_ENTRY_CREDITS_MODE=true + +TAVERN_ENABLED=true +TAVERN_TICK_MS=100 +MATCH_ENABLED=true +MATCH_RULESET_ID=race +MATCH_MAX_PLAYERS=4 +MATCH_PAID_ENTROPY_DELAY_BLOCKS=2 +MATCH_PAID_ENTROPY_CONFIRMATIONS=2 +MATCH_TICK_MS=250 +MATCH_MIN_DURATION_MS=20000 +MATCH_HARD_CEILING_MS=240000 +MATCH_CREDITS_COST=1 +MATCH_HOUSE_FEE_PERCENT=5 +# Enable only after provisioning race-entry ticket products and validating escrow end to end. +MATCH_CRYPTO_RACE_ENABLED=false +MATCH_PAYOUTS_ENABLED=false +MATCH_ENTRY_FEE_ATOMIC=10000000000 +MATCH_PAYOUT_MAX=50000000000 diff --git a/src/auth/smirkPolicy.js b/src/auth/smirkPolicy.js new file mode 100644 index 0000000..3c321f6 --- /dev/null +++ b/src/auth/smirkPolicy.js @@ -0,0 +1,8 @@ +const { isTestNetworkFor } = require('../game/helpers/gameModeUtils'); + +/** Smirk is an explicit mainnet-only feature. Missing, malformed, or truthy-ish values stay off. */ +function isSmirkEnabled(env = process.env) { + return env.SMIRK_ENABLED === 'true' && !isTestNetworkFor(env.MONERO_NETWORK); +} + +module.exports = { isSmirkEnabled }; diff --git a/src/config/commerceDisclosurePolicy.js b/src/config/commerceDisclosurePolicy.js new file mode 100644 index 0000000..ed91197 --- /dev/null +++ b/src/config/commerceDisclosurePolicy.js @@ -0,0 +1,187 @@ +/** + * Public commerce disclosures and paid-action acknowledgement validation. + * + * This is a product safety control, not a jurisdiction or identity check. The server derives + * the words shown to players from the active runtime switches, and independently refuses paid + * actions unless the browser echoes the current policy version and every required acknowledgement. + */ + +const DEFAULT_POLICY_VERSION = '2026-07-21'; +const DEFAULT_MINIMUM_AGE = 18; +const ALLOWED_CONTACT_PROTOCOLS = new Set(['https:', 'http:', 'mailto:']); +const ACKNOWLEDGEMENT_KEYS = new Set([ + 'policyVersion', + 'ageEligible', + 'termsRead', + 'riskAccepted', + 'testnetUnderstood' +]); + +function isTrue(value) { + return ['true', '1', 'yes', 'on'].includes(String(value || '').trim().toLowerCase()); +} + +function minimumAge(env = process.env) { + const value = Number(env.MINIMUM_AGE); + return Number.isInteger(value) && value >= 18 && value <= 120 + ? value + : DEFAULT_MINIMUM_AGE; +} + +function safeContactUrl(value) { + const raw = String(value || '').trim(); + if (!raw) return null; + try { + const parsed = new URL(raw); + return ALLOWED_CONTACT_PROTOCOLS.has(parsed.protocol) ? parsed.href : null; + } catch (_) { + return null; + } +} + +function paidAcknowledgementRequired(env, gameModeManager) { + // Payment intake can be paused while already-purchased prestige credits remain spendable in + // PvP. Keep the public browser policy enabled for that value-bearing action too; free actions + // bypass the acknowledgement explicitly at their individual server handlers. + let matchEconomies = null; + try { + matchEconomies = gameModeManager?._getMatchEconomies?.() || null; + } catch (_) { + // Public disclosure generation must remain available during partial startup. The + // conservative fallback below still treats an enabled paid product as value-bearing. + matchEconomies = null; + } + const matchCanConsumePaidEntitlement = matchEconomies + ? Boolean(matchEconomies.credits_prestige || matchEconomies.crypto_race) + : Boolean( + gameModeManager?.creditsModeEnabled + || gameModeManager?.directModeEnabled + || gameModeManager?.freePlayEnabled + ); + const paidMatchEconomyAvailable = isTrue(env.MATCH_ENABLED) + && matchCanConsumePaidEntitlement; + const paidSoloEntitlementAvailable = Boolean( + gameModeManager?.creditsModeEnabled || gameModeManager?.directModeEnabled + ); + const productionPaid = String(env.NODE_ENV || '').toLowerCase() === 'production' + && Boolean( + gameModeManager?.paymentsEnabled + || paidSoloEntitlementAvailable + || paidMatchEconomyAvailable + ); + return productionPaid || isTrue(env.PAID_ACKNOWLEDGEMENT_REQUIRED); +} + +function buildCommerceDisclosure(gameModeManager, env = process.env) { + const cryptoType = String(gameModeManager?.cryptoType || env.CRYPTO_TYPE || 'WOW').toUpperCase(); + const network = String(gameModeManager?.network || env.MONERO_NETWORK || 'mainnet').toLowerCase(); + const isTestNetwork = Boolean(gameModeManager?.isTestNetwork) + || (cryptoType === 'XMR' && network !== 'mainnet'); + const paymentsEnabled = Boolean(gameModeManager?.paymentsEnabled); + const directEnabled = paymentsEnabled && Boolean(gameModeManager?.directModeEnabled); + const creditsEnabled = paymentsEnabled && Boolean(gameModeManager?.creditsModeEnabled); + // A stale subordinate payout flag for a disabled product must not be advertised as an active + // reward. Runtime startup policy uses the same mode-enabled distinction. + const directPayouts = directEnabled + && Boolean(gameModeManager?.isPayoutEnabledForMode?.('PAID_SINGLE')); + const creditsPayouts = creditsEnabled + && Boolean(gameModeManager?.isPayoutEnabledForMode?.('PAID_CREDITS')); + const cryptoMatchPayouts = isTrue(env.MATCH_ENABLED) + && isTrue(env.MATCH_CRYPTO_RACE_ENABLED) + && isTrue(env.MATCH_PAYOUTS_ENABLED) + && Boolean(gameModeManager?.payoutsEnabled); + const anyPayouts = directPayouts || creditsPayouts || cryptoMatchPayouts; + const currencyLabel = String(gameModeManager?.currencyLabel || (isTestNetwork ? `s${cryptoType}` : cryptoType)); + const operatorContactUrl = safeContactUrl(env.OPERATOR_CONTACT_URL); + + return Object.freeze({ + policyVersion: String(env.LEGAL_POLICY_VERSION || DEFAULT_POLICY_VERSION).trim(), + termsEffectiveDate: String(env.TERMS_EFFECTIVE_DATE || '').trim() || null, + minimumAge: minimumAge(env), + paidAcknowledgementRequired: paidAcknowledgementRequired(env, gameModeManager), + operator: Object.freeze({ + name: String(env.OPERATOR_NAME || '').trim() || 'Site operator (identity not configured)', + contactUrl: operatorContactUrl, + contactLabel: String(env.OPERATOR_CONTACT_LABEL || '').trim() + || (operatorContactUrl ? operatorContactUrl.replace(/^mailto:/, '') : 'Operator contact is not configured') + }), + service: Object.freeze({ + gameName: String(gameModeManager?.gameName || 'Wowngeon'), + cryptoType, + currencyLabel, + network, + isTestNetwork, + freePlayEnabled: Boolean(gameModeManager?.freePlayEnabled), + paymentsEnabled, + directPaidEntryEnabled: directEnabled, + paidCreditsEnabled: creditsEnabled, + soloPayoutsEnabled: directPayouts || creditsPayouts, + cryptoMatchPayoutsEnabled: cryptoMatchPayouts, + anyPayoutsEnabled: anyPayouts, + paidPrestigeOnly: (directEnabled || creditsEnabled) && !anyPayouts + }), + notices: Object.freeze({ + jurisdiction: String(env.RESTRICTED_LOCATIONS_NOTICE || '').trim() || null, + legalReview: 'These product disclosures are not a legal opinion, licence, or representation that the service is lawful in every location.', + identityControl: 'The acknowledgement records a player statement; it does not verify age, identity, or location.' + }), + links: Object.freeze({ + terms: '/terms', + privacy: '/privacy', + responsiblePlay: '/responsible-play' + }) + }); +} + +function validatePaidAcknowledgement(raw, disclosure, options = {}) { + const required = options.required === true || disclosure?.paidAcknowledgementRequired === true; + if (!required) return { ok: true, code: 'NOT_REQUIRED', acknowledgement: null }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { ok: false, code: 'PAID_ACK_REQUIRED', message: 'Review and acknowledge the paid-play disclosures before continuing.' }; + } + const prototype = Object.getPrototypeOf(raw); + const keys = Object.keys(raw); + if ((prototype !== Object.prototype && prototype !== null) + || keys.length !== ACKNOWLEDGEMENT_KEYS.size + || keys.some(key => !ACKNOWLEDGEMENT_KEYS.has(key))) { + return { ok: false, code: 'PAID_ACK_MALFORMED', message: 'The paid-play acknowledgement payload is invalid. Reload the disclosures and try again.' }; + } + if (typeof raw.policyVersion !== 'string' || raw.policyVersion.length < 1 || raw.policyVersion.length > 64) { + return { ok: false, code: 'PAID_ACK_MALFORMED', message: 'The paid-play acknowledgement payload is invalid. Reload the disclosures and try again.' }; + } + if (String(raw.policyVersion || '') !== String(disclosure.policyVersion || '')) { + return { ok: false, code: 'PAID_ACK_VERSION', message: 'The paid-play disclosures changed. Review the current version before continuing.' }; + } + if (typeof raw.ageEligible !== 'boolean' + || typeof raw.termsRead !== 'boolean' + || typeof raw.riskAccepted !== 'boolean' + || typeof raw.testnetUnderstood !== 'boolean') { + return { ok: false, code: 'PAID_ACK_MALFORMED', message: 'The paid-play acknowledgement payload is invalid. Reload the disclosures and try again.' }; + } + if (raw.ageEligible !== true || raw.termsRead !== true || raw.riskAccepted !== true) { + return { ok: false, code: 'PAID_ACK_INCOMPLETE', message: 'Every paid-play acknowledgement is required before continuing.' }; + } + if (disclosure.service?.isTestNetwork && raw.testnetUnderstood !== true) { + return { ok: false, code: 'PAID_ACK_TESTNET', message: 'Confirm that this test network uses valueless test coins and never send mainnet funds.' }; + } + if (!disclosure.service?.isTestNetwork && raw.testnetUnderstood !== false) { + return { ok: false, code: 'PAID_ACK_MALFORMED', message: 'The paid-play acknowledgement payload is invalid. Reload the disclosures and try again.' }; + } + const acknowledgement = { + policyVersion: disclosure.policyVersion, + ageEligible: true, + termsRead: true, + riskAccepted: true, + testnetUnderstood: disclosure.service?.isTestNetwork === true + }; + return { ok: true, code: 'ACKNOWLEDGED', acknowledgement: Object.freeze(acknowledgement) }; +} + +module.exports = { + DEFAULT_POLICY_VERSION, + buildCommerceDisclosure, + minimumAge, + paidAcknowledgementRequired, + safeContactUrl, + validatePaidAcknowledgement +}; diff --git a/src/config/environmentValidator.js b/src/config/environmentValidator.js index 5de02fe..5efcd32 100644 --- a/src/config/environmentValidator.js +++ b/src/config/environmentValidator.js @@ -1,90 +1,597 @@ /** - * Environment Validator - * Validates payment configuration and emits warnings for suspicious settings. + * Startup configuration validation. + * + * Development remains forgiving so a contributor can boot individual subsystems. Production is + * deliberately fail-closed: an ambiguous money or block-source setting must never silently fall + * back to a dangerous default. */ +const { isSupported, familyFor } = require('../chain/chainProfile'); +const { resolveMatchRuleset } = require('../game/rulesets'); +const money = require('../money/atomic'); +const { + PG_BIGINT_MAX, + cryptoRulesetSupported, + parseHouseFeeBasisPoints, + parsePositiveAtomic, + playerContract +} = require('../network/matchEconomyPolicy'); + +const TRUE_VALUES = new Set(['true', '1', 'yes', 'on']); +const BOOLEAN_VALUES = new Set(['true', 'false', '1', '0', 'yes', 'no', 'y', 'n', 'on', 'off']); +const PLACEHOLDER_SECRET = /(change[_-]?me|replace[_-]?with|your[_-]|example|password|secret)/i; + +const BOOLEAN_ENV_KEYS = [ + 'PAYMENTS_ENABLED', 'PAYOUTS_ENABLED', 'DIRECT_PAYMENT_ENABLED', + 'DIRECT_PAYOUTS_ENABLED', 'CREDITS_ENABLED', 'CREDITS_PAYOUTS_ENABLED', + 'CREDITS_PAYOUT_ENABLED', 'FREE_PLAY_ENABLED', 'DIRECT_REQUIRES_ADDRESS', + 'DIRECT_ALLOW_GUEST_PLAY', 'CREDITS_REQUIRES_ADDRESS', 'ALLOW_MIXED_MODE', + 'PREFER_CREDITS_FIRST', 'EARLY_ENTRY_ENABLED', 'EARLY_ENTRY_FREE_MODE', + 'EARLY_ENTRY_CREDITS_MODE', 'ALLOW_MAINNET_PAYOUTS', 'TRUST_PROXY', + 'SMIRK_ENABLED', 'TAVERN_ENABLED', 'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', + 'MATCH_PAYOUTS_ENABLED', + 'SIMULATED_BLOCKS', 'FORCE_SIMULATED_BLOCKS', 'DEBUG_HOTKEYS', + 'PAID_ACKNOWLEDGEMENT_REQUIRED' +]; + +const ATOMIC_ENV_KEYS = [ + 'DIRECT_GAME_PRICE', 'SINGLE_GAME_PRICE', 'CREDITS_PACKAGE_PRICE', + 'CREDITS_PAYOUT_BASE', 'PAYOUT_MIN_AMOUNT', 'PAYOUT_MAX_PER_GAME', + 'CREDITS_PAYOUT_MIN', 'CREDITS_PAYOUT_MAX', 'MAX_CREDIT_PURCHASE_PER_DAY', + 'BALANCE_WARN', 'BALANCE_CRITICAL', 'LOW_BALANCE_THRESHOLD', 'MATCH_ENTRY_FEE_ATOMIC', + 'MATCH_PAYOUT_MAX' +]; + +const POSITIVE_ATOMIC_ENV_KEYS = new Set([ + 'DIRECT_GAME_PRICE', 'SINGLE_GAME_PRICE', 'CREDITS_PACKAGE_PRICE', + 'CREDITS_PAYOUT_BASE', 'PAYOUT_MAX_PER_GAME', 'CREDITS_PAYOUT_MAX', + 'MAX_CREDIT_PURCHASE_PER_DAY', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_PAYOUT_MAX' +]); + +function isTrue(value) { + return TRUE_VALUES.has(String(value || '').trim().toLowerCase()); +} + +function canonicalInteger(value) { + return String(value ?? '').trim().replace(/_/g, ''); +} + +function raceTicketGrant(product) { + const grants = product?.grants; + if (!grants || typeof grants !== 'object') return null; + const hasEntries = Object.prototype.hasOwnProperty.call(grants, 'race_entries') + || Object.prototype.hasOwnProperty.call(grants, 'raceEntries'); + if (!hasEntries) return null; + return { + productId: String(product.id || '(unnamed)'), + entries: grants.race_entries ?? grants.raceEntries, + valueAtomic: grants.race_entry_value_atomic ?? grants.raceEntryValueAtomic, + price: product.price + }; +} + +function isLoopbackHost(hostname) { + const normalized = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase(); + return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; +} + class EnvironmentValidator { constructor(options = {}) { this.logger = options.logger || console; + this.env = options.env || process.env; + } + + _endpointIssue(name, value) { + if (!value) return `${name} is not set.`; + try { + const endpoint = new URL(value); + if (!['http:', 'https:'].includes(endpoint.protocol)) { + return `${name} must use http:// or https://.`; + } + return null; + } catch (_) { + return `${name} must be a valid URL.`; + } + } + + _pushEndpointTransportWarning(warnings, name, value) { + if (!value) return; + try { + const endpoint = new URL(value); + if (endpoint.protocol === 'http:' && !isLoopbackHost(endpoint.hostname)) { + warnings.push(`${name} uses plaintext HTTP on a non-loopback host; use TLS or a private authenticated network.`); + } + } catch (_) { + // Syntax errors are reported separately. + } } validate(config) { + const env = this.env; const warnings = []; const errors = []; + const production = String(env.NODE_ENV || '').toLowerCase() === 'production'; + const paymentsEnabled = Boolean(config?.paymentsEnabled); + const directEnabled = paymentsEnabled && Boolean(config?.modes?.direct?.enabled); + const creditsEnabled = paymentsEnabled && Boolean(config?.modes?.credits?.enabled); + const payoutMasterEnabled = Boolean(config?.payouts?.enabled); + const directPayoutsEnabled = payoutMasterEnabled && directEnabled + && Boolean(config?.payouts?.rules?.direct?.enabled); + const creditsPayoutsEnabled = payoutMasterEnabled && creditsEnabled + && Boolean(config?.payouts?.rules?.credits?.enabled); + const matchModeEnabled = isTrue(env.MATCH_ENABLED); + const matchCryptoEnabled = matchModeEnabled && isTrue(env.MATCH_CRYPTO_RACE_ENABLED); + const matchPayoutsEnabled = payoutMasterEnabled && matchCryptoEnabled + && isTrue(env.MATCH_PAYOUTS_ENABLED); + const anyPayoutsEnabled = directPayoutsEnabled || creditsPayoutsEnabled || matchPayoutsEnabled; + // PAYOUTS_ENABLED also authorizes settlement of already-created durable rows even when + // every admission switch is off, so payout-only recovery has the same wallet boundary. + const walletRequired = paymentsEnabled || payoutMasterEnabled; + const cryptoType = String(env.CRYPTO_TYPE || config?.currency?.symbol || 'WOW').trim().toUpperCase(); + const network = String(env.MONERO_NETWORK || 'mainnet').trim().toLowerCase(); - // Check for conflicting legacy/new config - if (process.env.GAME_MODE && process.env.PAYMENT_MODES) { + if (env.GAME_MODE && env.PAYMENT_MODES) { warnings.push('Both GAME_MODE and PAYMENT_MODES are set. PAYMENT_MODES takes precedence.'); } - // Wallet RPC required for payments - if (config.paymentsEnabled && !process.env.PRIMARY_WALLET_ENDPOINT) { - warnings.push('Payments enabled but PRIMARY_WALLET_ENDPOINT not set. Wallet RPC will fail.'); + if (!isSupported(cryptoType)) { + errors.push(`CRYPTO_TYPE=${cryptoType || '(empty)'} is not a supported chain profile.`); + } else if (walletRequired && familyFor(cryptoType) !== 'monero') { + errors.push(`Paid production adapters are not implemented for CRYPTO_TYPE=${cryptoType}; use WOW or XMR.`); } - // Blockchain RPC required for payments - if (config.paymentsEnabled && !process.env.PRIMARY_RPC_ENDPOINT) { - warnings.push('Payments enabled but PRIMARY_RPC_ENDPOINT missing. Block sync will fail.'); + if (cryptoType === 'WOW' && network !== 'mainnet') { + errors.push('Wownero only supports MONERO_NETWORK=mainnet.'); + } + if (cryptoType === 'XMR' && !['mainnet', 'stagenet', 'testnet'].includes(network)) { + errors.push('MONERO_NETWORK must be mainnet, stagenet, or testnet for XMR.'); } - // Direct mode price validation - if (config.modes.direct.enabled && config.modes.direct.price <= 0n) { - errors.push('Direct payment mode enabled but price is not positive.'); + const walletIssue = this._endpointIssue('PRIMARY_WALLET_ENDPOINT', env.PRIMARY_WALLET_ENDPOINT); + if (walletRequired && walletIssue) { + (production ? errors : warnings).push(`Financial workers require a wallet but ${walletIssue}`); } - - // Credits mode package validation - if (config.modes.credits.enabled && !config.modes.credits.packages.length) { - errors.push('Credits mode enabled but no credit packages defined.'); + if (Boolean(env.WALLET_RPC_USER) !== Boolean(env.WALLET_RPC_PASSWORD)) { + (production ? errors : warnings).push('WALLET_RPC_USER and WALLET_RPC_PASSWORD must be set together.'); } - - // Validate CREDITS_PACKAGES JSON format - if (process.env.CREDITS_PACKAGES) { + if (production && paymentsEnabled) { + if (!isTrue(env.PAID_ACKNOWLEDGEMENT_REQUIRED)) { + errors.push('Production payments require PAID_ACKNOWLEDGEMENT_REQUIRED=true; the server will not accept a paid action without the current acknowledgement.'); + } + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(String(env.LEGAL_POLICY_VERSION || ''))) { + errors.push('LEGAL_POLICY_VERSION must be an explicit 1-64 character version identifier for production payments.'); + } + const effectiveDate = String(env.TERMS_EFFECTIVE_DATE || ''); + const parsedEffectiveDate = /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate) + ? new Date(`${effectiveDate}T00:00:00Z`) + : null; + if (!parsedEffectiveDate || Number.isNaN(parsedEffectiveDate.getTime()) + || parsedEffectiveDate.toISOString().slice(0, 10) !== effectiveDate) { + errors.push('TERMS_EFFECTIVE_DATE must be an explicit valid YYYY-MM-DD date for production payments.'); + } + const configuredMinimumAge = Number(env.MINIMUM_AGE); + if (!Number.isInteger(configuredMinimumAge) || configuredMinimumAge < 18 || configuredMinimumAge > 120) { + errors.push('MINIMUM_AGE must be an explicit integer from 18 through 120 for production payments.'); + } + const operatorName = String(env.OPERATOR_NAME || '').trim(); + if (operatorName.length < 2 || operatorName.length > 120 || PLACEHOLDER_SECRET.test(operatorName)) { + errors.push('OPERATOR_NAME must identify the responsible operator without placeholder text for production payments.'); + } try { - const parsed = JSON.parse(process.env.CREDITS_PACKAGES); - if (!Array.isArray(parsed)) { + const contact = new URL(String(env.OPERATOR_CONTACT_URL || '')); + if (!['https:', 'mailto:'].includes(contact.protocol)) { + errors.push('OPERATOR_CONTACT_URL must use https:// or mailto: for production payments.'); + } + if (PLACEHOLDER_SECRET.test(String(env.OPERATOR_CONTACT_URL || ''))) { + errors.push('OPERATOR_CONTACT_URL must not contain placeholder text for production payments.'); + } + } catch (_) { + errors.push('OPERATOR_CONTACT_URL must be an explicit valid https:// or mailto: URL for production payments.'); + } + } + if (production && walletRequired) { + if (!env.WALLET_RPC_USER || !env.WALLET_RPC_PASSWORD) { + errors.push('Production financial workers require WALLET_RPC_USER and WALLET_RPC_PASSWORD for wallet-rpc Digest authentication.'); + } else if (String(env.WALLET_RPC_PASSWORD).length < 24 + || PLACEHOLDER_SECRET.test(String(env.WALLET_RPC_PASSWORD))) { + errors.push('WALLET_RPC_PASSWORD must be a strong non-placeholder secret of at least 24 characters in production.'); + } + if (env.PRIMARY_WALLET_ENDPOINT) { + try { + const walletEndpoint = new URL(env.PRIMARY_WALLET_ENDPOINT); + if (walletEndpoint.protocol === 'http:' && !isLoopbackHost(walletEndpoint.hostname)) { + errors.push('Production wallet-rpc over plaintext HTTP must bind to a loopback address.'); + } + } catch (_) { + // Endpoint syntax is reported by _endpointIssue above. + } + } + } + if (env.HOSTED_BY) { + try { + const hostedBy = new URL(env.HOSTED_BY); + if (!['http:', 'https:'].includes(hostedBy.protocol)) { + (production ? errors : warnings).push('HOSTED_BY must use http:// or https://.'); + } + if (production && hostedBy.protocol !== 'https:') { + errors.push('HOSTED_BY must use https:// in production.'); + } + } catch (_) { + (production ? errors : warnings).push('HOSTED_BY must be a valid public URL.'); + } + } + const daemonIssue = this._endpointIssue('PRIMARY_RPC_ENDPOINT', env.PRIMARY_RPC_ENDPOINT); + if (production && daemonIssue) { + errors.push(`Production block timing requires a daemon RPC: ${daemonIssue}`); + } else if (paymentsEnabled && daemonIssue) { + warnings.push(`Payments are enabled but ${daemonIssue}`); + } + this._pushEndpointTransportWarning(warnings, 'PRIMARY_WALLET_ENDPOINT', env.PRIMARY_WALLET_ENDPOINT); + this._pushEndpointTransportWarning(warnings, 'PRIMARY_RPC_ENDPOINT', env.PRIMARY_RPC_ENDPOINT); + + if (config?.modes?.direct?.enabled && config.modes.direct.price <= 0n) { + errors.push('Direct payment mode is enabled but its price is not positive.'); + } + if (config?.modes?.credits?.enabled && !config.modes.credits.packages?.length) { + errors.push('Credits mode is enabled but no credit packages are defined.'); + } + + if (env.CREDITS_PACKAGES) { + try { + const packages = JSON.parse(env.CREDITS_PACKAGES); + if (!Array.isArray(packages)) { errors.push('CREDITS_PACKAGES must be a JSON array.'); - } else if (parsed.length === 0) { - warnings.push('CREDITS_PACKAGES is an empty array. Using defaults.'); + } else if (packages.length === 0) { + errors.push('CREDITS_PACKAGES must not be empty when explicitly configured.'); } else { - // Validate each package has required fields - parsed.forEach((pkg, i) => { - if (!pkg.id) warnings.push(`CREDITS_PACKAGES[${i}] missing "id" field.`); - if (!pkg.credits && pkg.credits !== 0) warnings.push(`CREDITS_PACKAGES[${i}] missing "credits" field.`); - if (!pkg.price) warnings.push(`CREDITS_PACKAGES[${i}] missing "price" field.`); + packages.forEach((pkg, index) => { + if (!pkg || typeof pkg !== 'object') { + errors.push(`CREDITS_PACKAGES[${index}] must be an object.`); + return; + } + if (!pkg.id) errors.push(`CREDITS_PACKAGES[${index}] is missing "id".`); + if (!Number.isInteger(Number(pkg.credits)) || Number(pkg.credits) <= 0) { + errors.push(`CREDITS_PACKAGES[${index}].credits must be a positive integer.`); + } + try { + if (BigInt(pkg.price) <= 0n) throw new Error('not positive'); + } catch (_) { + errors.push(`CREDITS_PACKAGES[${index}].price must be a positive atomic-unit integer.`); + } }); } - } catch (e) { - errors.push(`CREDITS_PACKAGES is not valid JSON: ${e.message}`); + } catch (error) { + errors.push(`CREDITS_PACKAGES is not valid JSON: ${error.message}`); } - } else if (config.modes.credits.enabled) { - this.logger.info?.('ℹ️ Credits mode enabled with default packages. Set CREDITS_PACKAGES for production.'); + } else if (creditsEnabled) { + warnings.push('Credits mode is using built-in package defaults; set CREDITS_PACKAGES explicitly for production pricing.'); } - // Warn if direct payouts enabled but no wallet RPC - if (config.payouts.rules.direct.enabled && !process.env.PRIMARY_WALLET_ENDPOINT) { - warnings.push('DIRECT_PAYOUTS_ENABLED=true but no PRIMARY_WALLET_ENDPOINT. Payouts will fail.'); + if (!payoutMasterEnabled + && (Boolean(config?.payouts?.rules?.direct?.enabled) || Boolean(config?.payouts?.rules?.credits?.enabled))) { + warnings.push('Payout rules are enabled but PAYOUTS_ENABLED is off; the master switch suppresses all payout creation and dispatch.'); } - // Warn if credits payouts enabled but no wallet RPC - if (config.payouts.rules.credits.enabled && !process.env.PRIMARY_WALLET_ENDPOINT) { - warnings.push('CREDITS_PAYOUTS_ENABLED=true but no PRIMARY_WALLET_ENDPOINT. Payouts will fail.'); + if (production) { + for (const key of BOOLEAN_ENV_KEYS) { + if (Object.prototype.hasOwnProperty.call(env, key) + && !BOOLEAN_VALUES.has(String(env[key]).trim().toLowerCase())) { + errors.push(`${key} must be an explicit boolean (true or false).`); + } + } + + for (const key of ATOMIC_ENV_KEYS) { + if (!Object.prototype.hasOwnProperty.call(env, key) || env[key] === '') continue; + const raw = canonicalInteger(env[key]); + if (!/^\d+$/.test(raw)) { + errors.push(`${key} must be a non-negative integer in atomic units.`); + continue; + } + try { + if (POSITIVE_ATOMIC_ENV_KEYS.has(key) && BigInt(raw) <= 0n) { + errors.push(`${key} must be greater than zero.`); + } + } catch (_) { + errors.push(`${key} must be a valid atomic-unit integer.`); + } + } + + if (env.GAME_MODE && !['FREE', 'PAID_SINGLE', 'PAID_CREDITS'].includes(String(env.GAME_MODE).trim().toUpperCase())) { + errors.push('GAME_MODE must be FREE, PAID_SINGLE, or PAID_CREDITS.'); + } + if (env.PAYMENT_MODES) { + const configuredModes = String(env.PAYMENT_MODES).split(',') + .map(mode => mode.trim().toLowerCase()).filter(Boolean); + const invalidModes = configuredModes.filter(mode => !['direct', 'credits'].includes(mode)); + if (configuredModes.length === 0 || invalidModes.length > 0) { + errors.push('PAYMENT_MODES must contain only direct and/or credits.'); + } + } + + for (const key of [ + 'DIRECT_PAYOUT_ESCAPE', 'DIRECT_PAYOUT_TREASURE', + 'CREDITS_PAYOUT_ESCAPE', 'CREDITS_PAYOUT_TREASURE' + ]) { + if (!Object.prototype.hasOwnProperty.call(env, key) || env[key] === '') continue; + const multiplier = Number(env[key]); + if (!Number.isFinite(multiplier) || multiplier <= 0) { + errors.push(`${key} must be a finite number greater than zero.`); + } + } + if (Object.prototype.hasOwnProperty.call(env, 'MATCH_HOUSE_FEE_PERCENT')) { + if (parseHouseFeeBasisPoints(env.MATCH_HOUSE_FEE_PERCENT) === null) { + errors.push('MATCH_HOUSE_FEE_PERCENT must be at least 0 and less than 100.'); + } + } + if (isTrue(env.TRUST_PROXY)) { + const proxyHops = Number(env.TRUST_PROXY_HOPS || 1); + if (!Number.isInteger(proxyHops) || proxyHops < 1 || proxyHops > 8) { + errors.push('TRUST_PROXY_HOPS must be an integer from 1 through 8.'); + } + } + + if (matchModeEnabled) { + for (const key of [ + 'MATCH_PAID_ENTROPY_DELAY_BLOCKS', + 'MATCH_PAID_ENTROPY_CONFIRMATIONS' + ]) { + const raw = String(env[key] ?? '').trim(); + const value = /^\d+$/.test(raw) ? Number(raw) : NaN; + if (!Number.isSafeInteger(value) || value < 2 || value > 100) { + errors.push(`${key} must be an explicit safe integer from 2 through 100 when production match mode is enabled.`); + } + } + } + + if (matchCryptoEnabled && !Object.prototype.hasOwnProperty.call(env, 'MATCH_PAYOUTS_ENABLED')) { + errors.push('Production crypto matches require an explicit MATCH_PAYOUTS_ENABLED=true|false setting.'); + } + if (isTrue(env.MATCH_PAYOUTS_ENABLED) && !matchCryptoEnabled) { + errors.push('MATCH_PAYOUTS_ENABLED=true requires MATCH_ENABLED=true and MATCH_CRYPTO_RACE_ENABLED=true.'); + } + if (matchPayoutsEnabled) { + if (!directEnabled && !creditsEnabled) { + errors.push('Crypto match payouts require at least one enabled paid product mode for race-entry tickets.'); + } + const payoutCap = parsePositiveAtomic(env.MATCH_PAYOUT_MAX); + if (payoutCap === null) { + errors.push('MATCH_PAYOUT_MAX must be an explicit positive PostgreSQL BIGINT atomic-unit cap when crypto match payouts are enabled.'); + } + + const ruleset = resolveMatchRuleset(env.MATCH_RULESET_ID || 'race'); + if (!cryptoRulesetSupported(ruleset)) { + errors.push(`MATCH_RULESET_ID=${ruleset.id} does not have supported single-winner crypto payout semantics.`); + } + const requestedMaxRaw = String(env.MATCH_MAX_PLAYERS ?? '').trim(); + const requestedMax = /^\d+$/.test(requestedMaxRaw) + ? Number(requestedMaxRaw) + : NaN; + const contract = playerContract(ruleset, env.MATCH_MAX_PLAYERS || ruleset.players.max); + if (!Number.isInteger(requestedMax) + || requestedMax < contract.minPlayers + || requestedMax > ruleset.players.max + || requestedMax > 32) { + errors.push(`MATCH_MAX_PLAYERS must be an integer from ${contract.minPlayers} through ${Math.min(32, ruleset.players.max)} for ruleset ${ruleset.id}.`); + } + + const entryFee = parsePositiveAtomic(env.MATCH_ENTRY_FEE_ATOMIC); + const feeBasisPoints = parseHouseFeeBasisPoints(env.MATCH_HOUSE_FEE_PERCENT); + if (entryFee === null) { + errors.push('MATCH_ENTRY_FEE_ATOMIC must be an explicit positive PostgreSQL BIGINT atomic-unit value when crypto match payouts are enabled.'); + } + if (feeBasisPoints === null) { + errors.push('MATCH_HOUSE_FEE_PERCENT must be explicitly set from 0 (inclusive) to 100 (exclusive) when crypto match payouts are enabled.'); + } + + const catalog = [ + ...(config?.modes?.credits?.packages || []), + ...(config?.products?.cosmetic || []) + ]; + const ticketProducts = catalog.map(raceTicketGrant).filter(Boolean); + if (ticketProducts.length === 0) { + errors.push('Crypto match payouts require at least one configured race-entry product with explicit per-ticket atomic backing.'); + } + for (const ticket of ticketProducts) { + const rawEntries = String(ticket.entries ?? '').trim(); + let ticketEntries = null; + if (/^\d+$/.test(rawEntries)) { + try { + const parsed = BigInt(rawEntries); + if (parsed > 0n && parsed <= 2147483647n) ticketEntries = parsed; + } catch (_) { /* reported below */ } + } + if (ticketEntries === null) { + errors.push(`Race-entry product ${ticket.productId} must grant a positive integer number of tickets.`); + continue; + } + const ticketValue = parsePositiveAtomic(ticket.valueAtomic); + if (ticketValue === null) { + errors.push(`Race-entry product ${ticket.productId} must set grants.race_entry_value_atomic to a positive PostgreSQL BIGINT atomic-unit value.`); + continue; + } + if (entryFee !== null && ticketValue !== entryFee) { + errors.push(`Race-entry product ${ticket.productId} backing must equal MATCH_ENTRY_FEE_ATOMIC (${entryFee} atomic units per ticket).`); + } + const price = parsePositiveAtomic(ticket.price); + const requiredBacking = ticketValue * ticketEntries; + if (price === null || price < requiredBacking) { + errors.push(`Race-entry product ${ticket.productId} price must fund at least ${requiredBacking} atomic units of ticket backing.`); + } + } + + if (entryFee !== null && payoutCap !== null && feeBasisPoints !== null + && Number.isInteger(requestedMax)) { + const pot = entryFee * BigInt(requestedMax); + const feeBp = BigInt(feeBasisPoints); + const maximumLiability = pot - ((pot * feeBp) / 10000n); + if (pot > PG_BIGINT_MAX) { + errors.push(`MATCH_ENTRY_FEE_ATOMIC * MATCH_MAX_PLAYERS exceeds PostgreSQL BIGINT (${PG_BIGINT_MAX}).`); + } + if (maximumLiability > payoutCap) { + errors.push(`MATCH_PAYOUT_MAX is below the configured ${requestedMax}-player winner liability (${maximumLiability} atomic units).`); + } + } + } + + if (payoutMasterEnabled) { + const balanceCritical = parsePositiveAtomic(env.BALANCE_CRITICAL); + const balanceWarn = parsePositiveAtomic(env.BALANCE_WARN); + const acceptedLiabilities = []; + + if (directPayoutsEnabled || creditsPayoutsEnabled) { + const soloPayoutCap = parsePositiveAtomic(env.PAYOUT_MAX_PER_GAME); + if (soloPayoutCap === null) { + errors.push('PAYOUT_MAX_PER_GAME must be an explicit positive PostgreSQL BIGINT atomic-unit cap when solo payouts are enabled.'); + } else { + acceptedLiabilities.push({ label: 'PAYOUT_MAX_PER_GAME', amount: soloPayoutCap }); + } + } + if (matchPayoutsEnabled) { + const matchPayoutCap = parsePositiveAtomic(env.MATCH_PAYOUT_MAX); + if (matchPayoutCap !== null) { + acceptedLiabilities.push({ label: 'MATCH_PAYOUT_MAX', amount: matchPayoutCap }); + } + } + + if (balanceCritical === null) { + errors.push('BALANCE_CRITICAL must be an explicit positive PostgreSQL BIGINT atomic-unit reserve when payouts are enabled.'); + } else { + const uncovered = acceptedLiabilities + .filter(liability => balanceCritical < liability.amount) + .sort((left, right) => left.amount < right.amount ? 1 : -1); + if (uncovered.length > 0) { + const largest = uncovered[0]; + errors.push(`BALANCE_CRITICAL must be at least ${largest.label} (${largest.amount} atomic units) so one accepted payout is always covered.`); + } + } + + if (balanceWarn === null) { + errors.push('BALANCE_WARN must be an explicit positive PostgreSQL BIGINT atomic-unit warning threshold when payouts are enabled.'); + } else if (balanceCritical !== null && balanceWarn < balanceCritical) { + errors.push(`BALANCE_WARN must be greater than or equal to BALANCE_CRITICAL (${balanceCritical} atomic units).`); + } + } + + // Never let production advertise a multiplier outcome which settlement would cap or + // suppress. Browser disclosures and immutable game terms must describe the same amount. + const validateSoloOutcomes = (label, enabled, base, rule) => { + if (!enabled) return; + let baseAtomic; + let minAtomic; + let maxAtomic; + try { + baseAtomic = money.toBig(base); + minAtomic = money.toBig(rule?.minPayout); + maxAtomic = money.toBig(rule?.maxPayout); + } catch (_) { + errors.push(`${label} payout base/min/max must be exact atomic-unit integers.`); + return; + } + for (const [outcome, multiplier] of [ + ['escape', rule?.multipliers?.escape], + ['escapeWithTreasure', rule?.multipliers?.escapeWithTreasure] + ]) { + let amount; + try { amount = money.mulByDecimal(baseAtomic, multiplier); } + catch (_) { + errors.push(`${label} ${outcome} multiplier must be a non-negative decimal.`); + continue; + } + if (amount < minAtomic || amount <= 0n) { + errors.push(`${label} ${outcome} payout (${amount} atomic units) is below its configured minimum (${minAtomic}).`); + } + if (amount > maxAtomic) { + errors.push(`${label} ${outcome} payout (${amount} atomic units) exceeds its configured maximum (${maxAtomic}).`); + } + } + }; + validateSoloOutcomes( + 'Direct', directPayoutsEnabled, + config?.modes?.direct?.price, + config?.payouts?.rules?.direct + ); + validateSoloOutcomes( + 'Credits', creditsPayoutsEnabled, + config?.payouts?.rules?.credits?.baseValue, + config?.payouts?.rules?.credits + ); + + if (!Object.prototype.hasOwnProperty.call(env, 'PAYMENTS_ENABLED') && !env.GAME_MODE) { + errors.push('Production requires an explicit PAYMENTS_ENABLED=true|false setting.'); + } + if (!Object.prototype.hasOwnProperty.call(env, 'PAYOUTS_ENABLED')) { + errors.push('Production requires an explicit PAYOUTS_ENABLED=true|false setting.'); + } + if (directEnabled && !Object.prototype.hasOwnProperty.call(env, 'DIRECT_PAYOUTS_ENABLED')) { + errors.push('Production direct mode requires an explicit DIRECT_PAYOUTS_ENABLED=true|false setting.'); + } + if (creditsEnabled + && !Object.prototype.hasOwnProperty.call(env, 'CREDITS_PAYOUTS_ENABLED') + && !Object.prototype.hasOwnProperty.call(env, 'CREDITS_PAYOUT_ENABLED')) { + errors.push('Production credits mode requires an explicit CREDITS_PAYOUTS_ENABLED=true|false setting.'); + } + + const adminKey = String(env.ADMIN_API_KEY || ''); + if (adminKey.length < 32 || PLACEHOLDER_SECRET.test(adminKey) || new Set(adminKey).size < 8) { + errors.push('ADMIN_API_KEY must be a strong non-placeholder secret of at least 32 characters in production.'); + } + if (!env.DB_PASSWORD || PLACEHOLDER_SECRET.test(String(env.DB_PASSWORD))) { + errors.push('DB_PASSWORD must be set to a non-placeholder value in production.'); + } + + const simulated = isTrue(env.SIMULATED_BLOCKS) + || isTrue(env.FORCE_SIMULATED_BLOCKS) + || String(env.BLOCK_SOURCE || '').toLowerCase() === 'simulated'; + if (simulated) { + errors.push('Simulated blocks are forbidden when NODE_ENV=production.'); + } + if (isTrue(env.DEBUG_HOTKEYS)) { + errors.push('DEBUG_HOTKEYS must not be enabled in production.'); + } + + if (payoutMasterEnabled && network === 'mainnet' && !isTrue(env.ALLOW_MAINNET_PAYOUTS)) { + errors.push('Mainnet payouts require the explicit safety acknowledgement ALLOW_MAINNET_PAYOUTS=true.'); + } + if (network !== 'mainnet' && isTrue(env.SMIRK_ENABLED)) { + warnings.push('SMIRK_ENABLED is ignored on test networks; set it false to keep operator intent clear.'); + } + if (isTrue(env.TRUST_PROXY) === false) { + warnings.push('TRUST_PROXY is not enabled; this is correct only when the Node process is directly exposed.'); + } } - // Info about FREE mode - if (!config.paymentsEnabled) { - this.logger.info?.('ℹ️ Payments disabled. Server running in FREE mode.'); + if (!paymentsEnabled) { + this.logger.info?.('ℹ️ Payments disabled. Server will run in free mode.'); } + warnings.forEach(message => this.logger.warn?.(`⚠️ ${message}`)); + errors.forEach(message => this.logger.error?.(`❌ ${message}`)); - // Emit warnings and errors - if (warnings.length) { - warnings.forEach(msg => this.logger.warn?.(`⚠️ ${msg}`)); + return { + warnings, + errors, + production, + money: { + paymentsEnabled, + payoutsEnabled: anyPayoutsEnabled, + payoutWorkerEnabled: payoutMasterEnabled, + walletRequired, + directPayoutsEnabled, + creditsPayoutsEnabled, + matchPayoutsEnabled + } + }; + } + + assertValid(config) { + const result = this.validate(config); + if (result.errors.length > 0) { + const error = new Error(`Invalid production configuration (${result.errors.length} error${result.errors.length === 1 ? '' : 's'}).`); + error.name = 'EnvironmentValidationError'; + error.code = 'INVALID_ENVIRONMENT'; + error.validation = result; + throw error; } - - if (errors.length) { - errors.forEach(msg => this.logger.error?.(`❌ ${msg}`)); - } - - return { warnings, errors }; + return result; } } diff --git a/src/config/paymentConfig.js b/src/config/paymentConfig.js index 83051a9..fc81705 100644 --- a/src/config/paymentConfig.js +++ b/src/config/paymentConfig.js @@ -48,8 +48,7 @@ const DEFAULT_CONFIG = Object.freeze({ enabled: true, multipliers: { escape: 2.0, - escapeWithTreasure: 3.0, - perfectRun: 5.0 + escapeWithTreasure: 3.0 }, minPayout: 1000000000n, maxPayout: 10000000000000n @@ -58,8 +57,7 @@ const DEFAULT_CONFIG = Object.freeze({ enabled: false, multipliers: { escape: 2.0, - escapeWithTreasure: 3.0, - perfectRun: 3.0 + escapeWithTreasure: 3.0 }, baseValue: 100000000000n, minPayout: 1000000000n, @@ -163,10 +161,12 @@ function parseAtomicValue(value, fallback) { } if (typeof value === 'number') { - if (!Number.isFinite(value)) { + // A Number above MAX_SAFE_INTEGER has already lost atomic units before it reaches + // this parser. Reject it instead of freezing a rounded value into the config. + if (!Number.isSafeInteger(value)) { return fallback; } - return BigInt(Math.round(value)); + return BigInt(value); } const normalized = String(value).trim().toLowerCase().replace(/_/g, ''); @@ -174,7 +174,9 @@ function parseAtomicValue(value, fallback) { return fallback; } - if (normalized.startsWith('0x')) { + // Parse integer text with BigInt before touching Number. Atomic-unit configuration often + // exceeds Number.MAX_SAFE_INTEGER; routing it through Number first silently rounds money. + if (/^[+-]?\d+$/.test(normalized) || /^0x[0-9a-f]+$/i.test(normalized)) { try { return BigInt(normalized); } catch (error) { @@ -182,16 +184,9 @@ function parseAtomicValue(value, fallback) { } } - const numeric = Number(normalized); - if (Number.isFinite(numeric)) { - return BigInt(Math.round(numeric)); - } - - try { - return BigInt(normalized); - } catch (error) { - return fallback; - } + // Atomic-unit configuration is integral by definition. Decimal/exponential text must + // not be rounded through Number, especially once values exceed 2^53. + return fallback; } function parseFloatValue(value, fallback) { @@ -255,7 +250,12 @@ class PaymentConfigManager { loadConfig() { const config = cloneConfig(DEFAULT_CONFIG); - config.paymentsEnabled = parseBoolean(process.env.PAYMENTS_ENABLED, config.paymentsEnabled); + // PAYMENTS_ENABLED is the authoritative master switch. Keep its explicit value + // separate while parsing the subordinate mode selectors below; otherwise + // PAYMENT_MODES/DIRECT_PAYMENT_ENABLED/CREDITS_ENABLED can accidentally turn the + // payment system back on after an operator has set PAYMENTS_ENABLED=false. + const paymentsMaster = parseBoolean(process.env.PAYMENTS_ENABLED, null); + config.paymentsEnabled = paymentsMaster === null ? config.paymentsEnabled : paymentsMaster; const gameModeOverride = process.env.GAME_MODE ? process.env.GAME_MODE.toUpperCase() : null; if (gameModeOverride === 'FREE') { @@ -299,6 +299,12 @@ class PaymentConfigManager { if (creditsEnabled !== undefined) { config.modes.credits.enabled = creditsEnabled; } + + if (paymentsMaster === false) { + config.paymentsEnabled = false; + config.modes.direct.enabled = false; + config.modes.credits.enabled = false; + } config.modes.credits.creditsPerGame = parseInteger(process.env.CREDITS_PER_GAME, config.modes.credits.creditsPerGame); config.modes.credits.requiresAddress = parseBoolean(process.env.CREDITS_REQUIRES_ADDRESS, config.modes.credits.requiresAddress); config.modes.credits.allowMixedMode = parseBoolean(process.env.ALLOW_MIXED_MODE, config.modes.credits.allowMixedMode); @@ -340,8 +346,21 @@ class PaymentConfigManager { config.payouts.rules.credits.multipliers.escape = parseFloatValue(process.env.CREDITS_PAYOUT_ESCAPE, config.payouts.rules.credits.multipliers.escape); config.payouts.rules.credits.multipliers.escapeWithTreasure = parseFloatValue(process.env.CREDITS_PAYOUT_TREASURE, config.payouts.rules.credits.multipliers.escapeWithTreasure); config.payouts.rules.credits.baseValue = parseAtomicValue(process.env.CREDITS_PAYOUT_BASE, config.payouts.rules.credits.baseValue); + config.payouts.rules.credits.minPayout = parseAtomicValue(process.env.CREDITS_PAYOUT_MIN ?? process.env.PAYOUT_MIN_AMOUNT, config.payouts.rules.credits.minPayout); + // PAYOUT_MAX_PER_GAME is an outer safety bound for every solo payout. A dedicated + // credits override is supported, but absent one the global cap must not protect only + // direct entries while leaving credit wins at a larger default. + const globalPayoutMax = parseAtomicValue(process.env.PAYOUT_MAX_PER_GAME, null); + const creditsPayoutMax = parseAtomicValue(process.env.CREDITS_PAYOUT_MAX, config.payouts.rules.credits.maxPayout); + config.payouts.rules.credits.maxPayout = globalPayoutMax !== null && globalPayoutMax < creditsPayoutMax + ? globalPayoutMax + : creditsPayoutMax; config.payouts.processing.batchInterval = parseInteger(process.env.PAYOUT_BATCH_INTERVAL, config.payouts.processing.batchInterval); + config.payouts.processing.confirmations = parseInteger( + process.env.PAYMENT_CONFIRMATIONS, + config.payouts.processing.confirmations + ); config.payouts.processing.maxRetries = parseInteger(process.env.PAYOUT_MAX_RETRIES, config.payouts.processing.maxRetries); config.limits.maxGamesPerHour = parseInteger(process.env.MAX_GAMES_PER_HOUR, config.limits.maxGamesPerHour); @@ -400,6 +419,11 @@ class PaymentConfigManager { this.logger.warn?.('⚠️ Payments enabled but no payment modes active. Falling back to FREE mode.'); } + if (!Number.isSafeInteger(this.config.payouts.processing.confirmations) + || this.config.payouts.processing.confirmations < 1) { + throw new Error('Payment confirmations must be an integer of at least 1'); + } + this.validatePrices(); return true; } diff --git a/src/config/runtimePolicy.js b/src/config/runtimePolicy.js new file mode 100644 index 0000000..b94db3e --- /dev/null +++ b/src/config/runtimePolicy.js @@ -0,0 +1,35 @@ +/** + * Pure runtime safety policies shared by startup, public status, and background workers. + * Keeping these predicates side-effect free makes the money kill switches regression-testable. + */ + +function isPayoutProcessingEnabled(config, gameModeManager, options = {}) { + if (!config?.payouts?.enabled || gameModeManager?.payoutsEnabled === false) return false; + + const direct = Boolean(config.modes?.direct?.enabled) + && Boolean(config.payouts?.rules?.direct?.enabled) + && Boolean(gameModeManager?.directPayoutEnabled); + const credits = Boolean(config.modes?.credits?.enabled) + && Boolean(config.payouts?.rules?.credits?.enabled) + && Boolean(gameModeManager?.creditsPayoutEnabled); + + // Admission switches are intentionally not consulted for durable liabilities. Once any + // paid game has created a payout row, turning off a product or match mode must stop new + // liabilities without stranding the existing one. Only settlement-worker callers opt into + // this behavior, and PAYOUTS_ENABLED plus the manager emergency switch remain authoritative. + const acceptedLiabilities = options.settleAcceptedLiabilities === true + // Compatibility for callers/tests written before the dispatcher was audited as generic. + || options.settleAcceptedMatchLiabilities === true; + + return direct || credits || acceptedLiabilities; +} + +function isWalletRequired(config, gameModeManager, options = {}) { + // Intake needs subaddress creation/receipt checks. Settlement still needs transfer RPC when + // operators pause every new invoice, so payout-only recovery must initialize the same + // authenticated wallet boundary instead of falling through to a default endpoint. + return Boolean(config?.paymentsEnabled) + || isPayoutProcessingEnabled(config, gameModeManager, options); +} + +module.exports = { isPayoutProcessingEnabled, isWalletRequired }; diff --git a/src/db/databaseManager.js b/src/db/databaseManager.js index 953035a..4d2c915 100644 --- a/src/db/databaseManager.js +++ b/src/db/databaseManager.js @@ -36,6 +36,8 @@ class DatabaseManager { constructor() { this.pool = null; this.connected = false; + this.lastSuccessfulQueryAt = 0; + this.lastFailedQueryAt = 0; this.queryValidator = new QueryValidator(); } @@ -78,6 +80,7 @@ class DatabaseManager { client.release(); this.connected = true; + this.lastSuccessfulQueryAt = Date.now(); console.log('✅ Database connected successfully'); return true; @@ -155,6 +158,7 @@ class DatabaseManager { try { const start = Date.now(); const result = await this.pool.query(text, params); + this.lastSuccessfulQueryAt = Date.now(); const duration = Date.now() - start; if (process.env.NODE_ENV === 'development') { @@ -163,6 +167,7 @@ class DatabaseManager { return result; } catch (error) { + this.lastFailedQueryAt = Date.now(); console.error('❌ Database query error:', error.message); console.error('Query:', text); throw error; @@ -244,6 +249,12 @@ class DatabaseManager { return this.connected; } + isHealthy(maxStaleMs = Number(process.env.DB_HEALTH_MAX_STALE_MS) || 30000) { + return this.connected + && this.lastSuccessfulQueryAt > 0 + && (Date.now() - this.lastSuccessfulQueryAt) <= maxStaleMs; + } + /** * Health check */ diff --git a/src/debug/debugManager.js b/src/debug/debugManager.js index 1d78189..a1bca5b 100644 --- a/src/debug/debugManager.js +++ b/src/debug/debugManager.js @@ -4,7 +4,6 @@ * Keeps debug/development code separate from production logic */ -const rpc = require('../rpc/rpccalls.js'); const packageConfig = require('../package.json'); class DebugManager { @@ -40,6 +39,7 @@ class DebugManager { this.debugInterval = null; this.statusInterval = null; this.lastProductionBlockHeight = 0; + this.lastSuccessfulRpcAt = 0; // Log configuration on startup this.logConfig(); @@ -119,13 +119,17 @@ class DebugManager { // Initialize RPC service if available const RpcService = require('../rpc/rpcService'); this.rpcService = new RpcService(); - + // Real blockchain monitoring with new RPC service const poll = async () => { try { const currentHeight = await this.rpcService.getBlockHeight(); + + if (this.rpcService.healthy) { + this.lastSuccessfulRpcAt = Date.now(); + } - if (!currentHeight) { + if (!currentHeight || !this.rpcService.healthy) { if (this.CONSOLE_LOGGING) { console.log("❌ Failed to get block count from daemon"); } @@ -151,18 +155,6 @@ class DebugManager { console.error("❌ RPC Error:", error.message); } - // Fallback to legacy RPC if new service fails - rpc.daemonCall("get_block_count", "", (result) => { - if (result && result.result && result.result.count) { - const currentHeight = result.result.count; - this.broadcastManager.broadcastBlockHeight(currentHeight); - - if (currentHeight > this.lastProductionBlockHeight) { - this.onNewBlock(currentHeight); - this.lastProductionBlockHeight = currentHeight; - } - } - }); } }; // immediate first poll so UI shows real height quickly @@ -181,6 +173,24 @@ class DebugManager { return this.lastProductionBlockHeight; } + /** Whether the production daemon has answered recently enough for readiness. */ + isChainHealthy(maxStaleMs = Number(process.env.RPC_HEALTH_MAX_STALE_MS) || 15000) { + if (this.SIMULATED_BLOCKS) return true; + return this.lastSuccessfulRpcAt > 0 && (Date.now() - this.lastSuccessfulRpcAt) <= maxStaleMs; + } + + /** Sanitized chain identity for readiness; never exposes RPC URLs or credentials. */ + getChainIdentity() { + return this.rpcService?.getIdentityStatus?.() || { + required: this.IS_PRODUCTION, + verified: false, + expected: null, + actual: null, + reason: 'not_checked', + checkedAt: 0 + }; + } + /** * Set debug block height (debug mode only) * @param {number} height - New block height @@ -263,6 +273,8 @@ class DebugManager { currentBlockHeight: this.getCurrentBlockHeight(), isRunning: !!this.debugInterval, lastProductionBlock: this.lastProductionBlockHeight, + chainHealthy: this.isChainHealthy(), + lastSuccessfulRpcAt: this.lastSuccessfulRpcAt, debugBlock: this.debugBlockHeight }; } diff --git a/src/game/dungeon.js b/src/game/dungeon.js index dd69ecf..296c448 100644 --- a/src/game/dungeon.js +++ b/src/game/dungeon.js @@ -42,6 +42,9 @@ const DUNGEON_CONFIGS = { * Dungeon generation utilities */ class DungeonGenerator { + static get GENERATOR_VERSION() { return 'dungeon-generator-v1'; } + static get FINGERPRINT_VERSION() { return 1; } + // Get the configuration object with difficulty settings applied static getConfig(cryptoType = null) { const difficulty = getDifficultyConfig(cryptoType || process.env.CRYPTO_TYPE || 'WOW'); @@ -99,7 +102,10 @@ class DungeonGenerator { * @param {object} dungeon - A dungeon object from generate()/regenerateFromSeed() * @returns {string} SHA-256 hex of the layout-defining fields */ - static layoutFingerprint(dungeon) { + static layoutFingerprint(dungeon, fingerprintVersion = this.FINGERPRINT_VERSION) { + if (Number(fingerprintVersion) !== 1) { + throw new Error(`Unsupported dungeon fingerprint version: ${fingerprintVersion}`); + } const payload = JSON.stringify({ map: dungeon.map, entrance: dungeon.entrance, diff --git a/src/game/fairnessOfferManager.js b/src/game/fairnessOfferManager.js new file mode 100644 index 0000000..89c258b --- /dev/null +++ b/src/game/fairnessOfferManager.js @@ -0,0 +1,133 @@ +/** + * One-time, socket-bound server-seed commitments for solo games. + * + * The server publishes hash(serverSeed) before a client chooses its clientSeed. A start request + * consumes exactly one published offer; the secret serverSeed is then handed to Game internally + * and revealed only after the run. Keeping issuance separate from consumption prevents the server + * from choosing/grinding its seed after seeing the player's contribution. + */ + +const crypto = require('crypto'); +const { generateSeed, hashSeed, normalizeClientSeed } = require('./provablyFair'); + +const DEFAULT_TTL_MS = 15 * 60 * 1000; + +class FairnessOfferManager { + constructor({ ttlMs = DEFAULT_TTL_MS, now = () => Date.now(), seedFactory = generateSeed, idFactory = () => crypto.randomUUID() } = {}) { + this.ttlMs = Math.max(1000, Number(ttlMs) || DEFAULT_TTL_MS); + this.now = now; + this.seedFactory = seedFactory; + this.idFactory = idFactory; + this.offers = new Map(); // offerId -> private offer + this.currentBySocket = new Map(); // socketId -> offerId + this.consumed = new Map(); // offerId -> expiry (explicit replay detection) + } + + _prune() { + const now = this.now(); + for (const [id, offer] of this.offers.entries()) { + if (offer.expiresAt <= now) { + this.offers.delete(id); + if (this.currentBySocket.get(offer.socketId) === id) this.currentBySocket.delete(offer.socketId); + } + } + for (const [id, expiresAt] of this.consumed.entries()) { + if (expiresAt <= now) this.consumed.delete(id); + } + } + + _public(offer) { + return { + offerId: offer.offerId, + commitment: offer.commitment, + issuedAt: offer.issuedAt, + expiresAt: offer.expiresAt, + algorithm: 'HMAC-SHA256(serverSeed, clientSeed)', + proofVersion: 2 + }; + } + + /** Return the existing unexpired offer for a socket, or publish a fresh one. */ + ensureOffer(socketId) { + this._prune(); + const owner = String(socketId || ''); + if (!owner) throw new Error('socketId is required for a fairness offer'); + const currentId = this.currentBySocket.get(owner); + const current = currentId ? this.offers.get(currentId) : null; + if (current) return this._public(current); + + const issuedAt = this.now(); + const serverSeed = this.seedFactory(); + const offer = { + offerId: this.idFactory(), + socketId: owner, + serverSeed, + commitment: hashSeed(serverSeed), + issuedAt, + expiresAt: issuedAt + this.ttlMs + }; + this.offers.set(offer.offerId, offer); + this.currentBySocket.set(owner, offer.offerId); + return this._public(offer); + } + + /** + * Consume a published offer once. Missing offerId is accepted only for legacy clients with + * an empty client seed; modern clients must echo the offer they actually received. + */ + consume(socketId, { offerId = null, clientSeed = '' } = {}) { + this._prune(); + const owner = String(socketId || ''); + const normalizedSeed = normalizeClientSeed(clientSeed); + if (clientSeed != null && String(clientSeed).trim() !== '' && normalizedSeed === null) { + return { success: false, code: 'INVALID_CLIENT_SEED', reason: 'Client seed must be 1-64 hexadecimal characters.' }; + } + + if (!offerId && normalizedSeed) { + return { success: false, code: 'OFFER_REQUIRED', reason: 'A published fairness offer is required for a client seed.' }; + } + + const resolvedId = offerId || this.currentBySocket.get(owner); + if (!resolvedId) { + return { success: false, code: 'OFFER_MISSING', reason: 'No active fairness offer. Request a new offer and retry.' }; + } + if (this.consumed.has(resolvedId)) { + return { success: false, code: 'OFFER_REPLAYED', reason: 'That fairness offer was already used.' }; + } + + const offer = this.offers.get(resolvedId); + if (!offer) { + return { success: false, code: 'OFFER_EXPIRED', reason: 'That fairness offer expired. Request a new offer and retry.' }; + } + if (offer.socketId !== owner) { + return { success: false, code: 'OFFER_OWNER_MISMATCH', reason: 'That fairness offer belongs to another connection.' }; + } + + // Synchronous delete/mark makes consumption atomic in Node's event loop. No await may + // occur between checking and claiming this offer. + this.offers.delete(resolvedId); + if (this.currentBySocket.get(owner) === resolvedId) this.currentBySocket.delete(owner); + this.consumed.set(resolvedId, Math.max(offer.expiresAt, this.now() + this.ttlMs)); + + return { + success: true, + proofInput: { + proofVersion: 2, + offerId: offer.offerId, + offerIssuedAt: offer.issuedAt, + serverSeed: offer.serverSeed, + commitment: offer.commitment, + clientSeed: normalizedSeed || '' + } + }; + } + + discardSocket(socketId) { + const owner = String(socketId || ''); + const id = this.currentBySocket.get(owner); + if (id) this.offers.delete(id); + this.currentBySocket.delete(owner); + } +} + +module.exports = FairnessOfferManager; diff --git a/src/game/fairnessVerifier.js b/src/game/fairnessVerifier.js new file mode 100644 index 0000000..77a3ff9 --- /dev/null +++ b/src/game/fairnessVerifier.js @@ -0,0 +1,118 @@ +const DungeonGenerator = require('./dungeon'); +const { levelSeed } = require('./provablyFair'); + +function parseJson(value) { + if (value == null || value === '') return null; + if (typeof value === 'object') return value; + try { return JSON.parse(value); } catch (_) { return null; } +} + +/** + * Regenerate and compare the versioned layout manifest attached to an already-verified + * seed relationship. Legacy rows with only `layout_fingerprint` remain level-one verifiable; + * new rows must contain one contiguous entry for every advertised depth. + */ +function attachDungeonVerification(result, { + effectiveSeed, + proofContext = null, + expectedFingerprint = null, + expectedFingerprints = null, + generatorVersion = null +} = {}) { + if (!result.valid || !effectiveSeed) return result; + try { + const context = parseJson(proofContext) || {}; + const options = context.generationOptions && typeof context.generationOptions === 'object' + ? context.generationOptions + : {}; + const recordedGenerator = generatorVersion || context.generatorVersion || DungeonGenerator.GENERATOR_VERSION; + if (recordedGenerator !== DungeonGenerator.GENERATOR_VERSION) { + throw Object.assign(new Error('unsupported_generator_version'), { code: 'UNSUPPORTED_GENERATOR' }); + } + + const parsedManifest = parseJson(expectedFingerprints); + const hasManifest = Array.isArray(parsedManifest) && parsedManifest.length > 0; + const maxDepth = Math.max(1, parseInt(context.maxDepth, 10) || 1); + let manifest; + if (hasManifest) { + manifest = parsedManifest.slice().sort((a, b) => Number(a.depth) - Number(b.depth)); + const depths = manifest.map(item => Number(item.depth)); + const expectedDepths = Array.from({ length: maxDepth }, (_, i) => i + 1); + if (manifest.length !== maxDepth || depths.some((depth, i) => depth !== expectedDepths[i])) { + throw Object.assign(new Error('incomplete_layout_manifest'), { code: 'INCOMPLETE_MANIFEST' }); + } + } else { + manifest = [{ + depth: 1, + fingerprintVersion: 1, + generatorVersion: recordedGenerator, + fingerprint: expectedFingerprint ? String(expectedFingerprint).trim() : null + }]; + } + + const levels = manifest.map((entry) => { + const depth = Number(entry.depth); + const entryGenerator = entry.generatorVersion || recordedGenerator; + if (entryGenerator !== DungeonGenerator.GENERATOR_VERSION) { + throw Object.assign(new Error('unsupported_generator_version'), { code: 'UNSUPPORTED_GENERATOR' }); + } + const fingerprintVersion = Number(entry.fingerprintVersion || context.fingerprintVersion || 1); + const dungeon = DungeonGenerator.regenerateFromSeed( + levelSeed(effectiveSeed, depth), + context.cryptoType || process.env.CRYPTO_TYPE || 'WOW', + options + ); + if (depth < maxDepth) dungeon.treasure = null; + const fingerprint = DungeonGenerator.layoutFingerprint(dungeon, fingerprintVersion); + const expected = entry.fingerprint ? String(entry.fingerprint).trim() : null; + return { + depth, + fingerprintVersion, + generatorVersion: entryGenerator, + fingerprint, + expectedFingerprint: expected, + matches: expected ? fingerprint === expected : null, + dungeonSize: { width: dungeon.map[0].length, height: dungeon.map.length }, + entrance: dungeon.entrance, + exit: dungeon.exit, + treasure: dungeon.treasure + }; + }); + + const compared = levels.filter(level => level.expectedFingerprint); + const allMatch = compared.length > 0 ? compared.every(level => level.matches) : null; + result.generatorVersion = recordedGenerator; + result.layoutManifestVersion = hasManifest ? 1 : 0; + result.verificationScope = hasManifest ? 'all_depths' : 'legacy_level_1_only'; + result.levels = levels; + result.verifiedDepths = levels.map(level => level.depth); + result.layoutFingerprint = levels[0]?.fingerprint || null; + result.expectedLayoutFingerprint = levels[0]?.expectedFingerprint || null; + result.layoutMatches = allMatch; + result.dungeonSize = levels[0]?.dungeonSize || null; + result.entrance = levels[0]?.entrance || null; + result.exit = levels[0]?.exit || null; + result.treasure = levels[0]?.treasure ?? null; + if (allMatch === false) { + result.valid = false; + result.message = '❌ Seed derivation verified, but one or more regenerated depth fingerprints do not match the played run.'; + } else if (hasManifest && allMatch === true) { + result.message = `✅ Game proof and all ${levels.length} dungeon depth fingerprints verified.`; + } + } catch (error) { + result.valid = false; + if (error?.code === 'UNSUPPORTED_GENERATOR') { + result.message = '❌ This proof uses an unsupported dungeon generator version.'; + result.regenerationError = 'unsupported_generator_version'; + } else if (error?.code === 'INCOMPLETE_MANIFEST') { + result.message = '❌ The persisted all-depth layout manifest is incomplete.'; + result.regenerationError = 'incomplete_layout_manifest'; + } else { + result.message = '❌ Proof fields verified, but the persisted dungeon context could not be regenerated.'; + result.regenerationError = 'regeneration_failed'; + } + } + return result; +} + +module.exports = { attachDungeonVerification }; diff --git a/src/game/game.js b/src/game/game.js index 46f171d..895d346 100644 --- a/src/game/game.js +++ b/src/game/game.js @@ -18,6 +18,11 @@ class Game { * @param {object} gameOptions - Optional game configuration overrides */ constructor(socketId, user, gameOptions = {}) { + const suppliedOptions = (gameOptions && typeof gameOptions === 'object') ? gameOptions : {}; + const fairnessProof = suppliedOptions.fairnessProof || null; + // Internal proof material (including the secret server seed) must never ride into dungeon + // options, snapshots, logs, or the persisted public generation context. + const { fairnessProof: _privateFairnessProof, clientSeed: optionClientSeed, ...dungeonOptions } = suppliedOptions; this.user = user; this.id = uuidv4(); this.socketId = socketId; @@ -28,7 +33,11 @@ class Game { this.startedAt = Date.now(); // Provably fair: Generate game proof before anything else - this.gameProof = createGameProof(this.id); + this.gameProof = createGameProof( + this.id, + fairnessProof?.clientSeed ?? optionClientSeed ?? '', + fairnessProof + ); // Per-game deterministic RNG derived from the committed seed. ALL game randomness // (dungeon generation + monster movement) flows through this so the game is // reproducible from the seed for provably-fair verification. @@ -39,22 +48,30 @@ class Game { const dungeonConfig = DungeonGenerator.getConfig(); // Determine dimensions - use provided options or defaults - const width = gameOptions.width || dungeonConfig.DEFAULT_WIDTH; - const height = gameOptions.height || dungeonConfig.DEFAULT_HEIGHT; + const width = dungeonOptions.width || dungeonConfig.DEFAULT_WIDTH; + const height = dungeonOptions.height || dungeonConfig.DEFAULT_HEIGHT; // Merge game configuration this.gameConfig = { width: width, height: height, // Dungeon generation options (can be overridden by gameOptions) - floorVariation: gameOptions.floorVariation !== undefined ? gameOptions.floorVariation : dungeonConfig.FLOOR_VARIATION, - torchEnabled: gameOptions.torchEnabled !== undefined ? gameOptions.torchEnabled : dungeonConfig.TORCH_ENABLED, - torchDensity: gameOptions.torchDensity !== undefined ? gameOptions.torchDensity : dungeonConfig.TORCH_DENSITY, - primaryFloor: gameOptions.primaryFloor || dungeonConfig.PRIMARY_FLOOR, - secondaryFloor: gameOptions.secondaryFloor || dungeonConfig.SECONDARY_FLOOR, - torchTile: gameOptions.torchTile || dungeonConfig.TORCH_TILE, + floorVariation: dungeonOptions.floorVariation !== undefined ? dungeonOptions.floorVariation : dungeonConfig.FLOOR_VARIATION, + torchEnabled: dungeonOptions.torchEnabled !== undefined ? dungeonOptions.torchEnabled : dungeonConfig.TORCH_ENABLED, + torchDensity: dungeonOptions.torchDensity !== undefined ? dungeonOptions.torchDensity : dungeonConfig.TORCH_DENSITY, + primaryFloor: dungeonOptions.primaryFloor || dungeonConfig.PRIMARY_FLOOR, + secondaryFloor: dungeonOptions.secondaryFloor || dungeonConfig.SECONDARY_FLOOR, + torchTile: dungeonOptions.torchTile || dungeonConfig.TORCH_TILE, + // Resolve every layout-affecting preset value now. Persisting these exact values lets an + // old game regenerate after operators change difficulty environment variables later. + roomWidthRange: dungeonOptions.roomWidthRange || dungeonConfig.ROOM_WIDTH_RANGE, + roomHeightRange: dungeonOptions.roomHeightRange || dungeonConfig.ROOM_HEIGHT_RANGE, + corridorLengthRange: dungeonOptions.corridorLengthRange || dungeonConfig.CORRIDOR_LENGTH_RANGE, + dugPercentage: dungeonOptions.dugPercentage ?? dungeonConfig.DUG_PERCENTAGE, + roomPositionRatio: dungeonOptions.roomPositionRatio ?? dungeonConfig.difficulty?.treasure?.roomPositionRatio, + cryptoType: dungeonOptions.cryptoType || process.env.CRYPTO_TYPE || 'WOW', // Other game options - ...gameOptions + ...dungeonOptions }; if (CONSOLE_LOGGING) { @@ -64,6 +81,56 @@ class Game { } this.initializeGame(); + + // Bind the proof to every level in the committed run. Deeper layouts are independently + // regenerated from their per-depth seeds now, before play, so a crash or early death still + // leaves a complete, immutable verification manifest for the advertised descent. + this.gameProof.generatorVersion = DungeonGenerator.GENERATOR_VERSION; + this.gameProof.context = this.getProofContext(); + this.gameProof.layoutFingerprints = this._buildLayoutFingerprintManifest(); + // Backward-compatible level-one alias used by older verification clients/rows. + this.gameProof.layoutFingerprint = this.gameProof.layoutFingerprints[0]?.fingerprint || null; + } + + _buildLayoutFingerprintManifest() { + const manifest = []; + for (let depth = 1; depth <= this.maxDepth; depth += 1) { + let dungeon; + if (depth === 1) { + dungeon = this.dungeon; + } else { + dungeon = DungeonGenerator.regenerateFromSeed( + levelSeed(this.gameProof.seed, depth), + this.gameConfig.cryptoType, + this.gameProof.context.generationOptions + ); + if (depth < this.maxDepth) dungeon.treasure = null; + } + manifest.push({ + depth, + fingerprintVersion: DungeonGenerator.FINGERPRINT_VERSION, + generatorVersion: DungeonGenerator.GENERATOR_VERSION, + fingerprint: DungeonGenerator.layoutFingerprint(dungeon) + }); + } + return manifest; + } + + getProofContext() { + const keys = [ + 'width', 'height', 'floorVariation', 'torchEnabled', 'torchDensity', 'primaryFloor', + 'secondaryFloor', 'torchTile', 'roomWidthRange', 'roomHeightRange', + 'corridorLengthRange', 'dugPercentage', 'roomPositionRatio', 'cryptoType' + ]; + const generationOptions = {}; + for (const key of keys) generationOptions[key] = this.gameConfig[key]; + return { + cryptoType: this.gameConfig.cryptoType || process.env.CRYPTO_TYPE || 'WOW', + maxDepth: this.maxDepth, + generatorVersion: DungeonGenerator.GENERATOR_VERSION, + fingerprintVersion: DungeonGenerator.FINGERPRINT_VERSION, + generationOptions + }; } /** @@ -100,7 +167,7 @@ class Game { this.height = this.gameConfig.height; // Get difficulty settings - const difficultyConfig = getDifficultyConfig(process.env.CRYPTO_TYPE || 'WOW'); + const difficultyConfig = getDifficultyConfig(this.gameConfig.cryptoType || process.env.CRYPTO_TYPE || 'WOW'); this.difficultyConfig = difficultyConfig; // Multi-level descent: a run spans `maxDepth` levels (a pacing knob ∝ block time, from the @@ -151,6 +218,17 @@ class Game { } this.dungeon = dungeon; + // Once the constructor has committed its all-level manifest, assert every actually played + // descent still matches it. This turns accidental generator drift within a live process into + // a hard failure rather than silently serving an unverifiable paid layout. + const expected = this.gameProof.layoutFingerprints?.find(item => item.depth === depth); + if (expected) { + const actual = DungeonGenerator.layoutFingerprint(dungeon, expected.fingerprintVersion); + if (actual !== expected.fingerprint) { + throw new Error(`Generated level ${depth} does not match its committed fingerprint`); + } + } + // Place player at the entrance. if (dungeon.entrance) { this.player.moveTo(dungeon.entrance[0], dungeon.entrance[1]); @@ -359,6 +437,7 @@ class Game { * @param {object} gameStats - Additional statistics */ endGame(result, gameStats = {}) { + this.finalResult = result; if (this.user) { const score = gameStats.score || 0; this.user.endGame(result, score, gameStats); @@ -382,7 +461,7 @@ class Game { */ getProofReveal() { return getPostGameReveal(this.gameProof, { - won: this.gameState === 'won', + won: this.finalResult === 'won' || this.gameState === 'won', treasureFound: this.player?.hasTreasure ?? false, moves: this.moveCount, duration: Math.floor((Date.now() - this.startedAt) / 1000) @@ -401,4 +480,4 @@ class Game { } } -module.exports = Game; \ No newline at end of file +module.exports = Game; diff --git a/src/game/gameManager.js b/src/game/gameManager.js index 713fb5f..9c17b2c 100644 --- a/src/game/gameManager.js +++ b/src/game/gameManager.js @@ -6,13 +6,30 @@ const Game = require('../game/game'); class GameManager { - constructor({ activeGames, io, broadcastManager, debugManager, gameModeManager, spectatorManager = null }) { + constructor({ + activeGames, + io, + broadcastManager, + debugManager, + gameModeManager, + spectatorManager = null, + settlementRetryBaseMs = 1000, + settlementRetryMaxMs = 30000 + }) { this.activeGames = activeGames; this.io = io; this.broadcastManager = broadcastManager; this.debugManager = debugManager; this.gameModeManager = gameModeManager; this.spectatorManager = spectatorManager; + this.suspendedGameManager = null; + this._pendingSettlements = new Map(); + this._settlementRetryBaseMs = Math.max(1, Number(settlementRetryBaseMs) || 1000); + this._settlementRetryMaxMs = Math.max( + this._settlementRetryBaseMs, + Number(settlementRetryMaxMs) || 30000 + ); + this._isShuttingDown = false; } /** @@ -23,6 +40,11 @@ class GameManager { this.spectatorManager = spectatorManager; } + /** Late binding avoids a constructor cycle with reconnect support. */ + setSuspendedGameManager(suspendedGameManager) { + this.suspendedGameManager = suspendedGameManager; + } + /** * Create a new game for a user * @param {Object} user - User object @@ -31,6 +53,13 @@ class GameManager { * @returns {Object} Created game instance */ async createGameForUser(user, gameType = 'standard', options = {}) { + // Settlement-pending games intentionally remain in activeGames. This is the last-line + // guard against starting/charging a replacement run while the previous terminal result + // is not yet durable (and also closes ordinary double-start races between handlers). + if (this.activeGames.has(user.id)) { + throw new Error('User already has an active or settlement-pending game'); + } + let game; if (gameType === 'legacy') { @@ -39,12 +68,18 @@ class GameManager { game = Game.createStandardGame(user.id, user, options); } + // Persist before exposing the game or charging entry. Paid-start processing updates this + // exact row; allowing play to continue after a failed INSERT can consume a payment/credit + // with no durable game or payout-liability anchor. + await this._insertGameRecord(game, user); + + if (this.gameModeManager?.paymentsEnabled && !game.dbId) { + throw new Error('Durable game record is required before a paid game can start'); + } + user.joinGame(game); this.activeGames.set(user.id, game); - // Insert DB record and wait for it (needed for processGameStart to find the row) - await this._insertGameRecord(game, user); - if (this.debugManager.CONSOLE_LOGGING) { console.log(`[createGameForUser] Created ${gameType} game ${game.id} for user ${user.id} (dbId: ${game.dbId || 'none'})`); } @@ -61,130 +96,298 @@ class GameManager { * @param {number} score - Game score */ async handleGameOver(socket, game, status, reason, message, score = 0) { + const socketId = socket?.id || socket; + if (!socketId || !game?.id) return { success: false, reason: 'Invalid game completion' }; + if (game.settlementCommitted) return { success: true, reason: 'Game already completed' }; + + // Escape, monster collision, and block timeout may converge in one event-loop turn. The + // first caller freezes the authoritative terminal facts synchronously; every later caller + // shares that exact settlement attempt instead of overwriting it or paying twice. + const existing = this._pendingSettlements.get(game.id); + if (existing) return this._attemptSettlement(existing); + try { - const socketId = socket.id || socket; - - // IMMEDIATELY remove from active games to prevent double-processing. - // If escape + block timeout fire in the same event loop tick, the second - // call would find the game still here during the first call's await. - this.activeGames.delete(socketId); - - game.gameState = status; const moves = game.moveCount || 0; - const durationSeconds = game.startedAt ? Math.max(0, Math.round((Date.now() - game.startedAt) / 1000)) : null; + const durationSeconds = game.startedAt + ? Math.max(0, Math.round((Date.now() - game.startedAt) / 1000)) + : null; const finalScore = score > 0 ? score : this._calculateScore(game, status, reason); - const gameStats = { - score: finalScore, - reason: reason, - treasuresFound: game.player.hasTreasure ? 1 : 0, - moves, - durationSeconds - }; - game.endGame(status, gameStats); - - // Process game completion with payment system - let payoutInfo = null; - if (this.gameModeManager) { - try { - payoutInfo = await this.gameModeManager.completeGame( - socketId, - game.id, - status === 'won', - game.player.hasTreasure || false, - { moves, durationSeconds, score: finalScore } - ); - - if (this.debugManager.CONSOLE_LOGGING && payoutInfo) { - console.log(`💰 Payout processed for ${socketId}:`, payoutInfo); - } - } catch (error) { - console.error('Error processing game completion:', error); - } - } - - // Emit game over event with provably fair reveal - const proofReveal = game.getProofReveal ? game.getProofReveal() : null; - - this.io.to(socketId).emit('game_over', { - status: status, - reason: reason, - message: message, + const treasure = !!game.player?.hasTreasure; + const terminal = Object.freeze({ + status, + reason, + message, score: finalScore, moves, durationSeconds, - payout: payoutInfo, - treasure: game.player.hasTreasure || false, - proof: proofReveal + treasure, + won: status === 'won', + outcome: reason === 'escaped' ? 'escaped' : reason }); - // Persist completion details if DB available - await this._updateGameRecord(game, socketId, status, reason, moves, durationSeconds); - - // Notify spectators that the game has ended and broadcast updated list - if (this.spectatorManager) { - this.spectatorManager.notifyGameEnded(game.id, { - status, - reason, - message, + // Mark terminal before any await. Keep ownership in activeGames until the transaction + // commits: movement and all existing start paths can then fail closed on this entry. + game.gameState = status; + game.settlementPending = true; + const settlement = { + game, + socketId, + terminal, + proof: null, + attempts: 0, + retryTimer: null, + inFlight: null, + pendingNoticeSent: false, + committed: false + }; + this._pendingSettlements.set(game.id, settlement); + try { + game.endGame(status, { score: finalScore, + reason, + treasuresFound: treasure ? 1 : 0, moves, - durationSeconds, - treasure: game.player.hasTreasure || false + durationSeconds }); + } catch (error) { + // In-memory user stats and rendering state are secondary to the durable result. + // A local callback failure must not suppress the settlement retry. + console.error('In-memory game finalization failed:', error.message || error); + } + try { + settlement.proof = game.getProofReveal ? game.getProofReveal() : null; + } catch (error) { + console.error('Game proof reveal failed:', error.message || error); + } + return await this._attemptSettlement(settlement); + } catch (error) { + // Never release a terminal game merely because orchestration failed. If an intent was + // installed it remains retryable; otherwise retain the game and flag it for operators. + game.settlementPending = true; + console.error('GameManager.handleGameOver error:', error.message || error); + return { success: false, reason: 'Game settlement pending' }; + } + } + + async _attemptSettlement(settlement) { + if (!settlement || settlement.committed) return { success: true, reason: 'Game already completed' }; + if (settlement.inFlight) return settlement.inFlight; + + const attempt = (async () => { + settlement.attempts += 1; + const socketId = this._socketIdForGame(settlement.game, settlement.socketId); + settlement.socketId = socketId; + let payoutInfo; + + try { + if (this.gameModeManager) { + const terminal = settlement.terminal; + payoutInfo = await this.gameModeManager.completeGame( + socketId, + settlement.game.id, + terminal.won, + terminal.treasure, + { + moves: terminal.moves, + durationSeconds: terminal.durationSeconds, + score: terminal.score, + reason: terminal.reason, + outcome: terminal.outcome + } + ); + } else { + const terminal = settlement.terminal; + await this._updateGameRecord( + settlement.game, + socketId, + terminal.status, + terminal.reason, + terminal.moves, + terminal.durationSeconds + ); + payoutInfo = { success: true, payout: null }; + } + } catch (error) { + console.error('Error processing game completion:', error.message || error); + payoutInfo = { success: false }; } - // Broadcast leaderboard update if score > 0 - if (finalScore > 0) { - try { - let displayName = null; - if (this.gameModeManager?.db) { - const userRow = await this.gameModeManager.db.query( - `SELECT COALESCE(display_name, - CASE WHEN payout_address IS NOT NULL - THEN LEFT(payout_address, 4) || '...' || RIGHT(payout_address, 4) - ELSE 'Anon#' || id - END) as name - FROM users WHERE socket_id = $1`, [socketId]); - displayName = userRow.rows[0]?.name || 'Unknown'; - } - this.io.emit('leaderboard_update', { - name: displayName || 'Unknown', - score: finalScore, - treasure: game.player.hasTreasure || false - }); + // `completeGame` deliberately normalizes transaction errors into success:false. + // Treat every other shape as non-durable and retry; only explicit success releases + // the game and publishes a result. + if (!payoutInfo || payoutInfo.success !== true) { + this._emitSettlementPending(settlement); + this._scheduleSettlementRetry(settlement); + return payoutInfo || { success: false, reason: 'Game settlement pending' }; + } - // Global "win feed": announce actual escapes to everyone so the room feels - // alive (the leaderboard_update above also fires for lost-with-treasure runs; - // the feed should only celebrate real escapes). Payout amount is intentionally - // omitted — it's processed asynchronously and not reliably known yet here. - if (status === 'won') { - this.io.emit('win_feed', { - name: displayName || 'Someone', - treasure: game.player.hasTreasure || false, - score: finalScore, - paid: !!(payoutInfo && payoutInfo.amount) - }); - } - } catch (lbErr) { - // Non-critical, don't block game over - if (this.debugManager.CONSOLE_LOGGING) { - console.warn('Leaderboard broadcast failed:', lbErr.message); - } + settlement.committed = true; + settlement.game.settlementPending = false; + settlement.game.settlementCommitted = true; + if (settlement.retryTimer) { + clearTimeout(settlement.retryTimer); + settlement.retryTimer = null; + } + this._pendingSettlements.delete(settlement.game.id); + + // Capture the reconnect-adjusted socket before releasing ownership. Remove only this + // exact game so a theoretically newer entry can never be deleted by a late retry. + const committedSocketId = this._socketIdForGame(settlement.game, settlement.socketId); + for (const [activeSocketId, activeGame] of this.activeGames.entries()) { + if (activeGame === settlement.game) this.activeGames.delete(activeSocketId); + } + const stableUserId = settlement.game.dbUserId ?? settlement.game.userId; + if (stableUserId != null) { + try { + this.suspendedGameManager?.removeSuspendedGame?.(stableUserId, { countExpired: false }); + } catch (error) { + console.error('Committed suspended-game cleanup failed:', error.message || error); } } - if (this.debugManager.CONSOLE_LOGGING) { - console.log(`🎮 Game ${game.id} ended for ${socketId}: ${status} (${reason}), score: ${finalScore}`); - } + await this._publishCommittedSettlement(settlement, payoutInfo, committedSocketId); + return payoutInfo; + })(); + settlement.inFlight = attempt; + try { + return await attempt; + } finally { + if (settlement.inFlight === attempt) settlement.inFlight = null; + } + } + + _scheduleSettlementRetry(settlement) { + if (this._isShuttingDown || !settlement || settlement.committed || settlement.retryTimer) return; + const exponent = Math.min(Math.max(settlement.attempts - 1, 0), 10); + const delay = Math.min(this._settlementRetryBaseMs * (2 ** exponent), this._settlementRetryMaxMs); + const timer = setTimeout(() => { + if (settlement.retryTimer === timer) settlement.retryTimer = null; + if (settlement.committed || !this._pendingSettlements.has(settlement.game.id)) return; + this._attemptSettlement(settlement).catch(error => { + console.error('Solo settlement retry failed:', error.message || error); + }); + }, delay); + timer.unref?.(); + settlement.retryTimer = timer; + } + + _emitSettlementPending(settlement) { + if (settlement.pendingNoticeSent) return; + settlement.pendingNoticeSent = true; + const socketId = this._socketIdForGame(settlement.game, settlement.socketId); + try { + this.io.to(socketId).emit('game_settlement_pending', { + gameId: settlement.game.id, + code: 'GAME_FINISH_NOT_DURABLE', + retrying: true + }); } catch (error) { - console.error('GameManager.handleGameOver error:', error); - // Still try to clean up even if there was an error - const socketId = socket?.id || socket; - if (socketId) { - this.activeGames.delete(socketId); + // Socket delivery is best-effort. It must never prevent the authoritative DB retry. + console.error('Solo settlement-pending broadcast failed:', error.message || error); + } + } + + _socketIdForGame(game, fallback) { + for (const [socketId, activeGame] of this.activeGames.entries()) { + if (activeGame === game) return socketId; + } + return game?.socketId || fallback; + } + + async _publishCommittedSettlement(settlement, payoutInfo, socketId) { + const { game, terminal, proof } = settlement; + + try { + this.io.to(socketId).emit('game_over', { + status: terminal.status, + reason: terminal.reason, + message: terminal.message, + score: terminal.score, + moves: terminal.moves, + durationSeconds: terminal.durationSeconds, + payout: payoutInfo, + treasure: terminal.treasure, + proof + }); + } catch (error) { + console.error('Game-over broadcast failed:', error.message || error); + } + + try { + this.spectatorManager?.notifyGameEnded(game.id, { + status: terminal.status, + reason: terminal.reason, + message: terminal.message, + score: terminal.score, + moves: terminal.moves, + durationSeconds: terminal.durationSeconds, + treasure: terminal.treasure + }); + } catch (error) { + console.error('Spectator game-over broadcast failed:', error.message || error); + } + + if (terminal.score > 0) { + try { + let displayName = null; + if (this.gameModeManager?.db) { + const userRow = await this.gameModeManager.db.query( + `SELECT COALESCE(display_name, + CASE WHEN payout_address IS NOT NULL + THEN LEFT(payout_address, 4) || '...' || RIGHT(payout_address, 4) + ELSE 'Anon#' || id + END) as name + FROM users WHERE socket_id = $1`, [socketId]); + displayName = userRow.rows[0]?.name || 'Unknown'; + } + this.io.emit('leaderboard_update', { + name: displayName || 'Unknown', + score: terminal.score, + treasure: terminal.treasure + }); + if (terminal.status === 'won') { + this.io.emit('win_feed', { + name: displayName || 'Someone', + treasure: terminal.treasure, + score: terminal.score, + paid: !!payoutInfo?.payout?.amount + }); + } + } catch (error) { + if (this.debugManager.CONSOLE_LOGGING) { + console.warn('Leaderboard broadcast failed:', error.message); + } } } + + if (this.debugManager.CONSOLE_LOGGING) { + console.log(`🎮 Game ${game.id} ended for ${socketId}: ${terminal.status} (${terminal.reason}), score: ${terminal.score}`); + } + } + + /** + * Stop timers and make bounded, serialized final attempts before the DB pool closes. + * Returns unresolved count so the process shutdown path can log/escalate it truthfully. + */ + async shutdown({ timeoutMs = 4000, retryIntervalMs = 50 } = {}) { + this._isShuttingDown = true; + for (const settlement of this._pendingSettlements.values()) { + if (settlement.retryTimer) clearTimeout(settlement.retryTimer); + settlement.retryTimer = null; + } + + const initial = this._pendingSettlements.size; + const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0); + do { + const pending = Array.from(this._pendingSettlements.values()); + if (pending.length === 0) break; + await Promise.allSettled(pending.map(settlement => this._attemptSettlement(settlement))); + if (this._pendingSettlements.size === 0 || Date.now() >= deadline) break; + await new Promise(resolve => setTimeout(resolve, Math.max(1, retryIntervalMs))); + } while (Date.now() < deadline); + + return { initial, settled: initial - this._pendingSettlements.size, pending: this._pendingSettlements.size }; } /** @@ -317,10 +520,36 @@ class GameManager { try { const result = await db.query(` - INSERT INTO games (user_id, socket_id, game_mode, status, start_block_height, dungeon_seed, created_at) - VALUES ((SELECT id FROM users WHERE socket_id = $1), $2, $3, 'active', $4, $5, NOW()) + INSERT INTO games ( + user_id, socket_id, game_mode, status, start_block_height, dungeon_seed, + proof_version, fairness_offer_id, fairness_offer_issued_at, + proof_commitment, server_seed, client_seed, effective_seed, + layout_fingerprint, layout_fingerprints, generator_version, + proof_context, created_at + ) + VALUES ( + (SELECT id FROM users WHERE socket_id = $1), $2, $3, 'active', $4, $5, + $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW() + ) RETURNING id, user_id - `, [socketId, socketId, gameMode, blockHeight, game.id]); + `, [ + socketId, + socketId, + gameMode, + blockHeight, + game.id, + game.gameProof?.proofVersion || 1, + game.gameProof?.offerId || null, + game.gameProof?.offerIssuedAt ? new Date(game.gameProof.offerIssuedAt) : null, + game.gameProof?.commitment || null, + game.gameProof?.serverSeed || null, + game.gameProof?.clientSeed || '', + game.gameProof?.seed || null, + game.gameProof?.layoutFingerprint || null, + game.gameProof?.layoutFingerprints ? JSON.stringify(game.gameProof.layoutFingerprints) : null, + game.gameProof?.generatorVersion || null, + game.gameProof?.context ? JSON.stringify(game.gameProof.context) : null + ]); game.dbId = result.rows[0]?.id || null; // Stamp the stable DB users.id onto the in-memory game object so @@ -344,6 +573,7 @@ class GameManager { } catch (err) { console.error('Game insert failed:', err.message); game.dbId = null; + throw err; } } } @@ -364,7 +594,8 @@ class GameManager { try { await db.query(` UPDATE games SET status = $1, outcome = $2, treasure_found = $3, moves_made = $4, - duration_seconds = $5, score = $8, completed_at = NOW() + duration_seconds = $5, score = $8, completed_at = NOW(), + proof_revealed_at = NOW() WHERE dungeon_seed = $6 AND socket_id = $7 `, [status, outcome, game.player.hasTreasure, moves, durationSeconds, game.id, socketId, score]); @@ -386,4 +617,4 @@ class GameManager { } } -module.exports = GameManager; \ No newline at end of file +module.exports = GameManager; diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index cb7bcb6..71251f2 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -18,18 +18,38 @@ const paymentConfig = require('../config/paymentConfig'); const money = require('../money/atomic'); const Entitlements = require('../multiplayer/entitlements'); const ProductGrants = require('../payments/productGrants'); +const { hashSeed, normalizeClientSeed } = require('./provablyFair'); const { buildProviderRegistry } = require('../payments/providers'); +const { listMatchRulesets, resolveMatchRuleset } = require('./rulesets'); +const { matchPayoutAdmissionPolicy } = require('../network/matchEconomyPolicy'); +const { isSmirkEnabled } = require('../auth/smirkPolicy'); +const { reservePayoutCapacity } = require('../services/payoutAdmissionService'); // A wallet "not enough (unlocked) money" error is raised BEFORE the tx is broadcast, so it // is SAFE to retry (no double-pay risk) — unlike an ambiguous post-broadcast failure. Monero // locks spent outputs (incl. change) for ~10 blocks, so this is the expected error when all // outputs are temporarily locked. -function isInsufficientFundsError(message) { - return /not enough (unlocked )?(money|balance|outputs)|insufficient|no unlocked|unlocked balance/i.test(String(message || '')); +function payoutErrorText(error, depth = 0) { + if (depth > 4 || error == null) return ''; + if (typeof error === 'string') return error; + const parts = [error.message, error.details && JSON.stringify(error.details)]; + if (error.cause && error.cause !== error) parts.push(payoutErrorText(error.cause, depth + 1)); + return parts.filter(Boolean).join(' '); +} + +function isInsufficientFundsError(error) { + return /not enough (unlocked )?(money|balance|outputs)|insufficient|no unlocked|unlocked balance/i + .test(payoutErrorText(error)); } const DEFAULT_SINGLE_GAME_PRICE = 5000000000; // 0.005 XMR or 0.05 WOW depending on currency decimals const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000; +const NO_PAYOUT_ADDRESS_SENTINEL = 'PENDING_NO_ADDRESS'; +const TX_HASH_PATTERN = /^[0-9a-f]{64}$/i; + +function validTxHash(value) { + return typeof value === 'string' && TX_HASH_PATTERN.test(value.trim()); +} class GameModeManager { constructor(databaseManager, walletRPCService, debugManager, paymentConfigManager = null, paymentProviders = null) { @@ -54,7 +74,14 @@ class GameModeManager { this.singleGamePrice = DEFAULT_SINGLE_GAME_PRICE; this.creditsPackagePrice = DEFAULT_CREDITS_PACKAGE_PRICE; + // PAYOUTS_ENABLED is the operator's emergency/master kill switch. Per-mode flags are + // subordinate to it; keeping the values separate makes hot config changes reversible + // without losing the configured direct/credits policy. + this.payoutsEnabled = true; this.creditsPayoutEnabled = false; + this.directPayoutEnabled = true; + this.directRequiresAddress = true; + this.creditsRequiresAddress = true; this.creditsPayoutBaseValue = DEFAULT_SINGLE_GAME_PRICE; this.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; this.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; @@ -64,6 +91,12 @@ class GameModeManager { this.directModeEnabled = false; this.creditsModeEnabled = false; this.configSnapshot = null; + this._batchPayoutTimer = null; + this._isBatchProcessing = false; + this._isShuttingDown = false; + // The composition root replaces this with the dynamic startup/runtime financial + // reconciliation gate. Default-open keeps isolated free/test instances compatible. + this.financialAdmissionAllowed = () => true; this.applyLegacyEnvConfig(); @@ -100,11 +133,14 @@ class GameModeManager { this.singleGamePrice = parseAtomicEnvValue(process.env.DIRECT_GAME_PRICE || process.env.SINGLE_GAME_PRICE, this.singleGamePrice); this.creditsPackagePrice = parseAtomicEnvValue(process.env.CREDITS_PACKAGE_PRICE, this.creditsPackagePrice); this.creditsPerGameCost = parseAtomicEnvValue(process.env.CREDITS_PER_GAME, 1) || 1; + this.payoutsEnabled = !/^false$/i.test(process.env.PAYOUTS_ENABLED || 'true'); this.creditsPayoutEnabled = /^true$/i.test(process.env.CREDITS_PAYOUTS_ENABLED || process.env.CREDITS_PAYOUT_ENABLED || 'false'); // Direct (per-game / entry-fee) payouts must be independently gateable, like credits. // Default true for backward compatibility; set DIRECT_PAYOUTS_ENABLED=false for a // no-payout instance (sell entry/credits for prestige only, e.g. mainnet legitimacy). this.directPayoutEnabled = !/^false$/i.test(process.env.DIRECT_PAYOUTS_ENABLED || 'true'); + this.directRequiresAddress = !/^false$/i.test(process.env.DIRECT_REQUIRES_ADDRESS || 'true'); + this.creditsRequiresAddress = !/^false$/i.test(process.env.CREDITS_REQUIRES_ADDRESS || 'true'); this.creditsPayoutBaseValue = parseAtomicEnvValue(process.env.CREDITS_PAYOUT_BASE, this.singleGamePrice); process.env.CREDITS_PER_GAME = String(this.creditsPerGameCost); @@ -159,11 +195,14 @@ class GameModeManager { } if (config.modes && config.modes.direct) { - const { price, enabled } = config.modes.direct; + const { price, enabled, requiresAddress } = config.modes.direct; if (price !== undefined && price !== null) { - this.singleGamePrice = Number(price); + this.singleGamePrice = money.toSafe(money.toBig(price)); } this.directModeEnabled = !!enabled; + if (requiresAddress !== undefined) { + this.directRequiresAddress = !!requiresAddress; + } } if (config.modes && config.modes.credits) { @@ -171,7 +210,7 @@ class GameModeManager { if (creditsMode.packages && creditsMode.packages.length > 0) { const primaryPackage = creditsMode.packages[0]; if (primaryPackage.price !== undefined && primaryPackage.price !== null) { - this.creditsPackagePrice = Number(primaryPackage.price); + this.creditsPackagePrice = money.toSafe(money.toBig(primaryPackage.price)); } if (primaryPackage.credits) { process.env.CREDITS_PER_PACKAGE = String(primaryPackage.credits); @@ -181,6 +220,13 @@ class GameModeManager { this.creditsPerGameCost = Number(creditsMode.creditsPerGame) || 1; } this.creditsModeEnabled = !!creditsMode.enabled; + if (creditsMode.requiresAddress !== undefined) { + this.creditsRequiresAddress = !!creditsMode.requiresAddress; + } + } + + if (config.payouts && config.payouts.enabled !== undefined) { + this.payoutsEnabled = !!config.payouts.enabled; } if (config.payouts && config.payouts.rules) { @@ -210,7 +256,7 @@ class GameModeManager { } if (creditsRule.baseValue !== undefined) { - this.creditsPayoutBaseValue = Number(creditsRule.baseValue); + this.creditsPayoutBaseValue = money.toSafe(money.toBig(creditsRule.baseValue)); } if (creditsRule.enabled !== undefined) { @@ -230,6 +276,12 @@ class GameModeManager { process.env.CREDITS_PAYOUT_ENABLED = this.creditsPayoutEnabled ? 'true' : 'false'; process.env.CREDITS_PAYOUTS_ENABLED = process.env.CREDITS_PAYOUT_ENABLED; + // applyLegacyEnvConfig() runs before the unified config snapshot. Without recomputing + // here, an instance with no legacy GAME_MODE starts from FREE and accidentally keeps + // free play enabled even when FREE_PLAY_ENABLED=false and paid modes are active. + this.freePlayEnabled = !this.paymentsEnabled + || /^true$/i.test(process.env.FREE_PLAY_ENABLED || 'false'); + if (options.emitLog) { this.logConfiguration(options.context || 'updated'); } @@ -272,18 +324,378 @@ class GameModeManager { }; } + /** + * PAYMENTS_ENABLED is the intake kill switch. Product-specific switches narrow it further; + * they can never create an invoice when the master switch is off. Keep this predicate pure + * so Socket.IO can refuse before consuming a fairness offer or touching wallet/DB state, and + * enforce it again in createPaymentRequest for non-Socket.IO callers. + */ + isPaymentIntakeEnabled(paymentType) { + if (!this._isFinancialAdmissionAllowed()) return false; + if (!this.paymentsEnabled) return false; + if (paymentType === 'single_game') return Boolean(this.directModeEnabled); + if (paymentType === 'credits_package') return Boolean(this.creditsModeEnabled); + if (paymentType === 'cosmetic_pack') return true; + // Unknown products are rejected by the canonical switch below; keeping them distinct + // from a disabled product produces the correct operator/client diagnostic. + return true; + } + + _isFinancialAdmissionAllowed() { + try { + return typeof this.financialAdmissionAllowed !== 'function' + || this.financialAdmissionAllowed() === true; + } catch (_) { + return false; + } + } + + _financialRecoveryPendingResult() { + return { + success: false, + code: 'FINANCIAL_RECOVERY_PENDING', + reason: 'Financial recovery is still pending; paid entry is temporarily unavailable.' + }; + } + + _assertPaymentIntakeEnabled(paymentType) { + if (this.isPaymentIntakeEnabled(paymentType)) return; + const product = paymentType === 'single_game' + ? 'Direct entry' + : (paymentType === 'credits_package' ? 'Credit purchases' : 'Payment intake'); + throw new ValidationError(`${product} is disabled by runtime policy`, { + code: 'PAYMENT_INTAKE_DISABLED', + safeMessage: 'That paid product is not available on this server.' + }); + } + + _requiresPaidFairnessV2() { + // Production invoices are never allowed to opt out: the proof must be committed + // before funds can arrive and survive a restart on the durable payment row. + if (process.env.NODE_ENV === 'production') return true; + if (/^false$/i.test(process.env.REQUIRE_PAID_FAIRNESS_V2 || '')) return false; + return /^true$/i.test(process.env.REQUIRE_PAID_FAIRNESS_V2 || ''); + } + + /** Validate and reduce private offer material before binding it to a payment row. */ + _normalizePaymentFairnessProof(input, { required = this._requiresPaidFairnessV2() } = {}) { + if (!input || typeof input !== 'object') { + if (required) { + throw new ValidationError('A v2 fairness offer is required before creating a paid entry', { + safeMessage: 'Request a new fairness offer before paying.' + }); + } + return null; + } + const proofVersion = Number(input.proofVersion); + const offerId = typeof input.offerId === 'string' ? input.offerId.trim() : ''; + const serverSeed = typeof input.serverSeed === 'string' ? input.serverSeed.trim().toLowerCase() : ''; + const commitment = typeof input.commitment === 'string' ? input.commitment.trim().toLowerCase() : ''; + const clientSeed = normalizeClientSeed(input.clientSeed); + const issuedMs = input.offerIssuedAt instanceof Date + ? input.offerIssuedAt.getTime() + : Number(input.offerIssuedAt); + if (proofVersion !== 2 + || !offerId || offerId.length > 64 + || !/^[0-9a-f]{64}$/.test(serverSeed) + || !/^[0-9a-f]{64}$/.test(commitment) + || hashSeed(serverSeed) !== commitment + || clientSeed === null + || !Number.isFinite(issuedMs) || issuedMs <= 0) { + throw new ValidationError('Invalid v2 fairness proof supplied for paid entry', { + safeMessage: 'The fairness offer is invalid or expired. Request a new offer.' + }); + } + return { + proofVersion: 2, + offerId, + offerIssuedAt: issuedMs, + serverSeed, + commitment, + clientSeed + }; + } + + _paymentFairnessProofFromRow(row) { + if (!row || row.fairness_bound_at == null) return null; + try { + return this._normalizePaymentFairnessProof({ + proofVersion: row.fairness_proof_version, + offerId: row.fairness_offer_id, + offerIssuedAt: row.fairness_offer_issued_at instanceof Date + ? row.fairness_offer_issued_at.getTime() + : Date.parse(row.fairness_offer_issued_at), + serverSeed: row.fairness_server_seed, + commitment: row.fairness_commitment, + clientSeed: row.fairness_client_seed || '' + }, { required: true }); + } catch (_) { + return null; + } + } + + _normalizeConfirmationReceipts(receipts, providerId) { + if (!Array.isArray(receipts)) return []; + const expectedProvider = String(providerId || 'native-monero'); + const normalized = new Map(); + for (const receipt of receipts) { + const evidenceType = String(receipt?.evidenceType || ''); + const receiptProvider = String(receipt?.providerId || expectedProvider); + const evidenceId = String(receipt?.evidenceId || '').trim(); + const txHash = typeof receipt?.txHash === 'string' + ? receipt.txHash.trim().toLowerCase() + : null; + const rawOutputId = typeof receipt?.outputId === 'string' + ? receipt.outputId.trim().toLowerCase() + : null; + const addressIndex = receipt?.addressIndex == null ? null : Number(receipt.addressIndex); + const amount = money.toBig(receipt?.amount); + if (receipt?.confirmed !== true || receiptProvider !== expectedProvider + || amount <= 0n || !evidenceId || evidenceId.length > 160) { + throw new ValidationError('Invalid confirmed payment receipt evidence'); + } + let outputId = rawOutputId; + if (evidenceType === 'chain_output') { + if (outputId?.startsWith('global:')) { + const rawIndex = outputId.slice('global:'.length); + if (!/^\d+$/.test(rawIndex)) { + throw new ValidationError('Invalid native chain-output receipt evidence'); + } + const index = BigInt(rawIndex); + if (index > 18446744073709551615n) { + throw new ValidationError('Invalid native chain-output receipt evidence'); + } + outputId = `global:${index.toString()}`; + } + if (expectedProvider !== 'native-monero' || !validTxHash(txHash) + || (!/^[0-9a-f]{64}$/.test(outputId || '') + && !/^global:(0|[1-9]\d*)$/.test(outputId || '')) + || evidenceId.toLowerCase() !== `${txHash}:${outputId}` + || !Number.isInteger(addressIndex) || addressIndex < 0) { + throw new ValidationError('Invalid native chain-output receipt evidence'); + } + } else if (evidenceType === 'provider_invoice') { + if (expectedProvider === 'native-monero' || txHash !== null + || outputId !== null || addressIndex !== null) { + throw new ValidationError('Invalid provider-invoice receipt evidence'); + } + } else { + throw new ValidationError('Unknown payment receipt evidence type'); + } + const value = { + providerId: expectedProvider, + evidenceType, + evidenceId: evidenceType === 'chain_output' ? `${txHash}:${outputId}` : evidenceId, + txHash, + outputId, + addressIndex, + amount: amount.toString() + }; + const prior = normalized.get(value.evidenceId); + if (prior) { + if (JSON.stringify(prior) !== JSON.stringify(value)) { + throw new ValidationError('Conflicting duplicate payment receipt evidence'); + } + continue; + } + normalized.set(value.evidenceId, value); + } + return Array.from(normalized.values()); + } + + async _persistConfirmationReceipts(client, payment, receipts, { requireCoverage = true } = {}) { + const providerId = String(payment.provider_id || 'native-monero'); + const normalized = this._normalizeConfirmationReceipts(receipts, providerId); + if (normalized.length === 0) { + if (process.env.NODE_ENV === 'production') { + throw new ValidationError('Confirmed payment has no durable receipt evidence'); + } + return null; + } + if (providerId === 'native-monero') { + const paymentAddressIndex = Number(payment.address_index); + if (!Number.isInteger(paymentAddressIndex) || paymentAddressIndex < 0 + || normalized.some(receipt => receipt.addressIndex !== paymentAddressIndex)) { + throw new ValidationError('Chain-output receipt does not belong to this invoice address'); + } + } + for (const receipt of normalized) { + await client.query(` + INSERT INTO payment_receipts ( + payment_id, provider_id, evidence_type, evidence_id, + tx_hash, output_id, address_index, amount, confirmed + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::bigint, TRUE) + ON CONFLICT (provider_id, evidence_id) DO NOTHING + `, [payment.id, receipt.providerId, receipt.evidenceType, receipt.evidenceId, + receipt.txHash, receipt.outputId, receipt.addressIndex, receipt.amount]); + } + const evidence = await client.query(` + SELECT COALESCE(SUM(amount), 0)::text AS total, + MIN(tx_hash) FILTER (WHERE tx_hash IS NOT NULL) AS primary_tx_hash, + COUNT(*)::text AS receipt_count + FROM payment_receipts + WHERE payment_id = $1 AND provider_id = $2 AND confirmed = TRUE + AND (($2 = 'native-monero' AND evidence_type = 'chain_output') + OR ($2 <> 'native-monero' AND evidence_type = 'provider_invoice')) + `, [payment.id, providerId]); + const total = money.toBig(evidence.rows[0]?.total || 0); + const expected = money.toBig(payment.expected_amount); + if (requireCoverage && total < expected) { + throw new ValidationError('Confirmed receipts do not cover the invoice amount'); + } + return { + total, + primaryTxHash: evidence.rows[0]?.primary_tx_hash || null, + receiptCount: Number(evidence.rows[0]?.receipt_count || 0) + }; + } + + async confirmSingleGamePayment(paymentId, receivedAmount, receipts = []) { + return this.db.withTransaction(async (client) => { + const locked = await client.query(` + SELECT id, status, expected_amount, provider_id, address_index + FROM payments WHERE id = $1 FOR UPDATE + `, [paymentId]); + const payment = locked.rows[0]; + if (!payment || payment.status !== 'pending') return { updated: false }; + const evidence = await this._persistConfirmationReceipts(client, payment, receipts); + const received = evidence?.total ?? money.toBig(receivedAmount || 0); + if (received < money.toBig(payment.expected_amount)) return { updated: false, underpaid: true }; + const update = await client.query(` + UPDATE payments + SET status = 'confirmed', confirmed_at = NOW(), received_amount = $2::bigint, + tx_hash = COALESCE($3, tx_hash), confirmation_evidence_at = NOW() + WHERE id = $1 AND status = 'pending' + RETURNING id + `, [paymentId, received.toString(), evidence?.primaryTxHash || null]); + return { updated: update.rows.length === 1 }; + }); + } + + /** + * Persist receipts discovered after invoice expiry without granting a game or product. + * The payment is left expired and a durable manual-review item is upserted. This makes late + * mining/refund cases visible while keeping stale or unbound entries out of the game queue. + */ + async reconcileLatePaymentForReview(paymentId, receipts = []) { + return this.db.withTransaction(async (client) => { + const locked = await client.query(` + SELECT id, status, expected_amount, provider_id, address_index + FROM payments + WHERE id = $1 + FOR UPDATE + `, [paymentId]); + const payment = locked.rows[0]; + if (!payment || !['pending', 'expired'].includes(payment.status)) { + return { checked: false, needsReview: false }; + } + + let evidence = null; + if (Array.isArray(receipts) && receipts.length > 0) { + evidence = await this._persistConfirmationReceipts(client, payment, receipts, { + requireCoverage: false + }); + } + const observed = evidence?.total || 0n; + + await client.query(` + UPDATE payments + SET status = CASE WHEN status = 'pending' THEN 'expired' ELSE status END, + late_receipt_checked_at = NOW(), + received_amount = GREATEST(COALESCE(received_amount, 0), $2::bigint) + WHERE id = $1 + `, [paymentId, observed.toString()]); + + if (observed <= 0n || !evidence?.receiptCount) { + return { checked: true, needsReview: false }; + } + + const coverageComplete = observed >= money.toBig(payment.expected_amount); + await client.query(` + INSERT INTO payment_late_reviews ( + payment_id, provider_id, observed_amount, expected_amount, + receipt_count, metadata + ) VALUES ($1, $2, $3::bigint, $4::bigint, $5, $6::jsonb) + ON CONFLICT (payment_id) DO UPDATE + SET provider_id = EXCLUDED.provider_id, + status = 'needs_review', + observed_amount = GREATEST(payment_late_reviews.observed_amount, + EXCLUDED.observed_amount), + expected_amount = EXCLUDED.expected_amount, + receipt_count = GREATEST(payment_late_reviews.receipt_count, + EXCLUDED.receipt_count), + metadata = EXCLUDED.metadata, + last_observed_at = NOW(), + resolved_at = NULL + `, [paymentId, payment.provider_id || 'native-monero', observed.toString(), + String(payment.expected_amount), evidence.receiptCount, + JSON.stringify({ coverageComplete, entitlementGranted: false })]); + + return { + checked: true, + needsReview: true, + observedAmount: observed.toString(), + coverageComplete + }; + }); + } + _getMatchEconomies() { const out = { free: true }; if (this.creditsModeEnabled || this.freePlayEnabled) { out.credits_prestige = true; } - if (process.env.MATCH_CRYPTO_RACE_ENABLED === 'true' && (this.directModeEnabled || this.creditsModeEnabled)) { + // Crypto admission has its own explicit switch, payout cap, ruleset contract, and the + // global payout safety gate. Existing accepted matches settle from their DB snapshot; + // this policy controls only new queue admission. + const cryptoPolicy = matchPayoutAdmissionPolicy({ + env: process.env, + gameModeManager: this, + ruleset: resolveMatchRuleset(process.env.MATCH_RULESET_ID || 'race'), + requestedMaxPlayers: process.env.MATCH_MAX_PLAYERS + }); + if (cryptoPolicy.enabled) { out.crypto_race = true; } return out; } + _getMatchRulesetInfo() { + const active = resolveMatchRuleset(process.env.MATCH_RULESET_ID || 'race'); + const catalog = listMatchRulesets(); + return { + activeRuleset: catalog.find(r => r.id === active.id) || catalog.find(r => r.id === 'race'), + rulesets: catalog + }; + } + + /** Effective payout policy for a recorded game mode (master switch + per-mode rule). */ + isPayoutEnabledForMode(mode) { + if (!this.payoutsEnabled) return false; + const normalized = String(mode || '').toUpperCase(); + if (normalized === 'PAID_SINGLE') { + return !!this.directPayoutEnabled + && (Number(this.directPayoutMultipliers.escape) > 0 + || Number(this.directPayoutMultipliers.escapeWithTreasure) > 0); + } + if (normalized === 'PAID_CREDITS') { + return !!this.creditsPayoutEnabled + && (Number(this.creditPayoutMultipliers.escape) > 0 + || Number(this.creditPayoutMultipliers.escapeWithTreasure) > 0); + } + return false; + } + + /** Whether this mode must have a payout address before entry/payment is accepted. */ + requiresPayoutAddressForMode(mode) { + const normalized = String(mode || '').toUpperCase(); + if (!this.isPayoutEnabledForMode(normalized)) return false; + if (normalized === 'PAID_SINGLE') return !!this.directRequiresAddress; + if (normalized === 'PAID_CREDITS') return !!this.creditsRequiresAddress; + return false; + } + getCosmeticProducts() { const products = this.configSnapshot?.products?.cosmetic; return Array.isArray(products) ? products : []; @@ -295,11 +707,29 @@ class GameModeManager { return products.find(p => p.id === productId) || null; } + /** + * Return the complete solo-payout outcome contract implemented by calculatePayout(). + * Explicitly selecting the two reachable outcomes prevents stale or experimental config + * keys from being advertised as payout promises by public APIs. + */ + getImplementedPayoutMultipliersForMode(mode) { + const normalizedMode = (mode || this.gameMode || 'FREE').toUpperCase(); + const configured = normalizedMode === 'PAID_CREDITS' + ? this.creditPayoutMultipliers + : this.directPayoutMultipliers; + const escape = configured?.escape ?? 0; + + return { + escape, + escapeWithTreasure: configured?.escapeWithTreasure ?? escape + }; + } + calculatePayout(mode, { treasureFound = false } = {}) { const normalizedMode = (mode || this.gameMode || 'FREE').toUpperCase(); const usingCredits = normalizedMode === 'PAID_CREDITS'; const base = usingCredits ? (this.creditsPayoutBaseValue || this.singleGamePrice) : this.singleGamePrice; - const multipliers = usingCredits ? this.creditPayoutMultipliers : this.directPayoutMultipliers; + const multipliers = this.getImplementedPayoutMultipliersForMode(normalizedMode); const multiplier = treasureFound ? (multipliers.escapeWithTreasure ?? multipliers.escape ?? 0) : (multipliers.escape ?? 0); @@ -309,9 +739,11 @@ class GameModeManager { // loss on large atomic amounts (the old `Math.round(base * multiplier)`). const amount = money.toSafe(money.mulByDecimal(base, multiplier)); - console.log(`🧮 calculatePayout: mode=${normalizedMode}, usingCredits=${usingCredits}, treasureFound=${treasureFound}`); - console.log(` base=${base}, multipliers=${JSON.stringify(multipliers)}, chosen multiplier=${multiplier}`); - console.log(` final amount=${amount} (base * multiplier = ${base} * ${multiplier})`); + if (this.debugManager?.CONSOLE_LOGGING) { + console.log(`🧮 calculatePayout: mode=${normalizedMode}, usingCredits=${usingCredits}, treasureFound=${treasureFound}`); + console.log(` base=${base}, multipliers=${JSON.stringify(multipliers)}, chosen multiplier=${multiplier}`); + console.log(` final amount=${amount} (base * multiplier = ${base} * ${multiplier})`); + } return { amount, multiplier, base }; } @@ -324,12 +756,14 @@ class GameModeManager { * @param {object} packageInfo - Package info (credits, bonus, etc.) * @returns {object} Result with success, creditsAdded, newBalance, alreadyProcessed */ - async processProductPaymentConfirmation(socketId, paymentId, productInfo = null, receivedAmount = null) { + async processProductPaymentConfirmation(socketId, paymentId, productInfo = null, receivedAmount = null, receipts = []) { try { // Look up user from payment record's user_id (stable across socket reconnects) // This avoids the bug where socket ID changes if user refreshes during confirmation const paymentLookup = await this.db.query(` - SELECT user_id, description, status, payment_type, product_id, product_grants + SELECT user_id, description, status, payment_type, product_id, product_grants, + received_amount, expected_amount, provider_id, provider_invoice_id, + address_index FROM payments WHERE id = $1 `, [paymentId]); @@ -347,6 +781,7 @@ class GameModeManager { const userId = paymentLookup.rows[0].user_id; const paymentRow = paymentLookup.rows[0]; + paymentRow.id = paymentId; // Determine credits to add from package info or payment description const paymentType = paymentRow.payment_type || 'credits_package'; @@ -364,15 +799,76 @@ class GameModeManager { id: paymentRow.product_id, grants: paymentRow.product_grants || undefined }; - const product = productInfo || durableProduct; + // New payments snapshot normalized grants at invoice creation. Prefer that durable + // promise over the live catalog so a product edit cannot change ticket backing while + // an invoice is awaiting confirmations. Legacy rows with an empty grant object retain + // the historical catalog/description fallback. + const hasDurableGrants = paymentRow.product_grants + && Object.keys(paymentRow.product_grants).length > 0; + const product = hasDurableGrants ? durableProduct : (productInfo || durableProduct); const productGrants = ProductGrants.normalizeProductGrants(product, { credits: fallbackCredits }); const creditsToAdd = productGrants.credits; const productId = product?.id || paymentRow.product_id || paymentType; const grantJson = ProductGrants.serializeProductGrants(productGrants); + const raceEntriesToAdd = productGrants.raceEntries || 0; + const raceEntryValueAtomic = productGrants.raceEntryValueAtomic + ? BigInt(productGrants.raceEntryValueAtomic) + : null; + const backingRequired = raceEntryValueAtomic === null + ? null + : raceEntryValueAtomic * BigInt(raceEntriesToAdd); + let paidAtomic = null; + for (const candidate of [paymentRow.received_amount, receivedAmount]) { + if (candidate == null || candidate === '') continue; + try { + const parsed = BigInt(String(candidate).split('.')[0]); + if (parsed >= 0n && (paidAtomic === null || parsed > paidAtomic)) { + paidAtomic = parsed; + } + } catch (_) { /* try the next trusted source */ } + } + const cryptoTicketFundingRequired = process.env.MATCH_CRYPTO_RACE_ENABLED === 'true' + && process.env.MATCH_PAYOUTS_ENABLED === 'true'; + let configuredMatchEntryFee = null; + try { + const rawEntryFee = String(process.env.MATCH_ENTRY_FEE_ATOMIC ?? '').trim().replace(/_/g, ''); + if (/^\d+$/.test(rawEntryFee)) { + const parsed = BigInt(rawEntryFee); + if (parsed > 0n) configuredMatchEntryFee = parsed; + } + } catch (_) { /* handled by the fail-closed condition below */ } + if (cryptoTicketFundingRequired && raceEntriesToAdd > 0 + && (backingRequired === null + || configuredMatchEntryFee === null + || raceEntryValueAtomic !== configuredMatchEntryFee + || paidAtomic === null + || paidAtomic < backingRequired)) { + throw new Error(`Race-entry product ${productId} lacks confirmed per-ticket payout backing`); + } // CRITICAL: Wrap all three operations in a transaction. // If any step fails, the payment stays 'pending' and can be recovered. const result = await this.db.withTransaction(async (client) => { + // Lock and finalize the durable product promise before inserting append-only + // receipt evidence. New invoices already contain this snapshot; this also gives + // legacy pending invoices one safe, pre-receipt upgrade path. + const snapshotResult = await client.query(` + UPDATE payments + SET product_id = COALESCE(product_id, $2), + product_grants = CASE + WHEN product_grants = '{}'::jsonb THEN $3::jsonb + ELSE product_grants + END + WHERE id = $1 AND status = 'pending' + RETURNING id, status, expected_amount, provider_id, provider_invoice_id, + address_index, product_id, product_grants + `, [paymentId, productId, JSON.stringify(grantJson)]); + if (snapshotResult.rows.length === 0) { + return { alreadyProcessed: true }; + } + const confirmationPayment = { ...paymentRow, ...snapshotResult.rows[0], id: paymentId }; + const receiptEvidence = await this._persistConfirmationReceipts(client, confirmationPayment, receipts); + const durableReceived = receiptEvidence?.total?.toString() || receivedAmount; // Step 1: Atomically mark payment as confirmed (prevents double-crediting) const paymentUpdateResult = await client.query(` UPDATE payments @@ -380,17 +876,34 @@ class GameModeManager { credits_purchased = $1, confirmed_at = NOW(), received_amount = COALESCE($3, received_amount), - product_id = COALESCE(product_id, $4), - product_grants = $5::jsonb + tx_hash = COALESCE($4, tx_hash), + confirmation_evidence_at = CASE WHEN $4::text IS NULL + THEN confirmation_evidence_at ELSE NOW() END WHERE id = $2 AND status = 'pending' RETURNING id - `, [creditsToAdd, paymentId, receivedAmount, productId, JSON.stringify(grantJson)]); + `, [creditsToAdd, paymentId, durableReceived, + receiptEvidence?.primaryTxHash || null]); if (paymentUpdateResult.rows.length === 0) { // Already processed by another instance - not an error return { alreadyProcessed: true }; } + // Preserve the value needed to reverse a payment-specific premium upgrade. + // Lock order matches refund processing: payment first, then user. + const priorUser = await client.query(` + SELECT id, premium_level + FROM users + WHERE id = $1 + FOR UPDATE + `, [userId]); + if (!priorUser.rows[0]) { + throw new Error(`Payment ${paymentId} user no longer exists`); + } + const previousPremiumLevel = priorUser.rows[0].premium_level || 'free'; + const premiumChanged = !!productGrants.premiumLevel + && previousPremiumLevel !== productGrants.premiumLevel; + // Step 2: Add credits / premium tier to user (using payment's user_id, not socket lookup) const updateResult = await client.query(` UPDATE users @@ -412,13 +925,13 @@ class GameModeManager { // Step 3: Record credit transaction for audit trail when credits were granted. if (creditsToAdd > 0) { await client.query(` - INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) - VALUES ($1, $2, 'package_purchase', $3, 'purchase') - `, [userId, creditsToAdd, newBalance]); + INSERT INTO credit_transactions ( + user_id, amount, reason, balance_after, transaction_type, payment_id + ) VALUES ($1, $2, 'package_purchase', $3, 'purchase', $4) + `, [userId, creditsToAdd, newBalance, paymentId]); } // Step 4: Grant race entry tickets if the product includes them. - const raceEntriesToAdd = productGrants.raceEntries || 0; if (raceEntriesToAdd > 0) { const raceEntryUpdate = await client.query(` UPDATE users @@ -426,22 +939,44 @@ class GameModeManager { WHERE id = $2 RETURNING race_entries `, [raceEntriesToAdd, userId]); + if (backingRequired !== null && paidAtomic !== null && paidAtomic >= backingRequired) { + const lotInsert = await client.query(` + INSERT INTO race_entry_lots ( + user_id, payment_id, unit_value_atomic, original_entries, + remaining_entries, product_id + ) VALUES ($1, $2, $3, $4, $4, $5) + ON CONFLICT (payment_id) DO NOTHING + RETURNING id + `, [userId, paymentId, raceEntryValueAtomic.toString(), raceEntriesToAdd, productId]); + if (lotInsert.rowCount !== 1) { + throw new Error(`Race-entry backing lot already exists for payment ${paymentId}`); + } + } await client.query(` INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, payment_id, metadata) VALUES ($1, $2, $3, 'purchase', $4, $5::jsonb) - `, [userId, raceEntriesToAdd, raceEntryUpdate.rows[0].race_entries, paymentId, JSON.stringify({ productId })]); + `, [ + userId, + raceEntriesToAdd, + raceEntryUpdate.rows[0].race_entries, + paymentId, + JSON.stringify({ + productId, + raceEntryValueAtomic: raceEntryValueAtomic?.toString() || null, + backingRequiredAtomic: backingRequired?.toString() || null + }) + ]); } - // Step 5: Grant cosmetic/render packs. Idempotent so retrying a confirmed - // product cannot duplicate entitlements. + // Step 5: Grant cosmetic/render packs. Only newly inserted rows belong to + // this payment; a refund must never delete ownership from an earlier source. + const newlyGrantedPacks = []; for (const pack of productGrants.packs) { - await client.query(` + const packInsert = await client.query(` INSERT INTO user_pack_entitlements (user_id, pack_id, source, expires_at, metadata) VALUES ($1, $2, $3, $4, $5::jsonb) - ON CONFLICT (user_id, pack_id) DO UPDATE - SET source = EXCLUDED.source, - expires_at = EXCLUDED.expires_at, - metadata = user_pack_entitlements.metadata || EXCLUDED.metadata + ON CONFLICT (user_id, pack_id) DO NOTHING + RETURNING pack_id `, [ userId, pack.id, @@ -449,6 +984,40 @@ class GameModeManager { pack.expiresAt || null, JSON.stringify({ productId, paymentId }) ]); + if (packInsert.rows?.[0]?.pack_id) { + newlyGrantedPacks.push({ + id: packInsert.rows[0].pack_id, + source: pack.source || 'product_purchase', + expiresAt: pack.expiresAt || null + }); + } + } + + // Unique payment-scoped grant claim. All balance/entitlement mutations above + // roll back if this marker already exists, so confirmation is exactly once. + const entitlementGrant = await client.query(` + INSERT INTO payment_entitlement_grants ( + payment_id, user_id, source, credits_granted, + purchase_progress_granted, race_entries_granted, packs_granted, + premium_level_granted, premium_level_previous, metadata + ) VALUES ( + $1, $2, 'product_confirmation', $3, $3, $4, $5::jsonb, + $6, $7, $8::jsonb + ) + ON CONFLICT (payment_id) DO NOTHING + RETURNING payment_id + `, [ + paymentId, + userId, + creditsToAdd, + raceEntriesToAdd, + JSON.stringify(newlyGrantedPacks), + premiumChanged ? productGrants.premiumLevel : null, + premiumChanged ? previousPremiumLevel : null, + JSON.stringify({ productId, requestedPacks: productGrants.packs.map(pack => pack.id) }) + ]); + if (entitlementGrant.rowCount === 0) { + throw new Error(`Payment ${paymentId} entitlement grant already exists`); } const grantRows = await client.query(` @@ -493,8 +1062,8 @@ class GameModeManager { } } - async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null, receivedAmount = null) { - return this.processProductPaymentConfirmation(socketId, paymentId, packageInfo, receivedAmount); + async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null, receivedAmount = null, receipts = []) { + return this.processProductPaymentConfirmation(socketId, paymentId, packageInfo, receivedAmount, receipts); } /** @@ -561,7 +1130,11 @@ class GameModeManager { async _findReusablePayment(userId, paymentType, productId = null) { if (!userId) return null; const result = await this.db.query(` - SELECT id, subaddress, expected_amount, payment_type, status, created_at, expires_at, description, product_id, product_grants + SELECT id, subaddress, expected_amount, payment_type, status, created_at, expires_at, + description, product_id, product_grants, + fairness_proof_version, fairness_offer_id, fairness_offer_issued_at, + fairness_commitment, fairness_server_seed, fairness_client_seed, + fairness_bound_at, fairness_consumed_at FROM payments WHERE user_id = $1 AND payment_type = $2 @@ -571,7 +1144,12 @@ class GameModeManager { ORDER BY created_at DESC LIMIT 1 `, [userId, paymentType, productId || null]); - return result.rows[0] || null; + const row = result.rows[0] || null; + if (row && paymentType === 'single_game' && this._requiresPaidFairnessV2() + && (!this._paymentFairnessProofFromRow(row) || row.fairness_consumed_at != null)) { + return null; + } + return row; } /** @@ -581,7 +1159,7 @@ class GameModeManager { if (!packageInfo) return null; return { ...packageInfo, - price: typeof packageInfo.price === 'bigint' ? Number(packageInfo.price) : packageInfo.price, + price: packageInfo.price == null ? packageInfo.price : money.toSafe(money.toBig(packageInfo.price)), grants: ProductGrants.publicGrantSummary(ProductGrants.normalizeProductGrants(packageInfo, { credits: Number(packageInfo.credits || 0) + Number(packageInfo.bonus || 0) })) @@ -590,7 +1168,7 @@ class GameModeManager { _mapPaymentRowToRequest(row, paymentType, packageInfo) { if (!row) return null; - const amount = Number(row.expected_amount); + const amount = money.toSafe(money.toBig(row.expected_amount)); return { id: row.id, address: row.subaddress, @@ -603,6 +1181,9 @@ class GameModeManager { productId: row.product_id || null, grants: row.product_grants || null, package: this._serializePackageInfo(packageInfo), + fairnessProof: paymentType === 'single_game' + ? this._paymentFairnessProofFromRow(row) + : null, reused: true }; } @@ -616,7 +1197,8 @@ class GameModeManager { console.log(`🎯 Credits per game cost: ${this.creditsPerGameCost}`); console.log(`🧮 Payout multipliers - direct: ${JSON.stringify(this.directPayoutMultipliers)}, credits: ${JSON.stringify(this.creditPayoutMultipliers)}`); console.log(`🔁 Mode availability - direct: ${this.directModeEnabled}, credits: ${this.creditsModeEnabled}, preferCreditsFirst: ${this.preferCreditsFirst}`); - if (this.creditsPayoutEnabled) { + console.log(`🛡️ Payout master switch: ${this.payoutsEnabled ? 'ENABLED' : 'DISABLED'}`); + if (this.isPayoutEnabledForMode('PAID_CREDITS')) { console.log(`🎁 Credits payout mode ENABLED - base value: ${this.creditsPayoutBaseValue} atomic (~${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`); } console.log(`⚙️ Payments enabled: ${this.paymentsEnabled}`); @@ -668,8 +1250,8 @@ class GameModeManager { creditsRequired: this.creditsPerGameCost, bothModesEnabled, preferCreditsFirst: this.preferCreditsFirst, - creditsPayoutsEnabled: this.creditsPayoutEnabled, - directPayoutsEnabled: this.directPayoutEnabled && this.directPayoutMultipliers.escape > 0 + creditsPayoutsEnabled: this.isPayoutEnabledForMode('PAID_CREDITS'), + directPayoutsEnabled: this.isPayoutEnabledForMode('PAID_SINGLE') }; } @@ -728,7 +1310,7 @@ class GameModeManager { type: 'buy_credits', label: `Buy ${pkg.credits} Credits`, mode: 'PURCHASE', - cost: Number(pkg.price), + cost: money.toSafe(money.toBig(pkg.price)), costDisplay: `${this.formatAtomicHuman(pkg.price, 2)} ${this.cryptoType}`, credits: pkg.credits + (pkg.bonus || 0), payoutEligible: false, @@ -768,10 +1350,11 @@ class GameModeManager { return { allowed: true, reason: 'Free mode' }; } - // Mixed mode: check if user can play with credits OR has confirmed payment + // Mixed mode: mirror _dispatchGameStart's exact precedence so eligibility/address + // checks approve the same mode that will actually be consumed. if (effective.bothModesEnabled) { - // Option 1: User has credits - if (effective.hasCredits) { + // Credits win immediately only when the operator prefers them. + if (effective.hasCredits && this.preferCreditsFirst) { return { allowed: true, reason: `${effective.creditsBalance} credits available`, @@ -785,24 +1368,52 @@ class GameModeManager { // Option 2: User has confirmed single_game payment const pendingPayment = await this.db.query(` SELECT * FROM payments - WHERE socket_id = $1 AND status = 'confirmed' + WHERE user_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS ( SELECT 1 FROM games WHERE games.payment_id = payments.id ) + AND NOT EXISTS ( + SELECT 1 FROM payment_entitlement_grants peg + WHERE peg.payment_id = payments.id + ) + AND NOT EXISTS ( + SELECT 1 FROM credit_transactions ct + WHERE ct.user_id = payments.user_id + AND ct.reason = 'single_game_recovered:' || payments.id + ) + AND ($2::boolean = FALSE OR ( + fairness_bound_at IS NOT NULL + AND fairness_consumed_at IS NULL + AND fairness_proof_version = 2 + )) ORDER BY confirmed_at DESC LIMIT 1 - `, [socketId]); + `, [user.id, this._requiresPaidFairnessV2()]); if (pendingPayment.rows.length > 0) { return { allowed: true, reason: 'Payment confirmed', paymentId: pendingPayment.rows[0].id, + fairnessProof: this._paymentFairnessProofFromRow(pendingPayment.rows[0]), effectiveMode: 'PAID_SINGLE' }; } + + // With no direct entry to claim, credits remain the fallback even when direct + // is preferred. + if (effective.hasCredits) { + return { + allowed: true, + reason: `${effective.creditsBalance} credits available`, + useCredits: true, + creditsRequired: this.creditsPerGameCost, + balance: effective.creditsBalance, + effectiveMode: 'PAID_CREDITS' + }; + } // Neither credits nor payment available return { @@ -835,21 +1446,36 @@ class GameModeManager { case 'PAID_SINGLE': const payment = await this.db.query(` SELECT * FROM payments - WHERE socket_id = $1 AND status = 'confirmed' + WHERE user_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS ( SELECT 1 FROM games WHERE games.payment_id = payments.id ) + AND NOT EXISTS ( + SELECT 1 FROM payment_entitlement_grants peg + WHERE peg.payment_id = payments.id + ) + AND NOT EXISTS ( + SELECT 1 FROM credit_transactions ct + WHERE ct.user_id = payments.user_id + AND ct.reason = 'single_game_recovered:' || payments.id + ) + AND ($2::boolean = FALSE OR ( + fairness_bound_at IS NOT NULL + AND fairness_consumed_at IS NULL + AND fairness_proof_version = 2 + )) ORDER BY confirmed_at DESC LIMIT 1 - `, [socketId]); + `, [user.id, this._requiresPaidFairnessV2()]); if (payment.rows.length > 0) { return { allowed: true, reason: 'Payment confirmed', paymentId: payment.rows[0].id, + fairnessProof: this._paymentFairnessProofFromRow(payment.rows[0]), effectiveMode: 'PAID_SINGLE' }; } @@ -871,21 +1497,12 @@ class GameModeManager { } /** - * Process game start (deduct credits or link payment). - * - * C7: `options` is the optional trailing opts bag and may carry a `clientSeed`. The - * dungeon's effective RNG seed is derived at game CREATION via - * provablyFair.deriveSeed(serverSeed, clientSeed); this start entry point accepts and - * forwards the (already-validated) client seed so the flow stays in sync and the value - * is surfaced on the start result. Non-breaking: clientSeed is optional (defaults to ''). + * Process game start (deduct credits or link payment). Fairness input is deliberately not + * accepted here: the one-time offer/client seed must already have been consumed by + * SocketHandlers and passed into Game before its dungeon is generated. */ async processGameStart(socketId, gameId, options = {}) { - const clientSeed = typeof options.clientSeed === 'string' ? options.clientSeed : ''; - const result = await this._dispatchGameStart(socketId, gameId, options); - if (result && typeof result === 'object' && clientSeed && result.clientSeed === undefined) { - result.clientSeed = clientSeed; - } - return result; + return this._dispatchGameStart(socketId, gameId, options); } async _dispatchGameStart(socketId, gameId, options = {}) { @@ -905,6 +1522,9 @@ class GameModeManager { if (effective.mode === 'FREE') { return { success: true, effectiveMode: 'FREE' }; } + if (!this._isFinancialAdmissionAllowed()) { + return this._financialRecoveryPendingResult(); + } // Mixed mode: determine which method to use if (effective.bothModesEnabled) { @@ -916,15 +1536,29 @@ class GameModeManager { // Check for confirmed direct payment const payment = await this.db.query(` SELECT * FROM payments - WHERE socket_id = $1 AND status = 'confirmed' + WHERE user_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS ( SELECT 1 FROM games WHERE games.payment_id = payments.id ) + AND NOT EXISTS ( + SELECT 1 FROM payment_entitlement_grants peg + WHERE peg.payment_id = payments.id + ) + AND NOT EXISTS ( + SELECT 1 FROM credit_transactions ct + WHERE ct.user_id = payments.user_id + AND ct.reason = 'single_game_recovered:' || payments.id + ) + AND ($2::boolean = FALSE OR ( + fairness_bound_at IS NOT NULL + AND fairness_consumed_at IS NULL + AND fairness_proof_version = 2 + )) ORDER BY confirmed_at DESC LIMIT 1 - `, [socketId]); + `, [user.id, this._requiresPaidFairnessV2()]); if (payment.rows.length > 0) { return await this._processGameStartWithPayment(user, payment.rows[0], gameId); @@ -946,15 +1580,29 @@ class GameModeManager { case 'PAID_SINGLE': const payment = await this.db.query(` SELECT * FROM payments - WHERE socket_id = $1 AND status = 'confirmed' + WHERE user_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS ( SELECT 1 FROM games WHERE games.payment_id = payments.id ) + AND NOT EXISTS ( + SELECT 1 FROM payment_entitlement_grants peg + WHERE peg.payment_id = payments.id + ) + AND NOT EXISTS ( + SELECT 1 FROM credit_transactions ct + WHERE ct.user_id = payments.user_id + AND ct.reason = 'single_game_recovered:' || payments.id + ) + AND ($2::boolean = FALSE OR ( + fairness_bound_at IS NOT NULL + AND fairness_consumed_at IS NULL + AND fairness_proof_version = 2 + )) ORDER BY confirmed_at DESC LIMIT 1 - `, [socketId]); + `, [user.id, this._requiresPaidFairnessV2()]); if (payment.rows.length === 0) { return { success: false, reason: 'No valid payment found' }; @@ -978,14 +1626,89 @@ class GameModeManager { * strings (for the BIGINT columns) plus the multipliers for the audit trail. */ _computePayoutSnapshot(mode) { - const escape = this.calculatePayout(mode, { treasureFound: false }); - const treasure = this.calculatePayout(mode, { treasureFound: true }); - return { - escapeAmount: String(escape.amount), - treasureAmount: String(treasure.amount), + const escape = this._effectivePayoutOutcome(mode, false); + const treasure = this._effectivePayoutOutcome(mode, true); + const bounds = this._payoutBoundsForMode(mode); + const eligible = this.isPayoutEnabledForMode(mode); + const snapshot = { + // These are exact payable amounts after applying the configured bounds. Settlement, + // admission accounting, and the browser all consume the same contract. + escapeAmount: escape.amountAtomic, + treasureAmount: treasure.amountAtomic, escapeMult: escape.multiplier, - treasureMult: treasure.multiplier + treasureMult: treasure.multiplier, + eligible }; + snapshot.terms = { + version: 2, + mode: String(mode || '').toUpperCase(), + eligible, + currency: this.cryptoType, + currencyDecimals: this.currencyDecimals, + escapeAmount: snapshot.escapeAmount, + treasureAmount: snapshot.treasureAmount, + escapeMultiplier: snapshot.escapeMult, + treasureMultiplier: snapshot.treasureMult, + minAmount: bounds?.min == null ? null : String(bounds.min), + maxAmount: bounds?.max == null ? null : String(bounds.max), + outcomes: { + escape, + escapeWithTreasure: treasure + } + }; + return snapshot; + } + + /** + * One exact, user-visible payout outcome. Raw multiplier math is retained for audit, while + * amountAtomic is what can actually be committed and paid after min/max policy. + */ + _effectivePayoutOutcome(mode, treasureFound) { + const normalizedMode = String(mode || '').toUpperCase(); + const calculated = this.calculatePayout(normalizedMode, { treasureFound }); + const rawAmount = money.toBig(calculated.amount); + const bounds = this._payoutBoundsForMode(normalizedMode); + const eligible = this.isPayoutEnabledForMode(normalizedMode); + let effective = eligible ? rawAmount : 0n; + let capApplied = false; + let suppressedReason = eligible ? null : 'payouts_disabled'; + + if (eligible && bounds?.max !== null && bounds?.max !== undefined && effective > bounds.max) { + effective = bounds.max; + capApplied = true; + } + if (eligible && (effective <= 0n + || (bounds?.min !== null && bounds?.min !== undefined && effective < bounds.min))) { + effective = 0n; + suppressedReason = 'below_minimum'; + } + + const decimals = Number.isInteger(this.currencyDecimals) && this.currencyDecimals >= 0 + ? this.currencyDecimals + : inferCurrencyDecimalsForSymbol(this.cryptoType); + return { + payable: eligible && effective > 0n, + amountAtomic: effective.toString(), + amountFormatted: money.format(effective, decimals), + rawAmountAtomic: rawAmount.toString(), + multiplier: calculated.multiplier, + capApplied, + suppressedReason + }; + } + + _publicPayoutOutcomes(mode) { + return { + escape: this._effectivePayoutOutcome(mode, false), + escapeWithTreasure: this._effectivePayoutOutcome(mode, true) + }; + } + + _snapshotMaxLiability(snapshot) { + if (!snapshot?.eligible) return 0n; + const escape = money.toBig(snapshot.escapeAmount || 0); + const treasure = money.toBig(snapshot.treasureAmount || 0); + return escape > treasure ? escape : treasure; } /** @@ -995,7 +1718,16 @@ class GameModeManager { */ async _processGameStartFree(user, gameId) { await this.db.withTransaction(async (client) => { - await client.query(`UPDATE games SET game_mode = 'FREE' WHERE dungeon_seed = $1`, [gameId]); + const gameUpdate = await client.query(` + UPDATE games SET game_mode = 'FREE', payout_eligible = FALSE, + payout_terms = $2::jsonb, payout_committed_at = NOW(), + entry_consumed_at = NOW(), entry_credits_spent = NULL + WHERE dungeon_seed = $1 + RETURNING id + `, [gameId, JSON.stringify({ version: 1, mode: 'FREE', eligible: false })]); + if (gameUpdate.rowCount === 0) { + throw Object.assign(new Error('Durable game row not found'), { code: 'GAME_ROW_REQUIRED' }); + } await client.query(` UPDATE users SET total_games_played = total_games_played + 1, updated_at = NOW() WHERE id = $1 `, [user.id]); @@ -1004,10 +1736,25 @@ class GameModeManager { } async _processGameStartWithCredits(user, socketId, gameId) { + if (!this._isFinancialAdmissionAllowed()) { + return this._financialRecoveryPendingResult(); + } const creditsToSpend = this.creditsPerGameCost; const snap = this._computePayoutSnapshot('PAID_CREDITS'); + const requiresAddress = this.requiresPayoutAddressForMode('PAID_CREDITS'); + if (requiresAddress && (typeof user.payout_address !== 'string' || !user.payout_address.trim())) { + return { success: false, reason: 'A payout address is required before starting this game.' }; + } const result = await this.db.withTransaction(async (client) => { + if (snap.eligible) { + await reservePayoutCapacity({ + client, + walletService: this.walletService, + newLiability: this._snapshotMaxLiability(snap), + gameModeManager: this + }); + } // Conditional update prevents credits going negative const updateRes = await client.query(` UPDATE users @@ -1015,22 +1762,30 @@ class GameModeManager { total_games_played = total_games_played + 1, updated_at = NOW() WHERE id = $2 AND credits >= $1 - RETURNING credits, total_credits_purchased - `, [creditsToSpend, user.id]); + AND ($3::boolean = FALSE OR NULLIF(BTRIM(payout_address), '') IS NOT NULL) + RETURNING credits, total_credits_purchased, payout_address + `, [creditsToSpend, user.id, requiresAddress]); if (updateRes.rows.length === 0) { - return { insufficient: true }; + return { admissionDenied: true }; } const remainingCredits = updateRes.rows[0].credits; const totalCreditsPurchased = updateRes.rows[0].total_credits_purchased || user.total_credits_purchased || 0; - await client.query(` + const gameUpdate = await client.query(` UPDATE games SET game_mode = 'PAID_CREDITS', payout_address = $2, payout_escape_amount = $3, payout_treasure_amount = $4, - payout_escape_mult = $5, payout_treasure_mult = $6 + payout_escape_mult = $5, payout_treasure_mult = $6, + payout_eligible = $7, payout_terms = $8::jsonb, + payout_committed_at = NOW(), entry_consumed_at = NOW(), + entry_credits_spent = $9 WHERE dungeon_seed = $1 - `, [gameId, user.payout_address || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult]); + RETURNING id + `, [gameId, updateRes.rows[0].payout_address || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult, snap.eligible, JSON.stringify(snap.terms), creditsToSpend]); + if (gameUpdate.rowCount === 0) { + throw Object.assign(new Error('Durable game row not found'), { code: 'GAME_ROW_REQUIRED' }); + } await client.query(` INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) @@ -1040,10 +1795,12 @@ class GameModeManager { return { remainingCredits, totalCreditsPurchased }; }); - if (result.insufficient) { + if (result.admissionDenied) { return { success: false, - reason: 'Insufficient credits', + reason: requiresAddress + ? 'Insufficient credits or payout address is missing' + : 'Insufficient credits', creditsRequired: creditsToSpend, creditsAvailable: user.credits }; @@ -1061,6 +1818,9 @@ class GameModeManager { } async _processGameStartWithPayment(user, payment, gameId) { + if (!this._isFinancialAdmissionAllowed()) { + return this._financialRecoveryPendingResult(); + } const snap = this._computePayoutSnapshot('PAID_SINGLE'); // M3: claiming a confirmed single_game payment must be atomic. Re-verify the payment // is still unclaimed and lock it FOR UPDATE inside the transaction, so two concurrent @@ -1068,12 +1828,35 @@ class GameModeManager { // (migration 023) is the final backstop; a 23505 there is treated as "already consumed". try { const result = await this.db.withTransaction(async (client) => { + if (snap.eligible) { + await reservePayoutCapacity({ + client, + walletService: this.walletService, + newLiability: this._snapshotMaxLiability(snap), + gameModeManager: this + }); + } const claim = await client.query(` - SELECT id FROM payments - WHERE id = $1 AND status = 'confirmed' AND payment_type = 'single_game' + SELECT * FROM payments + WHERE id = $1 AND user_id = $2 + AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS (SELECT 1 FROM games WHERE games.payment_id = payments.id) + AND NOT EXISTS ( + SELECT 1 FROM payment_entitlement_grants peg + WHERE peg.payment_id = payments.id + ) + AND NOT EXISTS ( + SELECT 1 FROM credit_transactions ct + WHERE ct.user_id = payments.user_id + AND ct.reason = 'single_game_recovered:' || payments.id + ) + AND ($3::boolean = FALSE OR ( + fairness_bound_at IS NOT NULL + AND fairness_consumed_at IS NULL + AND fairness_proof_version = 2 + )) FOR UPDATE - `, [payment.id]); + `, [payment.id, user.id, this._requiresPaidFairnessV2()]); if (claim.rows.length === 0) { // Locked+re-checked: a concurrent start already linked this payment (or it is @@ -1081,13 +1864,76 @@ class GameModeManager { return { alreadyConsumed: true }; } - await client.query(` + const claimedPayment = claim.rows[0]; + const payoutUser = await client.query(` + SELECT id, payout_address + FROM users + WHERE id = $1 + FOR UPDATE + `, [user.id]); + const lockedPayoutAddress = typeof payoutUser.rows[0]?.payout_address === 'string' + ? payoutUser.rows[0].payout_address.trim() + : ''; + if (this.requiresPayoutAddressForMode('PAID_SINGLE') && !lockedPayoutAddress) { + return { addressRequired: true }; + } + const boundProof = this._paymentFairnessProofFromRow(claimedPayment); + if (this._requiresPaidFairnessV2() && !boundProof) { + return { alreadyConsumed: true }; + } + + if (boundProof) { + const gameProof = await client.query(` + SELECT proof_version, fairness_offer_id, fairness_offer_issued_at, + proof_commitment, server_seed, client_seed + FROM games + WHERE dungeon_seed = $1 + FOR UPDATE + `, [gameId]); + const persisted = gameProof.rows[0]; + const issuedAt = persisted?.fairness_offer_issued_at instanceof Date + ? persisted.fairness_offer_issued_at.getTime() + : Date.parse(persisted?.fairness_offer_issued_at); + if (!persisted + || Number(persisted.proof_version) !== 2 + || persisted.fairness_offer_id !== boundProof.offerId + || String(persisted.proof_commitment || '').toLowerCase() !== boundProof.commitment + || String(persisted.server_seed || '').toLowerCase() !== boundProof.serverSeed + || String(persisted.client_seed || '').toLowerCase() !== boundProof.clientSeed + || issuedAt !== boundProof.offerIssuedAt) { + throw Object.assign(new Error('Game fairness proof does not match the paid invoice binding'), { + code: 'PAYMENT_FAIRNESS_MISMATCH' + }); + } + } + + const gameUpdate = await client.query(` UPDATE games SET payment_id = $1, game_mode = 'PAID_SINGLE', payout_address = $3, payout_escape_amount = $4, payout_treasure_amount = $5, - payout_escape_mult = $6, payout_treasure_mult = $7 + payout_escape_mult = $6, payout_treasure_mult = $7, + payout_eligible = $8, payout_terms = $9::jsonb, + payout_committed_at = NOW(), entry_consumed_at = NOW() WHERE dungeon_seed = $2 - `, [payment.id, gameId, user.payout_address || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult]); + RETURNING id + `, [payment.id, gameId, lockedPayoutAddress || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult, snap.eligible, JSON.stringify(snap.terms)]); + if (gameUpdate.rowCount === 0) { + throw Object.assign(new Error('Durable game row not found'), { code: 'GAME_ROW_REQUIRED' }); + } + + if (boundProof) { + const consumed = await client.query(` + UPDATE payments + SET fairness_consumed_at = NOW() + WHERE id = $1 AND fairness_consumed_at IS NULL + RETURNING id + `, [payment.id]); + if (consumed.rowCount !== 1) { + throw Object.assign(new Error('Payment fairness offer was already consumed'), { + code: 'PAYMENT_FAIRNESS_CONSUMED' + }); + } + } await client.query(` UPDATE users @@ -1103,6 +1949,9 @@ class GameModeManager { console.warn(`⚠️ Payment ${payment.id} already consumed by a concurrent start; aborting duplicate for game ${gameId}.`); return { success: false, reason: 'Payment already consumed', alreadyConsumed: true }; } + if (result.addressRequired) { + return { success: false, reason: 'A payout address is required before starting this game.' }; + } console.log(`Linked game ${gameId} to payment ${payment.id}`); return { @@ -1129,6 +1978,7 @@ class GameModeManager { */ async createPaymentRequest(socketId, paymentType, options = {}) { try { + this._assertPaymentIntakeEnabled(paymentType); // Prefer session-resolved userId (stable across reconnects) over socket_id lookup let user; if (options.userId) { @@ -1145,12 +1995,16 @@ class GameModeManager { let packageInfo = null; let productId = null; let productGrants = ProductGrants.normalizeProductGrants({}, { credits: 0 }); + let paymentFairnessProof = null; switch (paymentType) { case 'single_game': { amount = this.singleGamePrice; description = `${this.gameName} single game entry (${this.currencyLabel})`; productId = 'single_game'; + paymentFairnessProof = this._normalizePaymentFairnessProof(options.fairnessProof, { + required: this._requiresPaidFairnessV2() + }); break; } case 'credits_package': { @@ -1168,7 +2022,7 @@ class GameModeManager { } const packagePrice = selectedPackage?.price ?? this.creditsPackagePrice; - amount = typeof packagePrice === 'bigint' ? Number(packagePrice) : Number(packagePrice); + amount = money.toSafe(money.toBig(packagePrice)); packageInfo = selectedPackage; productId = selectedPackage?.id || 'credits_package'; const creditCount = selectedPackage?.credits ?? 10; @@ -1188,7 +2042,7 @@ class GameModeManager { }); } const price = product.price ?? 0; - amount = typeof price === 'bigint' ? Number(price) : Number(price); + amount = money.toSafe(money.toBig(price)); packageInfo = product; productId = product.id; productGrants = ProductGrants.normalizeProductGrants(product, { credits: 0 }); @@ -1251,8 +2105,19 @@ class GameModeManager { // Store payment info in database (address_index enables monitoring restoration after restart) const insertResult = await this.db.query(` - INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, description, created_at, expires_at, address_index, product_id, product_grants) - VALUES ($1, $2, $3, $4, $5, 'pending', $6, NOW(), $7, $8, $9, $10::jsonb) + INSERT INTO payments ( + user_id, socket_id, subaddress, expected_amount, payment_type, status, + description, created_at, expires_at, address_index, product_id, product_grants, + fairness_proof_version, fairness_offer_id, fairness_offer_issued_at, + fairness_commitment, fairness_server_seed, fairness_client_seed, fairness_bound_at, + provider_id, provider_invoice_id + ) + VALUES ( + $1, $2, $3, $4, $5, 'pending', $6, NOW(), $7, $8, $9, $10::jsonb, + $11, $12::varchar(64), $13, $14, $15, $16, + CASE WHEN $12::varchar(64) IS NULL THEN NULL ELSE NOW() END, + $17, $18 + ) RETURNING id, expires_at `, [ user.id, @@ -1264,7 +2129,15 @@ class GameModeManager { expiresAt, paymentResult.addressIndex ?? null, productId, - JSON.stringify(ProductGrants.serializeProductGrants(productGrants)) + JSON.stringify(ProductGrants.serializeProductGrants(productGrants)), + paymentFairnessProof?.proofVersion || null, + paymentFairnessProof?.offerId || null, + paymentFairnessProof ? new Date(paymentFairnessProof.offerIssuedAt) : null, + paymentFairnessProof?.commitment || null, + paymentFairnessProof?.serverSeed || null, + paymentFairnessProof?.clientSeed ?? null, + provider?.id || 'native-monero', + paymentResult.invoiceId || paymentResult.address ]); const insertedRow = insertResult.rows[0]; @@ -1273,7 +2146,7 @@ class GameModeManager { id: insertedRow?.id, address: paymentResult.address, invoiceId: paymentResult.invoiceId || paymentResult.address, - amount: amount, + amount: money.toSafe(money.toBig(amount)), amountFormatted: this.formatAtomicHuman(amount, 4), currency: this.cryptoType, expiresAt: insertedRow?.expires_at || expiresAt, @@ -1281,6 +2154,7 @@ class GameModeManager { paymentType, productId, grants: ProductGrants.publicGrantSummary(productGrants), + fairnessProof: paymentFairnessProof, description, reused: false, expiredAddresses @@ -1369,6 +2243,23 @@ class GameModeManager { /** * Set user payout address */ + async validatePayoutAddress(payoutAddress) { + const address = typeof payoutAddress === 'string' ? payoutAddress.trim() : ''; + if (!address) return { valid: false, error: 'Address is empty' }; + + // Payouts are sent by the native wallet service even when invoice creation is routed + // through a checkout gateway, so its network-aware validator is authoritative. + if (this.walletService && typeof this.walletService.validateAddress === 'function') { + return this.walletService.validateAddress(address); + } + + const provider = this.paymentProviders?.getProvider?.(this.cryptoType); + if (provider && typeof provider.validateAddress === 'function') { + return provider.validateAddress(this.cryptoType, address); + } + return { valid: false, error: 'No payout-address validator is available' }; + } + async setUserPayoutAddress(socketId, payoutAddress) { try { const user = await this.getOrCreateUser(socketId); @@ -1380,7 +2271,11 @@ class GameModeManager { WHERE id = $2 `, [payoutAddress, user.id]); - console.log(`💰 Set payout address for user ${user.id}: ${payoutAddress}`); + const addressText = typeof payoutAddress === 'string' ? payoutAddress.trim() : ''; + const redactedAddress = addressText.length > 18 + ? `${addressText.slice(0, 10)}…${addressText.slice(-6)}` + : (addressText ? '[redacted]' : '[cleared]'); + console.log(`💰 Set payout address for user ${user.id}: ${redactedAddress}`); // Reconcile any claimable match winnings this user earned with no address on file. // A match winner without a payout address is recorded as a durable 'needs_review' @@ -1396,12 +2291,12 @@ class GameModeManager { SET payout_address = $1, status = 'pending', last_error = NULL WHERE user_id = $2 AND status = 'needs_review' - AND reason = 'match_winner_no_address' + AND reason IN ('match_winner_no_address', 'solo_winner_no_address') AND payout_address = 'PENDING_NO_ADDRESS' RETURNING id `, [payoutAddress.trim(), user.id]); if (reconciled.rowCount > 0) { - console.log(`💰 Reconciled ${reconciled.rowCount} claimable match payout(s) for user ${user.id} now that an address is set`); + console.log(`💰 Reconciled ${reconciled.rowCount} claimable payout(s) for user ${user.id} now that an address is set`); if (typeof this._scheduleBatchPayout === 'function') this._scheduleBatchPayout(); } } catch (reconErr) { @@ -1454,7 +2349,9 @@ class GameModeManager { */ getGameModeInfo() { // Debug: Log credits payout base value being sent - console.log(`📤 getGameModeInfo: creditsPayoutBaseValue = ${this.creditsPayoutBaseValue} (${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`); + if (this.debugManager?.CONSOLE_LOGGING) { + console.log(`📤 getGameModeInfo: creditsPayoutBaseValue = ${this.creditsPayoutBaseValue} (${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`); + } // Determine if we should show a testnet warning // Only for XMR on stagenet/testnet - WOW only has mainnet @@ -1463,29 +2360,29 @@ class GameModeManager { ? `⚠️ STAGENET MODE: This server is using ${this.network} XMR. Do NOT send real mainnet XMR! Only ${this.network} XMR will be accepted.` : null; - // Serialize all credit packages (convert BigInt prices to Number) + // Serialize atomic prices as exact numbers when safe, decimal strings otherwise. const packages = this.configSnapshot?.modes?.credits?.packages || []; const creditPackages = packages.map(pkg => ({ id: pkg.id, label: pkg.label || pkg.id, credits: pkg.credits, - price: typeof pkg.price === 'bigint' ? Number(pkg.price) : pkg.price, + price: money.toSafe(money.toBig(pkg.price)), bonus: pkg.bonus || 0, grants: ProductGrants.publicGrantSummary(ProductGrants.normalizeProductGrants(pkg, { credits: Number(pkg.credits || 0) + Number(pkg.bonus || 0) })), priceFormatted: this.formatAtomicHuman( - typeof pkg.price === 'bigint' ? Number(pkg.price) : pkg.price, + money.toSafe(money.toBig(pkg.price)), 2 ) })); const cosmeticProducts = this.getCosmeticProducts().map(product => ({ id: product.id, label: product.label || product.id, - price: typeof product.price === 'bigint' ? Number(product.price) : product.price, + price: money.toSafe(money.toBig(product.price)), grants: ProductGrants.publicGrantSummary(ProductGrants.normalizeProductGrants(product, { credits: 0 })), priceFormatted: this.formatAtomicHuman( - typeof product.price === 'bigint' ? Number(product.price) : product.price, + money.toSafe(money.toBig(product.price)), 2 ) })); @@ -1510,16 +2407,24 @@ class GameModeManager { freePlayEnabled: this.freePlayEnabled, directModeEnabled: this.directModeEnabled, creditsModeEnabled: this.creditsModeEnabled, - directPayoutsEnabled: this.directPayoutEnabled && this.directPayoutMultipliers.escape > 0, - creditsPayoutsEnabled: this.creditsPayoutEnabled, + payoutsEnabled: this.payoutsEnabled, + directPayoutsEnabled: this.isPayoutEnabledForMode('PAID_SINGLE'), + creditsPayoutsEnabled: this.isPayoutEnabledForMode('PAID_CREDITS'), payoutMultipliers: { - direct: this.directPayoutMultipliers, - credits: this.creditPayoutMultipliers + direct: this.getImplementedPayoutMultipliersForMode('PAID_SINGLE'), + credits: this.getImplementedPayoutMultipliersForMode('PAID_CREDITS') + }, + // Exact server-calculated amounts. The browser must never recreate money promises + // with floating point multiplier arithmetic. + payoutOutcomes: { + direct: this._publicPayoutOutcomes('PAID_SINGLE'), + credits: this._publicPayoutOutcomes('PAID_CREDITS') }, features: { paymentRequired: this.paymentsEnabled, creditsSystem: this.creditsModeEnabled, - payouts: this.directPayoutMultipliers.escape > 0 || this.creditPayoutMultipliers.escape > 0 + payouts: this.isPayoutEnabledForMode('PAID_SINGLE') + || this.isPayoutEnabledForMode('PAID_CREDITS') }, earlyEntry: { enabled: this.configSnapshot?.earlyEntry?.enabled ?? false, @@ -1528,7 +2433,7 @@ class GameModeManager { }, // Smirk wallet integration. Smirk only works on mainnet, so it is forced off on // any test network (stagenet/testnet) regardless of SMIRK_ENABLED. - smirkEnabled: process.env.SMIRK_ENABLED !== 'false' && !this.isTestNetwork, + smirkEnabled: isSmirkEnabled(process.env), explorerTxUrl: process.env.EXPLORER_TX_URL || null, // Which top-level modes this instance offers. Solo (the single-player dungeon) is // on unless explicitly disabled; Tavern and Multiplayer are opt-in. The client uses @@ -1540,7 +2445,8 @@ class GameModeManager { match: { enabled: process.env.MATCH_ENABLED === 'true', economies: this._getMatchEconomies(), - maxPlayers: parseInt(process.env.MATCH_MAX_PLAYERS, 10) || 4 + maxPlayers: parseInt(process.env.MATCH_MAX_PLAYERS, 10) || 4, + ...this._getMatchRulesetInfo() } } }; @@ -1569,6 +2475,56 @@ class GameModeManager { return { min: toBigOrNull(rule.minPayout), max: toBigOrNull(rule.maxPayout) }; } + _soloPayoutCommitment(gameRow, recordedPaymentMode, treasureFound) { + let terms = gameRow.payout_terms || null; + if (typeof terms === 'string') { + try { terms = JSON.parse(terms); } catch (_) { terms = null; } + } + const hasCommittedTerms = !!(terms && Number(terms.version) >= 1); + // New rows carry an immutable boolean. NULL is reserved for games that predate the + // commitment migration and therefore retain the legacy live-policy fallback. + const eligible = typeof gameRow.payout_eligible === 'boolean' + ? gameRow.payout_eligible + : this.isPayoutEnabledForMode(recordedPaymentMode); + if (!eligible) return { eligible: false, terms }; + + let amount; + let multiplier; + if (hasCommittedTerms) { + amount = treasureFound ? terms.treasureAmount : terms.escapeAmount; + multiplier = treasureFound ? terms.treasureMultiplier : terms.escapeMultiplier; + } else if (gameRow.payout_escape_amount != null || gameRow.payout_treasure_amount != null) { + amount = treasureFound ? gameRow.payout_treasure_amount : gameRow.payout_escape_amount; + multiplier = treasureFound ? gameRow.payout_treasure_mult : gameRow.payout_escape_mult; + } else { + const live = this.calculatePayout(recordedPaymentMode, { treasureFound }); + amount = live.amount; + multiplier = live.multiplier; + } + + let amountBig; + try { amountBig = money.toBig(amount); } catch (_) { + throw new Error(`Invalid committed payout amount for game ${gameRow.id}`); + } + const toBigOrNull = (value) => { + if (value === undefined || value === null || value === '') return null; + try { return money.toBig(value); } catch (_) { return null; } + }; + const liveBounds = hasCommittedTerms ? null : this._payoutBoundsForMode(recordedPaymentMode); + const min = hasCommittedTerms ? toBigOrNull(terms.minAmount) : liveBounds?.min ?? null; + const max = hasCommittedTerms ? toBigOrNull(terms.maxAmount) : liveBounds?.max ?? null; + let capped = false; + if (max !== null && amountBig > max) { + amountBig = max; + amount = max.toString(); + capped = true; + } + if (amountBig <= 0n || (min !== null && amountBig < min)) { + return { eligible: true, belowMinimum: true, amount, multiplier, min, max, terms }; + } + return { eligible: true, amount, amountBig, multiplier, min, max, capped, terms }; + } + /** * Complete a game (called from socket handlers when game ends) * @param {string} socketId - Player's socket ID @@ -1578,223 +2534,265 @@ class GameModeManager { * @returns {object} payout / completion info */ async completeGame(socketId, gameId, won, treasureFound, metrics = {}) { - // For FREE mode we just return basic info - if (this.gameMode === 'FREE') { - return { success: true, mode: 'FREE', payout: null, score: metrics.score ?? null }; - } - try { - // Retrieve the game record to get the actual payment_mode used at game start. - // M4/C4: match by dungeon_seed ALONE (the UUID is unique) — never by the mutable - // socket_id — and carry the stable users.id (games.user_id) so the paying user is - // resolved by identity, not by the socket that happened to finish the game. - let recordedPaymentMode = this.gameMode; // fallback to global - let gameDbId = null; - let gameUserId = null; // stable users.id carried on the game row - let lockedPayoutAddress = null; // address locked at game start - let payoutSnapshot = null; // payout terms snapshotted at game start - try { - const gameRecord = await this.db.query(` - SELECT id, user_id, game_mode, payout_address, + const outcome = await this.db.withTransaction(async (client) => { + // Lock by immutable dungeon seed. Completion and any payout obligation are + // committed in this one transaction; neither half can survive alone. + const gameResult = await client.query(` + SELECT id, user_id, game_mode, status, treasure_found, payout_address, + payout_eligible, payout_terms, payout_escape_amount, payout_treasure_amount, payout_escape_mult, payout_treasure_mult FROM games WHERE dungeon_seed = $1 LIMIT 1 + FOR UPDATE `, [gameId]); - if (gameRecord.rows.length > 0) { - gameDbId = gameRecord.rows[0].id; - gameUserId = gameRecord.rows[0].user_id ?? null; - recordedPaymentMode = (gameRecord.rows[0].game_mode || this.gameMode).toUpperCase(); - lockedPayoutAddress = gameRecord.rows[0].payout_address || null; - const r = gameRecord.rows[0]; - if (r.payout_escape_amount != null || r.payout_treasure_amount != null) { - payoutSnapshot = { - escapeAmount: r.payout_escape_amount, - treasureAmount: r.payout_treasure_amount, - escapeMult: r.payout_escape_mult, - treasureMult: r.payout_treasure_mult - }; - } - } - } catch (e) { - // Non-fatal, use fallback - if (process.env.NODE_ENV === 'development') { - console.warn('Game record lookup warning:', e.message); - } - } - - // Update/record game completion in DB - try { - await this.db.query(` - UPDATE games SET status = $1, treasure_found = $2, moves_made = COALESCE($3, moves_made), duration_seconds = COALESCE($4, duration_seconds), completed_at = NOW() - WHERE dungeon_seed = $5 - `, [ - won ? 'won' : 'lost', - treasureFound, - metrics.moves ?? null, - metrics.durationSeconds ?? null, - gameId - ]); - } catch (e) { - // Non-fatal if games table differs during early dev. - if (process.env.NODE_ENV === 'development') { - console.warn('Game completion update warning:', e.message); - } - } - - // Handle payouts based on the actual recorded payment mode, not the current global - // setting. Each mode's payout is gated by its own enable flag, so a no-payout - // instance (DIRECT_PAYOUTS_ENABLED=false / CREDITS_PAYOUTS_ENABLED=false) never sends. - const payoutEligibleStartMode = (recordedPaymentMode === 'PAID_SINGLE' && this.directPayoutEnabled) - || (recordedPaymentMode === 'PAID_CREDITS' && this.creditsPayoutEnabled); - if (payoutEligibleStartMode && won) { - // M4/C4: never insert a payout without a resolved game row — a null game_id - // would defeat the payouts dedup (game_id) guard and could double-pay. - if (!gameDbId) { - console.error(`❌ completeGame: no game row for dungeon_seed ${gameId}; refusing to insert a payout.`); - this._alert('payout_unresolved_game', '⚠️ Payout skipped — game row not found', `

completeGame could not resolve a games row for dungeon_seed ${gameId} (socket ${socketId}). No payout was inserted.

`); - return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Game row not resolved', score: metrics.score ?? null }; + const game = gameResult.rows[0]; + if (!game) { + throw Object.assign(new Error('Durable game row not found'), { code: 'GAME_ROW_REQUIRED' }); } - // Use the payout terms snapshotted at game start (so a mid-game config change - // can't alter an in-flight payout). Fall back to live calculation only for - // older games that predate the snapshot columns. - let payoutAmount; - let multiplier; - if (payoutSnapshot) { - payoutAmount = treasureFound ? payoutSnapshot.treasureAmount : payoutSnapshot.escapeAmount; - multiplier = treasureFound ? payoutSnapshot.treasureMult : payoutSnapshot.escapeMult; - } else { - const live = this.calculatePayout(recordedPaymentMode, { treasureFound }); - payoutAmount = live.amount; - multiplier = live.multiplier; - } - - // M5: clamp/validate the payout against the configured min/max for this mode. - // Over-max -> cap + alert (never send more than policy allows). Under-min -> - // skip entirely (dust not worth an on-chain fee). Bounds are atomic BigInt. - const bounds = this._payoutBoundsForMode(recordedPaymentMode); - if (bounds) { - let amtBig = null; - try { amtBig = money.toBig(payoutAmount); } catch { amtBig = null; } - if (amtBig !== null) { - if (bounds.max !== null && amtBig > bounds.max) { - console.warn(`⚠️ Payout ${amtBig} for game ${gameId} exceeds configured max ${bounds.max}; capping.`); - this._alert('payout_over_max', '⚠️ Payout capped at configured maximum', `

Computed payout ${amtBig} for game ${gameId} exceeded the configured max ${bounds.max} and was capped. Mode: ${recordedPaymentMode}.

`); - amtBig = bounds.max; - payoutAmount = amtBig.toString(); - } - if (bounds.min !== null && amtBig < bounds.min) { - console.log(`Payout ${amtBig} for game ${gameId} below configured min ${bounds.min}; skipping payout.`); - return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Below minimum payout', score: metrics.score ?? null }; - } - } - } - - // Check if payout was already processed for this game (prevent duplicates) - const existingPayout = await this.db.query(` - SELECT id FROM payouts WHERE game_id = $1 AND status IN ('pending', 'processing', 'completed') - `, [gameDbId]); - if (existingPayout.rows.length > 0) { - console.log(`⚠️ Payout already exists for game ${gameId}, skipping duplicate`); - return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Payout already processed', score: metrics.score ?? null }; - } - - // M4/C4: resolve the paying user by the STABLE users.id carried on the game - // row (games.user_id), never by the mutable socket that finished the game. - let userRow = null; - if (gameUserId != null) { - const userResult = await this.db.query(`SELECT * FROM users WHERE id = $1 LIMIT 1`, [gameUserId]); - userRow = userResult.rows[0] || null; - } - if (!userRow) { - console.error(`❌ completeGame: could not resolve paying user (game user_id=${gameUserId}) for game ${gameId}; refusing payout.`); - this._alert('payout_unresolved_user', '⚠️ Payout skipped — paying user not resolved', `

completeGame could not resolve the paying user (games.user_id=${gameUserId}) for game ${gameId}. No payout was inserted.

`); - return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Paying user not resolved', score: metrics.score ?? null }; - } - - // Use payout address locked at game start, fall back to the user's address - const payoutAddress = lockedPayoutAddress || userRow.payout_address; - if (userRow && payoutAddress) { - // Create pending payout record, then schedule batch processing. - // Multiple wins within 5 seconds are batched into one transfer_split call, - // saving outputs and preventing locked-output failures. - try { - const insertResult = await this.db.query(` - INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, created_at) - VALUES ($1, $2, $3, $4, $5, $6, 'pending', NOW()) - RETURNING id - `, [ - userRow.id, - gameDbId, - payoutAddress, - payoutAmount, - multiplier, - treasureFound ? 'escape_with_treasure' : 'escape' - ]); - const payoutId = insertResult.rows[0].id; - - // Schedule batch processing (5-second debounce collects concurrent wins) - this._scheduleBatchPayout(); - + const recordedPaymentMode = (game.game_mode || this.gameMode || 'FREE').toUpperCase(); + const finalStatuses = new Set(['won', 'lost', 'expired']); + const recordedStatus = String(game.status || '').toLowerCase(); + const alreadyFinal = finalStatuses.has(recordedStatus); + if (alreadyFinal) { + const existing = await client.query(`SELECT id, status FROM payouts WHERE game_id = $1 LIMIT 1`, [game.id]); + if (existing.rows.length || recordedStatus !== 'won' || game.payout_eligible !== true) { return { success: true, mode: recordedPaymentMode, - payout: { - status: 'queued', - payoutId, - amount: payoutAmount, - multiplier, - treasure: treasureFound - }, + payout: null, + reason: existing.rows.length ? 'Payout already processed' : 'Game already completed', score: metrics.score ?? null }; - } catch (payoutErr) { - const normalizedPayout = normalizeError(payoutErr, 'Failed to create payout record'); - console.error('❌ Error creating payout record:', normalizedPayout.message); - return { success: true, mode: recordedPaymentMode, payout: null, payoutError: normalizedPayout.safeMessage, score: metrics.score ?? null }; } } - return { success: true, mode: recordedPaymentMode, payout: null, reason: 'No payout address', score: metrics.score ?? null }; - } - // Credits mode: decrement nothing here (already handled start). Optionally could award stats. - return { success: true, mode: recordedPaymentMode, payout: null, score: metrics.score ?? null }; + if (!alreadyFinal) { + await client.query(` + UPDATE games + SET status = $1, + outcome = COALESCE($2, outcome), + treasure_found = $3, + moves_made = COALESCE($4, moves_made), + duration_seconds = COALESCE($5, duration_seconds), + score = COALESCE($6, score), + completed_at = NOW(), + proof_revealed_at = NOW() + WHERE id = $7 + `, [ + won ? 'won' : 'lost', + metrics.outcome || metrics.reason || (won ? 'escaped' : null), + treasureFound, + metrics.moves ?? null, + metrics.durationSeconds ?? null, + metrics.score ?? null, + game.id + ]); + + if (Number(metrics.score) > 0 && game.user_id != null) { + await client.query(` + UPDATE users SET high_score = GREATEST(COALESCE(high_score, 0), $1) + WHERE id = $2 + `, [metrics.score, game.user_id]); + } + } + + const completedAsWin = alreadyFinal ? recordedStatus === 'won' : won; + const completedWithTreasure = alreadyFinal ? !!game.treasure_found : treasureFound; + if (!completedAsWin) { + return { success: true, mode: recordedPaymentMode, payout: null, score: metrics.score ?? null }; + } + + const commitment = this._soloPayoutCommitment(game, recordedPaymentMode, completedWithTreasure); + if (!commitment.eligible) { + return { success: true, mode: recordedPaymentMode, payout: null, score: metrics.score ?? null }; + } + if (commitment.belowMinimum) { + return { + success: true, + mode: recordedPaymentMode, + payout: null, + reason: 'Below minimum payout', + score: metrics.score ?? null + }; + } + + const existing = await client.query(`SELECT id, status FROM payouts WHERE game_id = $1 LIMIT 1`, [game.id]); + if (existing.rows.length > 0) { + return { + success: true, + mode: recordedPaymentMode, + payout: null, + reason: 'Payout already processed', + score: metrics.score ?? null + }; + } + + let user = null; + if (game.user_id != null) { + const userResult = await client.query(`SELECT id, payout_address FROM users WHERE id = $1 LIMIT 1`, [game.user_id]); + user = userResult.rows[0] || null; + } + const lockedAddress = typeof game.payout_address === 'string' ? game.payout_address.trim() : ''; + const fallbackAddress = typeof user?.payout_address === 'string' ? user.payout_address.trim() : ''; + const payoutAddress = lockedAddress || fallbackAddress; + const identityResolved = game.user_id != null && !!user; + const sendable = identityResolved && !!payoutAddress; + const status = sendable ? 'pending' : 'needs_review'; + const reason = !identityResolved + ? 'solo_winner_identity_review' + : (sendable + ? (completedWithTreasure ? 'escape_with_treasure' : 'escape') + : 'solo_winner_no_address'); + const inserted = await client.query(` + INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (game_id) WHERE game_id IS NOT NULL DO NOTHING + RETURNING id + `, [ + game.user_id, + game.id, + payoutAddress || NO_PAYOUT_ADDRESS_SENTINEL, + String(commitment.amount), + commitment.multiplier, + reason, + status + ]); + if (inserted.rows.length === 0) { + return { + success: true, + mode: recordedPaymentMode, + payout: null, + reason: 'Payout already processed', + score: metrics.score ?? null + }; + } + + return { + success: true, + mode: recordedPaymentMode, + payout: { + status: sendable ? 'queued' : 'needs_review', + payoutId: inserted.rows[0].id, + amount: commitment.amount, + multiplier: commitment.multiplier, + treasure: completedWithTreasure + }, + capped: commitment.capped, + needsReview: !sendable, + identityResolved, + score: metrics.score ?? null + }; + }); + + if (outcome.capped) { + console.warn(`⚠️ Committed payout for game ${gameId} exceeded its start-time cap and was capped.`); + this._alert('payout_over_max', '⚠️ Payout capped at committed maximum', `

The committed payout for game ${gameId} was capped at its start-time maximum.

`); + } + if (outcome.needsReview) { + this._alert('payout_liability_needs_review', '⚠️ Solo payout liability needs review', `

Game ${gameId} completed with a payout liability that cannot be dispatched automatically.

`); + } + if (outcome.payout?.status === 'queued') this._scheduleBatchPayout(); + return outcome; } catch (err) { const normalized = normalizeError(err, 'Failed to complete game'); console.error('❌ completeGame error:', normalized.message); - return { success: false, error: normalized.safeMessage }; + if (err?.code === 'GAME_ROW_REQUIRED') { + this._alert('payout_unresolved_game', '⚠️ Game completion not committed', `

No durable games row exists for dungeon seed ${gameId} (socket ${socketId}). Completion and payout were not committed.

`); + } + return { success: false, error: normalized.safeMessage, reason: err?.code === 'GAME_ROW_REQUIRED' ? 'Game row not resolved' : undefined }; } } // ---- Batch Payout Processing ---- + /** + * Repair historical/legacy half-completions that already carry an explicit committed + * liability. New completions create the row atomically; this scan is the crash/migration + * reconciliation path and deliberately ignores NULL/false eligibility. + */ + async reconcileCompletedSoloLiabilities({ limit = 100 } = {}) { + if (!this.db || this._isReconcilingSoloLiabilities) return { scanned: 0, created: 0, failed: 0 }; + this._isReconcilingSoloLiabilities = true; + try { + const candidates = await this.db.query(` + SELECT g.dungeon_seed, g.treasure_found + FROM games g + WHERE g.status = 'won' + AND g.payout_eligible = TRUE + AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.game_id = g.id) + ORDER BY g.completed_at ASC NULLS LAST, g.id ASC + LIMIT $1 + `, [Math.max(1, Math.min(1000, parseInt(limit, 10) || 100))]); + let created = 0; + let failed = 0; + for (const game of candidates.rows || []) { + const result = await this.completeGame( + 'solo-liability-reconciler', + game.dungeon_seed, + true, + !!game.treasure_found, + { reconcile: true } + ); + if (result?.payout?.payoutId) created += 1; + else if (!result?.success) failed += 1; + } + return { scanned: candidates.rows?.length || 0, created, failed }; + } finally { + this._isReconcilingSoloLiabilities = false; + } + } + /** * Schedule batch payout processing with a short debounce. * Multiple wins within the debounce window (5s) are batched into one transfer. * Also called on new block events as a safety net. */ _scheduleBatchPayout() { + if (!this.payoutsEnabled || this._isShuttingDown) return; + if (typeof this.payoutDispatchAllowed === 'function' && !this.payoutDispatchAllowed()) return; if (this._batchPayoutTimer) clearTimeout(this._batchPayoutTimer); this._batchPayoutTimer = setTimeout(() => { + this._batchPayoutTimer = null; + if (this._isShuttingDown) return; + if (typeof this.payoutDispatchAllowed === 'function' && !this.payoutDispatchAllowed()) return; this._processPendingPayouts().catch(err => { console.error('❌ Batch payout processing error:', err.message); }); }, 5000); } + /** Stop accepting scheduled payout work and cancel any debounce that has not fired yet. */ + shutdown() { + this._isShuttingDown = true; + if (this._batchPayoutTimer) { + clearTimeout(this._batchPayoutTimer); + this._batchPayoutTimer = null; + } + } + /** * Process all pending payouts that haven't been sent yet. * Batches them into a single transfer_split call for efficiency. * Called by debounce timer and on new block events. */ async _processPendingPayouts() { + // Emergency/operator kill switch. Return before locking or changing any rows so pending + // liabilities remain intact and can be resumed deliberately when payouts are re-enabled. + if (!this.payoutsEnabled || this._isShuttingDown) return; + if (typeof this.payoutDispatchAllowed === 'function' && !this.payoutDispatchAllowed()) return; // Prevent concurrent batch processing if (this._isBatchProcessing) return; this._isBatchProcessing = true; try { + // Reconcile any explicit committed wins left by an older deployment before + // dispatching pending rows. The kill switch above still prevents any wallet send. + await this.reconcileCompletedSoloLiabilities({ limit: 100 }); + // Gather pending payouts inside a transaction with row-level locks. // FOR UPDATE SKIP LOCKED prevents the retry service from grabbing the same rows. const pending = await this.db.withTransaction(async (client) => { @@ -1809,7 +2807,7 @@ class GameModeManager { if (result.rows.length > 0) { const ids = result.rows.map(r => r.id); await client.query( - `UPDATE payouts SET status = 'processing' WHERE id = ANY($1)`, + `UPDATE payouts SET status = 'processing', last_retry_at = NOW() WHERE id = ANY($1)`, [ids] ); } @@ -1827,8 +2825,11 @@ class GameModeManager { try { const totalNeeded = money.sum(pending.rows.map(p => p.amount)); // BigInt atomic const bal = await this.walletService.getBalance(); - // unlocked_balance arrives as a JS number from the RPC; coerce to integer BigInt. - const unlocked = (bal && !bal.error) ? BigInt(Math.round(Number(bal.unlocked_balance) || 0)) : null; + // Keep wallet atomic units exact even above Number.MAX_SAFE_INTEGER. + const unlocked = (bal && !bal.error) ? money.toBig(bal.unlocked_balance) : null; + if (unlocked === null) { + throw new Error('Wallet returned no authoritative unlocked balance'); + } if (unlocked !== null && unlocked < totalNeeded) { const ids = pending.rows.map(p => p.id); await this.db.query( @@ -1839,8 +2840,30 @@ class GameModeManager { return; } } catch (balErr) { - // If the balance check itself fails, fall through and let the transfer try. - console.error('Payout balance pre-check failed, proceeding:', balErr.message); + // A failed reserve/liveness probe is not permission to attempt a transfer. Keep + // the liabilities pending; a later healthy worker can re-evaluate them without an + // ambiguous broadcast outcome. + const ids = pending.rows.map(p => p.id); + await this.db.query( + `UPDATE payouts SET status = 'pending', last_error = $2, last_retry_at = NOW() + WHERE id = ANY($1) AND status = 'processing'`, + [ids, 'Deferred: wallet unlocked balance could not be verified'] + ).catch(() => {}); + console.error('Payout balance pre-check failed; batch deferred:', balErr.message); + return; + } + + // The operator/health gate may flip while rows are being claimed or the wallet is + // probed. Recheck immediately before the first possible broadcast and release the + // claims without incrementing retries if dispatch is no longer authorized. + if (typeof this.payoutDispatchAllowed === 'function' && !this.payoutDispatchAllowed()) { + const ids = pending.rows.map(p => p.id); + await this.db.query( + `UPDATE payouts SET status = 'pending', last_error = $2, last_retry_at = NOW() + WHERE id = ANY($1) AND status = 'processing'`, + [ids, 'Payout dispatch paused before wallet transfer'] + ).catch(() => {}); + return; } console.log(`📦 Processing batch of ${pending.rows.length} pending payout(s)`); @@ -1857,6 +2880,7 @@ class GameModeManager { if (pending.rows.length === 1) { // Single payout — use processPayout for simpler flow const p = pending.rows[0]; + let observedTxHash = null; try { const result = await this.walletService.processPayout({ userId: p.user_id, @@ -1865,19 +2889,24 @@ class GameModeManager { amount: p.amount, multiplier: p.multiplier }); + const txHash = typeof result?.txHash === 'string' ? result.txHash.trim() : ''; + if (result?.success !== true || !validTxHash(txHash)) { + throw new Error('Wallet did not return explicit success with one valid transaction hash'); + } + observedTxHash = txHash; // Store tx_hash + status + user stats atomically in one transaction await this.db.withTransaction(async (client) => { await client.query( `UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3, status = 'completed', processed_at = NOW() WHERE id = $4`, - [result.txHash || null, result.fee || null, batchId, p.id] + [txHash, result.fee || null, batchId, p.id] ); await client.query( `UPDATE users SET total_amount_won = total_amount_won + $1, total_payouts_received = COALESCE(total_payouts_received, 0) + 1 WHERE id = $2`, [p.amount, p.user_id] ); }); - console.log(`💸 Single payout ${p.id} completed: ${result.txHash}`); + console.log(`💸 Single payout ${p.id} completed: ${txHash}`); } catch (err) { // Insufficient-unlocked-funds is a pre-broadcast error -> retry on the next // batch run (revert to pending). ANY OTHER error is ambiguous: processPayout @@ -1885,12 +2914,18 @@ class GameModeManager { // errored, and this row has a null tx_hash so the retry service's blockchain // guard can't protect it — an auto-retry could DOUBLE-PAY. Mark 'needs_review' // (retry service skips it) + alert, mirroring the batch path below. - const fundsIssue = isInsufficientFundsError(err.message); + // Once a valid hash was observed, broadcast is proven even if the following + // DB transaction failed. Preserve that evidence and never return the row to + // the automatic retry pool. + const fundsIssue = !observedTxHash && isInsufficientFundsError(err); const status = fundsIssue ? 'pending' : 'needs_review'; console.error(`❌ Single payout ${p.id} failed -> ${status}:`, err.message); await this.db.query( - `UPDATE payouts SET status = $1, last_error = $2, last_retry_at = NOW() WHERE id = $3`, - [status, err.message, p.id] + `UPDATE payouts + SET status = $1, last_error = $2, + tx_hash = COALESCE($3, tx_hash), last_retry_at = NOW() + WHERE id = $4`, + [status, String(err.message).slice(0, 500), observedTxHash, p.id] ).catch(() => {}); if (!fundsIssue && this.alertService && typeof this.alertService.sendAlert === 'function') { this.alertService.sendAlert('single_payout_failed', { @@ -1903,14 +2938,24 @@ class GameModeManager { } else { // Multiple payouts — batch via transfer_split with multiple destinations const ids = pending.rows.map(p => p.id); + let observedTxHash = null; try { const result = await this.walletService.processBatchPayout(destinations); - // transfer_split sends ONE on-chain tx to all destinations; every payout - // row in this batch legitimately shares that tx_hash (see migration 015). - const txHash = (result.tx_hash_list && result.tx_hash_list[0]) || null; - const feePerPayout = result.totalFee - ? Math.ceil(result.totalFee / pending.rows.length) + // A shared hash is safe only when the RPC explicitly reports one transaction + // for the complete destination set. Multiple split hashes cannot be mapped to + // individual liabilities from wallet-rpc's response and require review. + const hashes = Array.isArray(result?.tx_hash_list) + ? result.tx_hash_list.map(hash => String(hash || '').trim()) + : []; + if (result?.success !== true || hashes.length !== 1 || !validTxHash(hashes[0])) { + throw new Error('Wallet returned ambiguous batch transaction-hash evidence'); + } + const txHash = hashes[0]; + observedTxHash = txHash; + const feePerPayout = result.totalFee != null + ? money.toSafe((money.toBig(result.totalFee) + BigInt(pending.rows.length) - 1n) + / BigInt(pending.rows.length)) : null; // Mark the ENTIRE batch completed in a SINGLE transaction so we never @@ -1940,12 +2985,15 @@ class GameModeManager { // even though the RPC response errored. Those rows have no tx_hash, so the // retry service's blockchain guard can't protect them and an auto-retry // could DOUBLE-PAY. Mark 'needs_review' (retry service skips it) + alert. - const fundsIssue = isInsufficientFundsError(err.message); + const fundsIssue = !observedTxHash && isInsufficientFundsError(err); const status = fundsIssue ? 'pending' : 'needs_review'; console.error(`❌ Batch payout failed (${pending.rows.length} payouts) -> ${status}:`, err.message); await this.db.query( - `UPDATE payouts SET status = $1, last_error = $2, last_retry_at = NOW() WHERE id = ANY($3)`, - [status, String(err.message).slice(0, 500), ids] + `UPDATE payouts + SET status = $1, last_error = $2, + tx_hash = COALESCE($4, tx_hash), last_retry_at = NOW() + WHERE id = ANY($3)`, + [status, String(err.message).slice(0, 500), ids, observedTxHash] ).catch(() => {}); if (!fundsIssue && this.alertService && typeof this.alertService.sendAlert === 'function') { this.alertService.sendAlert('batch_payout_failed', { diff --git a/src/game/helpers/gameModeUtils.js b/src/game/helpers/gameModeUtils.js index 4357953..fb59e0c 100644 --- a/src/game/helpers/gameModeUtils.js +++ b/src/game/helpers/gameModeUtils.js @@ -1,22 +1,17 @@ const ChainProfile = require('../../chain/chainProfile'); +const money = require('../../money/atomic'); const DEFAULT_DECIMALS = 12; const parseAtomicEnvValue = (val, fallback) => { if (val === undefined || val === null || val === '') return fallback; - if (typeof val !== 'string') { - const numVal = Number(val); - return Number.isFinite(numVal) ? Math.trunc(numVal) : fallback; - } - const cleaned = val.replace(/_/g, '').trim(); - if (!cleaned) { + const cleaned = typeof val === 'string' ? val.replace(/_/g, '').trim() : val; + try { + const parsed = money.toBig(cleaned); + return parsed >= 0n ? money.toSafe(parsed) : fallback; + } catch (_) { return fallback; } - const num = Number(cleaned); - if (!Number.isFinite(num) || num < 0) { - return fallback; - } - return Math.trunc(num); }; // Delegates to the ChainProfile registry — one source for all chains (WOW 11, XMR 12, BTC/LTC 8, @@ -53,24 +48,36 @@ const formatAtomic = ({ value, decimals, digits = 6 }) => { if (value === undefined || value === null) { return '0'; } - const divisor = getDecimalDivisor(decimals); - const quotient = Number(value) / divisor; - if (!Number.isFinite(quotient)) { - return value.toString(); + try { + const atomic = money.toBig(value); + const negative = atomic < 0n; + const abs = negative ? -atomic : atomic; + const scale = 10n ** BigInt(decimals); + const whole = abs / scale; + const fraction = (abs % scale).toString().padStart(decimals, '0') + .slice(0, Math.max(0, digits)).replace(/0+$/, ''); + return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`; + } catch (_) { + return String(value); } - return quotient.toFixed(digits).replace(/0+$/, '').replace(/\.$/, ''); }; const formatAtomicHuman = ({ value, decimals, digits = 3 }) => { if (value === undefined || value === null) { return '0'; } - const divisor = getDecimalDivisor(decimals); - const quotient = Number(value) / divisor; - if (!Number.isFinite(quotient)) { - return value.toString(); + try { + const atomic = money.toBig(value); + const negative = atomic < 0n; + const abs = negative ? -atomic : atomic; + const scale = 10n ** BigInt(decimals); + const whole = abs / scale; + const fraction = (abs % scale).toString().padStart(decimals, '0') + .slice(0, Math.max(0, digits)).padEnd(Math.max(0, digits), '0'); + return `${negative ? '-' : ''}${whole}${digits > 0 ? `.${fraction}` : ''}`; + } catch (_) { + return String(value); } - return quotient.toFixed(digits); }; module.exports = { diff --git a/src/game/movementManager.js b/src/game/movementManager.js index a13ed08..e5814ed 100644 --- a/src/game/movementManager.js +++ b/src/game/movementManager.js @@ -11,9 +11,11 @@ class MovementManager { this._lastMove = new Map(); // socketId -> timestamp this.postMoveHook = typeof postMoveHook === 'function' ? postMoveHook : null; this.spectatorManager = spectatorManager; // For broadcasting to spectators + this._acceptingMoves = true; } handleMove(socketId, moveData) { + if (!this._acceptingMoves) return; if (!moveData || typeof moveData.direction !== 'string') return; const now = Date.now(); const last = this._lastMove.get(socketId) || 0; @@ -23,6 +25,11 @@ class MovementManager { const game = this.activeGames.get(socketId); if (!game) return; // not in a game + // A terminal result stays in activeGames while its atomic DB completion is retried. Keeping + // the entry blocks replacement games; this explicit guard prevents the retained Game object + // from accepting moves (Game.movePlayer historically does not inspect gameState itself). + if (game.settlementPending || game.settlementCommitted || game.gameState === 'ended') return; + const dir = moveData.direction; let dx = 0, dy = 0; switch (dir) { @@ -95,6 +102,12 @@ class MovementManager { this.spectatorManager.broadcastToSpectators(game.id, state); } } + + /** Freeze gameplay before the graceful-shutdown settlement drain takes its snapshot. */ + shutdown() { + this._acceptingMoves = false; + this._lastMove.clear(); + } } module.exports = MovementManager; diff --git a/src/game/provablyFair.js b/src/game/provablyFair.js index 792bcbf..63795ac 100644 --- a/src/game/provablyFair.js +++ b/src/game/provablyFair.js @@ -1,7 +1,7 @@ /** * Provably Fair Gaming System * - * This module implements cryptographic verification for fair game generation. + * This module implements cryptographic verification for committed dungeon generation. * * How it works: * 1. Server generates a random seed before game starts @@ -17,10 +17,13 @@ * 6. Player can verify: hash(seed) === pre-game commitment * 7. Player can regenerate the dungeon using the seed to verify fairness * - * This prevents the server from: + * This lets a player detect the server: * - Generating impossible dungeons after seeing player's payment - * - Changing dungeon mid-game - * - Claiming different game outcomes + * - Substituting a different recorded dungeon layout after the run + * + * Outcome execution is a separate server-authoritative concern. Without a signed input/event + * transcript, this proof does not independently replay block timing, player moves, monster turns, + * the declared result, or payout delivery. */ const crypto = require('crypto'); @@ -42,6 +45,16 @@ function hashSeed(seed) { return crypto.createHash('sha256').update(seed).digest('hex'); } +/** + * Normalize a client contribution. Empty is a supported legacy value; non-empty values must be + * short hex so they are unambiguous across browser/server implementations. `null` means invalid. + */ +function normalizeClientSeed(value) { + if (value === undefined || value === null || String(value).trim() === '') return ''; + const seed = String(value).trim(); + return /^[0-9a-fA-F]{1,64}$/.test(seed) ? seed.toLowerCase() : null; +} + /** * Verify that a seed matches its commitment hash * @param {string} seed - The revealed seed @@ -156,17 +169,26 @@ function seededShuffle(rng, array) { * @param {string} [clientSeed=''] - Optional client-supplied seed (non-breaking) * @returns {object} Proof object with seeds, commitment, and creation time */ -function createGameProof(gameId, clientSeed = '') { - const serverSeed = generateSeed(); +function createGameProof(gameId, clientSeed = '', precommit = null) { + const normalizedClientSeed = normalizeClientSeed(clientSeed); + if (normalizedClientSeed === null) throw new Error('Invalid client seed'); + + const serverSeed = precommit?.serverSeed || generateSeed(); const commitment = hashSeed(serverSeed); // commits to serverSeed only + if (precommit?.commitment && precommit.commitment !== commitment) { + throw new Error('Fairness offer commitment does not match its server seed'); + } // Effective RNG seed folds in the client seed so neither party controls it. - const seed = deriveSeed(serverSeed, clientSeed); + const seed = deriveSeed(serverSeed, normalizedClientSeed); const timestamp = Date.now(); return { gameId, + proofVersion: precommit?.proofVersion || 1, + offerId: precommit?.offerId || null, + offerIssuedAt: precommit?.offerIssuedAt || null, serverSeed, // Keep secret until game ends - clientSeed, // Client contribution (may be '') + clientSeed: normalizedClientSeed, // Client contribution (may be '') seed, // Derived effective seed used to generate the dungeon commitment, // Show to player before game starts timestamp, @@ -183,6 +205,10 @@ function createGameProof(gameId, clientSeed = '') { function getPreGameCommitment(proof) { return { gameId: proof.gameId, + proofVersion: proof.proofVersion || 1, + offerId: proof.offerId || null, + offerIssuedAt: proof.offerIssuedAt || null, + clientSeed: proof.clientSeed ?? '', commitment: proof.commitment, timestamp: proof.timestamp, message: "This hash commits to your game's layout. After the game, you'll receive the seed to verify fairness." @@ -203,10 +229,18 @@ function getPostGameReveal(proof, gameResult = {}) { const effectiveSeed = proof.seed ?? deriveSeed(serverSeed, clientSeed); return { gameId: proof.gameId, + proofVersion: proof.proofVersion || 1, + offerId: proof.offerId || null, + offerIssuedAt: proof.offerIssuedAt || null, serverSeed, clientSeed, - seed: effectiveSeed, + seed: effectiveSeed, // backward-compatible alias + effectiveSeed, commitment: proof.commitment, + layoutFingerprint: proof.layoutFingerprint || null, + layoutFingerprints: proof.layoutFingerprints || null, + generatorVersion: proof.generatorVersion || proof.context?.generatorVersion || null, + context: proof.context || null, timestamp: proof.timestamp, gameResult: { won: gameResult.won ?? false, @@ -214,17 +248,50 @@ function getPostGameReveal(proof, gameResult = {}) { moves: gameResult.moves ?? 0, duration: gameResult.duration ?? 0 }, - verificationUrl: `/verify/${effectiveSeed}`, + verificationUrl: `/verify/${proof.gameId}`, instructions: [ - "To verify this game was fair:", + "To verify this game's committed dungeon generation:", `1. Compute SHA-256 of the serverSeed and confirm it equals the commitment: ${proof.commitment}`, "2. Compute HMAC-SHA256(key=serverSeed, msg=clientSeed) to derive the effective seed", - "3. The effective seed deterministically generates the dungeon layout, treasure, and monster behaviour", - `4. Open ${`/verify/${effectiveSeed}`} to regenerate the dungeon from the effective seed and compare its layout fingerprint` + "3. The effective seed deterministically generates every dungeon depth and its recorded placements", + `4. Open ${`/verify/${proof.gameId}`} to regenerate every depth and compare the versioned layout fingerprints`, + "5. This layout proof does not replay player input, block timing, monster turns, the result, or payout delivery" ] }; } +/** Verify both halves of a v2 proof: the published server commitment and derived RNG seed. */ +function verifyGameProof({ serverSeed, clientSeed = '', effectiveSeed, seed, commitment } = {}) { + const normalizedClientSeed = normalizeClientSeed(clientSeed); + const revealedServerSeed = String(serverSeed || ''); + const claimedEffectiveSeed = String(effectiveSeed || seed || ''); + const expectedCommitment = String(commitment || ''); + const shapeValid = /^[0-9a-f]{64}$/i.test(revealedServerSeed) + && /^[0-9a-f]{64}$/i.test(claimedEffectiveSeed) + && /^[0-9a-f]{64}$/i.test(expectedCommitment) + && normalizedClientSeed !== null; + const computedCommitment = shapeValid ? hashSeed(revealedServerSeed) : ''; + const computedEffectiveSeed = shapeValid ? deriveSeed(revealedServerSeed, normalizedClientSeed) : ''; + const commitmentValid = shapeValid && computedCommitment === expectedCommitment; + const effectiveSeedValid = shapeValid && computedEffectiveSeed === claimedEffectiveSeed; + const valid = commitmentValid && effectiveSeedValid; + + return { + valid, + commitmentValid, + effectiveSeedValid, + serverSeed: revealedServerSeed, + clientSeed: normalizedClientSeed === null ? String(clientSeed || '') : normalizedClientSeed, + effectiveSeed: claimedEffectiveSeed, + expectedCommitment, + computedCommitment, + computedEffectiveSeed, + message: valid + ? '✅ Game proof verified: the server commitment and client-derived dungeon seed both match.' + : '❌ Game proof verification failed.' + }; +} + /** * Verification result for a game * @param {string} seed - The revealed seed @@ -250,6 +317,7 @@ function verifyGame(seed, expectedCommitment) { module.exports = { generateSeed, hashSeed, + normalizeClientSeed, verifySeed, deriveSeed, createSeededRNG, @@ -260,5 +328,6 @@ module.exports = { createGameProof, getPreGameCommitment, getPostGameReveal, - verifyGame + verifyGame, + verifyGameProof }; diff --git a/src/game/rulesets/index.js b/src/game/rulesets/index.js index 013816f..3aeffbd 100644 --- a/src/game/rulesets/index.js +++ b/src/game/rulesets/index.js @@ -1,6 +1,13 @@ const { defineRuleset, WIN, ECONOMY } = require('./Ruleset'); const { resolveWinCondition } = require('./winConditions'); -const { BUILTINS, getRuleset, listRulesets, rulesetFromMatchOpts } = require('./registry'); +const { + BUILTINS, + getRuleset, + listRulesets, + listMatchRulesets, + resolveMatchRuleset, + rulesetFromMatchOpts +} = require('./registry'); module.exports = { defineRuleset, @@ -10,5 +17,7 @@ module.exports = { BUILTINS, getRuleset, listRulesets, + listMatchRulesets, + resolveMatchRuleset, rulesetFromMatchOpts }; diff --git a/src/game/rulesets/registry.js b/src/game/rulesets/registry.js index 7604453..4f17866 100644 --- a/src/game/rulesets/registry.js +++ b/src/game/rulesets/registry.js @@ -11,32 +11,37 @@ const BUILTINS = { id: 'solo-classic', label: 'Classic Escape', mode: 'solo', players: { min: 1, max: 1 }, winCondition: { type: WIN.FIRST_TO_EXIT }, - economy: { model: ECONOMY.FREE } + economy: { model: ECONOMY.FREE }, + metadata: { description: 'The original single-player escape dungeon.' } }), 'race': defineRuleset({ id: 'race', label: 'Escape Race', mode: 'race', world: { difficultyPreset: 'race' }, // matches DEFAULT_MATCH_PRESET (falls through difficultyConfig, preserved) players: { min: 2, max: 8 }, winCondition: { type: WIN.FIRST_TO_EXIT }, - economy: { model: ECONOMY.CRYPTO_RACE } + economy: { model: ECONOMY.CRYPTO_RACE }, + metadata: { description: 'First player to reach the exit wins.' } }), 'last-alive': defineRuleset({ id: 'last-alive', label: 'Last Alive', mode: 'pvp', world: { difficultyPreset: 'normal' }, entities: { monster: true, pvpCombat: true }, // step onto a rival to strike them down players: { min: 2, max: 8 }, - winCondition: { type: WIN.LAST_ALIVE } + winCondition: { type: WIN.LAST_ALIVE }, + metadata: { description: 'Outlast rivals or strike them down by moving into their tile.' } }), 'score-attack': defineRuleset({ id: 'score-attack', label: 'Score Attack', mode: 'race', players: { min: 1, max: 8 }, - winCondition: { type: WIN.HIGH_SCORE } + winCondition: { type: WIN.HIGH_SCORE }, + metadata: { description: 'Highest score at the deadline wins; escaping does not end the match.' } }), 'coop-escape': defineRuleset({ id: 'coop-escape', label: 'Co-op Escape', mode: 'coop', entities: { pvpCombat: false }, players: { min: 2, max: 8 }, - winCondition: { type: WIN.ALL_ESCAPE } + winCondition: { type: WIN.ALL_ESCAPE }, + metadata: { description: 'Work together until every surviving player escapes.' } }) }; @@ -49,10 +54,30 @@ function getRuleset(id, overrides = null) { function listRulesets() { return Object.values(BUILTINS).map(r => ({ id: r.id, label: r.label, mode: r.mode, + description: r.metadata.description || '', players: r.players, winCondition: r.winCondition.type, pvpCombat: r.entities.pvpCombat })); } +/** + * Match-mode catalog. `solo-classic` describes the separate solo engine and must never be + * selected for a multiplayer queue. Keeping this filter beside the registry gives the server, + * scheduler, and clients one stable allowlist. + */ +function listMatchRulesets() { + return listRulesets().filter(r => r.mode !== 'solo'); +} + +/** + * Resolve an operator-selected multiplayer ruleset. Unknown/solo ids fail closed to the classic + * race, preserving the historical behavior and preventing arbitrary client-authored specs. + */ +function resolveMatchRuleset(id) { + const key = typeof id === 'string' ? id.trim() : ''; + const candidate = BUILTINS[key]; + return candidate && candidate.mode !== 'solo' ? candidate : BUILTINS.race; +} + /** * Map the legacy MatchRoom option bag ({economy, variant, difficultyPreset, maxPlayers}) to a * ruleset that preserves the current race behavior — win condition stays first-to-exit regardless @@ -71,4 +96,11 @@ function rulesetFromMatchOpts(opts = {}) { }); } -module.exports = { BUILTINS, getRuleset, listRulesets, rulesetFromMatchOpts }; +module.exports = { + BUILTINS, + getRuleset, + listRulesets, + listMatchRulesets, + resolveMatchRuleset, + rulesetFromMatchOpts +}; diff --git a/src/game/rulesets/winConditions.js b/src/game/rulesets/winConditions.js index 773edc7..1655350 100644 --- a/src/game/rulesets/winConditions.js +++ b/src/game/rulesets/winConditions.js @@ -4,7 +4,7 @@ * * onExit(room, id) — a player reached the exit. Decide whether that ends the match / sets a winner. * onDeath(room) — a player just died. Decide whether the match is now over. - * rank(room) — final ordering (best-first) used by finalize() for placement + winnerId. + * rank(room) — final ordering used for placement and competitive winnerId. * * FIRST_TO_EXIT reproduces the classic race behavior byte-for-byte (so existing matches are * unchanged); LAST_ALIVE and HIGH_SCORE are the new PvP / score modes. diff --git a/src/index.js b/src/index.js index 09fd8b7..421c1ce 100644 --- a/src/index.js +++ b/src/index.js @@ -1,44 +1,71 @@ +// Load .env before reading any process setting (notably TRUST_PROXY). systemd's +// EnvironmentFile masked this ordering bug in production, while `npm run dev` did not. +require('dotenv').config(); + const express = require('express'); const path = require('path'); const crypto = require('crypto'); +const { createSocketOriginAllowRequest } = require('./network/socketOriginPolicy'); var app = express(); +app.disable('x-powered-by'); // Trust the reverse proxy (for correct req.ip / X-Forwarded-For handling) only when // explicitly enabled, so client IPs can't be spoofed in a direct-exposure deployment. if (process.env.TRUST_PROXY === 'true') { - app.set('trust proxy', true); + // Trust only the configured number of closest proxy hops. `true` trusts every value in + // X-Forwarded-For, allowing a client-supplied leftmost address to spoof req.ip. NPM is one + // hop in the production topology; multi-proxy deployments must opt in explicitly. + const trustedProxyHops = Math.max(1, Math.min(8, parseInt(process.env.TRUST_PROXY_HOPS, 10) || 1)); + app.set('trust proxy', trustedProxyHops); } var http = require('http').Server(app); -var io = require('socket.io')(http); - -// Load environment configuration -require('dotenv').config(); +var io = require('socket.io')(http, { + allowRequest: createSocketOriginAllowRequest(process.env) +}); // Import payment system components const DatabaseManager = require('./db/databaseManager'); const WalletRPCService = require('./payments/walletRPCService'); const PayoutRetryService = require('./payments/payoutRetryService'); const AlertService = require('./services/alertService'); +const HealthService = require('./services/healthService'); +const LatePaymentReconciler = require('./services/latePaymentReconciler'); const GameModeManager = require('./game/gameModeManager'); -const RpcService = require('./rpc/rpcService'); const PaymentConfigManager = require('./config/paymentConfig'); const EnvironmentValidator = require('./config/environmentValidator'); +const RuntimePolicy = require('./config/runtimePolicy'); const asyncHandler = require('./middleware/asyncHandler'); const createErrorMiddleware = require('./middleware/errorHandler'); +const noStore = require('./middleware/noStore'); +const { createCanaryDatabaseIdentityHandler } = require('./services/canaryDatabaseIdentity'); const { AppError, ValidationError, NotFoundError } = require('./utils/errors'); +const money = require('./money/atomic'); // Import modular components const BroadcastManager = require('./network/broadcastManager'); const DebugManager = require('./debug/debugManager'); const SocketHandlers = require('./network/socketHandlers'); -const { verifyGame, hashSeed } = require('./game/provablyFair'); +const { verifyGame, verifyGameProof } = require('./game/provablyFair'); +const { attachDungeonVerification } = require('./game/fairnessVerifier'); const { renderVerifyPage } = require('./views/verifyPage'); +const { renderPrivacy, renderResponsiblePlay, renderTerms } = require('./views/legalPages'); +const { buildCommerceDisclosure } = require('./config/commerceDisclosurePolicy'); const createAdminRoutes = require('./routes/admin'); const createAuthRoutes = require('./routes/auth'); +const { isSmirkEnabled } = require('./auth/smirkPolicy'); +const { + normalizeLeaderboardPeriod, + queryPrestigeLeaderboard +} = require('./network/prestigeLeaderboard'); // Initialize payment configuration const paymentConfigManager = new PaymentConfigManager({ logger: console }); const environmentValidator = new EnvironmentValidator({ logger: console }); -environmentValidator.validate(paymentConfigManager.getConfig()); +try { + environmentValidator.assertValid(paymentConfigManager.getConfig()); +} catch (error) { + console.error(`❌ Startup aborted: ${error.message}`); + process.exit(1); +} process.env.GAME_MODE = paymentConfigManager.getLegacyGameMode(); // Initialize modular components first @@ -49,19 +76,65 @@ broadcastManager.setDebugManager(debugManager); // Initialize payment system components (debugManager is now available) const databaseManager = new DatabaseManager(); const db = databaseManager; // Alias for convenience in API endpoints -const rpcService = new RpcService(); -const walletRPCService = new WalletRPCService(debugManager); +const walletRPCService = new WalletRPCService(debugManager, { + minConfirmations: paymentConfigManager.getConfig().payouts.processing.confirmations +}); const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager); // Provide io reference so GameModeManager can emit events (e.g., credits_update) gameModeManager.io = io; +// One effective payout gate used by every dispatcher. PAYOUTS_ENABLED is the master switch; +// per-mode flags cannot turn dispatch back on underneath it. +function isPayoutProcessingEnabled() { + return RuntimePolicy.isPayoutProcessingEnabled( + paymentConfigManager.getConfig(), + gameModeManager, + { settleAcceptedLiabilities: true } + ); +} + +// Set only after startup has authenticated and probed the wallet. Every dispatch path also +// rechecks live health so a later outage cannot keep sending from a stale startup result. +let payoutDispatchReady = false; +// Readiness, paid admission, and every payout dispatcher stay closed until all durable startup +// recovery passes have completed without an unresolved financial row. +let financialRecoveryReady = false; +function isFinancialRecoveryReady() { + return financialRecoveryReady + && socketHandlers?.isFinancialRecoveryReady?.() === true; +} +function canDispatchPayouts() { + return isFinancialRecoveryReady() + && payoutDispatchReady + && walletRPCService.getHealthStatus().healthy === true + && isPayoutProcessingEnabled(); +} +// Match/solo completion can request the manager's debounced dispatcher directly. Give that +// internal path the same startup, liveness, and operator-policy predicate as timers/retries. +gameModeManager.payoutDispatchAllowed = canDispatchPayouts; +// Invoice creation and paid solo entry consume value too; close both whenever startup or +// periodic liability reconciliation is incomplete. Explicit free play remains available. +gameModeManager.financialAdmissionAllowed = isFinancialRecoveryReady; +// Last-line guard shared by every wallet caller, including manual refunds and provider adapters. +// Dispatchers still check earlier so they do not claim durable rows while the gate is closed. +walletRPCService.transferAllowed = canDispatchPayouts; + // Initialize remaining components const activeGames = new Map(); // Maps socketId to Game objects let alertService = null; // Initialized later when payment system starts let payoutRetryService = null; // Initialized later when payment system starts let batchPayoutInterval = null; // Initialized later when payment system starts let paymentExpiryInterval = null; // Initialized later when payment system starts +let latePaymentReconciler = null; +let walletHealthInterval = null; // Keeps readiness tied to a live wallet, not startup state +let databaseHealthInterval = null; // Detects a database outage after startup const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, debugManager, gameModeManager, walletRPCService); +// Third-party renderer code executes with the same privileges as the game/payment UI. Keep CDN +// execution off by default; production operators must make the supply-chain tradeoff explicit. +const rendererCdnEnabled = /^true$/i.test(String(process.env.RENDERER_CDN_ENABLED || 'false')); +if (rendererCdnEnabled && process.env.NODE_ENV === 'production') { + console.warn('⚠️ RENDERER_CDN_ENABLED=true: third-party jsDelivr code is trusted by the production CSP'); +} // Security headers (defense in depth alongside output escaping). // CSP locks down where scripts/styles/connections may come from. Inline scripts/styles // are still permitted ('unsafe-inline') because the current pages rely on them heavily; @@ -69,24 +142,52 @@ const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, deb // same-origin Socket.IO websockets under CSP Level 3. blob: is limited to render-kit // GLB texture decoding; GLTFLoader turns embedded textures into blob URLs. app.use((req, res, next) => { - res.setHeader('Content-Security-Policy', [ + const scriptSources = ["'self'", "'unsafe-inline'"]; + const connectSources = ["'self'", 'blob:', 'https://smirk.cash']; + if (rendererCdnEnabled) { + scriptSources.push('https://cdn.jsdelivr.net'); + connectSources.push('https://cdn.jsdelivr.net'); + } + const csp = [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' https://cdn.socket.io https://cdn.jsdelivr.net", + `script-src ${scriptSources.join(' ')}`, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", "font-src 'self'", - "connect-src 'self' blob: https://smirk.cash https://cdn.socket.io https://cdn.jsdelivr.net", + `connect-src ${connectSources.join(' ')}`, "object-src 'none'", "base-uri 'self'", "frame-ancestors 'self'", "form-action 'self'" - ].join('; ')); + ]; + if (process.env.NODE_ENV === 'production') csp.push('upgrade-insecure-requests'); + res.setHeader('Content-Security-Policy', csp.join('; ')); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()'); + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin-allow-popups'); + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); + res.setHeader('Origin-Agent-Cluster', '?1'); + if (process.env.NODE_ENV === 'production') { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } next(); }); +// These surfaces return bearer-authenticated account, payment, or operator data. Tell both +// browsers and reverse proxies never to retain a response, including errors. +app.use(['/api/user', '/api/auth', '/api/admin', '/api/payment'], noStore); + +// Same-origin, non-secret browser policy. Loading this before renderModes.js prevents an optional +// renderer selection from even attempting a third-party fetch when CDN execution is disabled. +app.get('/runtime-config.js', (req, res) => { + res.setHeader('Cache-Control', 'no-store, max-age=0'); + res.type('application/javascript').send( + `window.WOWNGEON_RUNTIME=Object.freeze({rendererCdnEnabled:${rendererCdnEnabled}});` + ); +}); + // Configure static file serving const htmlPath = path.join(__dirname, '../html'); app.use(express.static(htmlPath, { @@ -105,6 +206,41 @@ app.get('/', function(req, res) { res.sendFile('index.html', { root: htmlPath }); }); +function currentCommerceDisclosure() { + return buildCommerceDisclosure(gameModeManager, process.env); +} + +// These pages intentionally describe the active server instead of claiming every deployment has +// the same money rules. They are product disclosures, not a substitute for operator legal review. +app.get('/terms', function(req, res) { + res.setHeader('Cache-Control', 'no-cache'); + res.type('html').send(renderTerms(currentCommerceDisclosure())); +}); +app.get('/privacy', function(req, res) { + res.setHeader('Cache-Control', 'no-cache'); + res.type('html').send(renderPrivacy(currentCommerceDisclosure())); +}); +app.get('/responsible-play', function(req, res) { + res.setHeader('Cache-Control', 'no-cache'); + res.type('html').send(renderResponsiblePlay(currentCommerceDisclosure())); +}); + +app.get('/api/disclosures', noStore, function(req, res) { + res.json(currentCommerceDisclosure()); +}); + +// This route is absent unless an isolated XMR stagenet canary opts in with a protected one-run +// nonce. It lets the financial harness bind the application process to the exact PostgreSQL +// database it independently audited before any value-bearing action. +const canaryDatabaseIdentityHandler = createCanaryDatabaseIdentityHandler({ + db: databaseManager, + env: process.env +}); +if (canaryDatabaseIdentityHandler) { + app.get('/api/canary/database-identity', noStore, + asyncHandler(canaryDatabaseIdentityHandler)); +} + // Human-friendly tavern route. The static file remains /tavern.html for direct asset serving. app.get(['/tavern', '/tavern/'], function(req, res) { res.sendFile('tavern.html', { root: htmlPath }); @@ -187,15 +323,16 @@ app.use('/api/user', userApiRateLimit); // C2: session-ownership guard for /api/user/:socketId/*. The caller must present the session // token (users.anon_token — emitted to the client as the 'session_token'/'session_resumed' -// event and stored as localStorage['wownerogue_token']) via the 'X-Session-Token' header -// (fallback ?t=). We only proceed for the row whose socket_id AND anon_token both match, so +// event and stored as localStorage['wownerogue_token']) via the 'X-Session-Token' header. +// Tokens are never accepted in URLs because reverse proxies and browser history log them. +// We only proceed for the row whose socket_id AND anon_token both match, so // one client can't read or mutate another player's payments/payouts/address. failStatus is // 403 for mutations, 401 for reads. function requireSessionOwnership(failStatus) { return async (req, res, next) => { try { const { socketId } = req.params; - const token = req.get('X-Session-Token') || req.query.t; + const token = req.get('X-Session-Token'); if (!socketId || !token) { return res.status(failStatus).json({ error: 'Session token required' }); } @@ -290,20 +427,15 @@ app.post('/api/user/:socketId/address', requireSessionOwnership(403), asyncHandl }); } - // Basic address validation (XMR/WOW addresses) - const ADDRESS_REGEX = /((?:4|8)[1-9A-HJ-NP-Za-km-z]{90,110}|(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})/; - if (!ADDRESS_REGEX.test(address.trim())) { - throw new ValidationError('Invalid address format', { - safeMessage: 'The provided address does not appear to be a valid XMR/WOW address.' - }); - } - try { - const success = await gameModeManager.setUserPayoutAddress(socketId, address.trim()); + // Keep REST and Socket.IO on the same network-aware validation path. AddressManager checks + // XMR prefixes for mainnet/stagenet/testnet, then asks wallet-rpc to verify checksum/nettype + // before persistence. + const success = await socketHandlers.addressManager.saveAddress(socketId, address, { autoConfirm: true }); if (!success) { throw new AppError('Failed to save address', { - statusCode: 500, - safeMessage: 'Unable to save payout address.' + statusCode: 400, + safeMessage: 'That address is not valid for this server network.' }); } res.json({ @@ -322,87 +454,46 @@ app.post('/api/user/:socketId/address', requireSessionOwnership(403), asyncHandl } })); -// Health check endpoint - enhanced for monitoring dashboard -app.get('/health', async (req, res) => { - const memUsage = process.memoryUsage(); - - // Get wallet balance - always attempt - let walletBalance = null; - let lowBalanceWarning = false; - const lowBalanceThreshold = parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR default - - try { - walletBalance = await walletRPCService.getBalance(); - if (walletBalance.error) { - console.warn('Wallet balance returned error:', walletBalance.error); - } else { - lowBalanceWarning = walletBalance.unlocked_balance < lowBalanceThreshold; - } - } catch (e) { - console.error('Failed to get wallet balance for health check:', e.message); - walletBalance = { balance: 0, unlocked_balance: 0, error: e.message }; - } - - const health = { - status: 'ok', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - nodeVersion: process.version, - environment: process.env.NODE_ENV || 'development', - - // Memory details - memory: { - heapUsed: memUsage.heapUsed, - heapTotal: memUsage.heapTotal, - rss: memUsage.rss, - external: memUsage.external || 0 - }, - - // Game statistics - games: { - active: activeGames.size, - queued: socketHandlers?.queueManager?.getQueueLength?.() || 0, - connected: io.sockets.sockets.size || 0, - mode: gameModeManager.gameMode - }, - - // Blockchain +function publicHealthSnapshot() { + const walletHealth = walletRPCService.getHealthStatus(); + return HealthService.buildPublicHealth({ + databaseReady: databaseManager.isHealthy(), blockHeight: debugManager.getCurrentBlockHeight(), - blockSource: process.env.BLOCK_SOURCE || 'daemon', + simulatedBlocks: debugManager.SIMULATED_BLOCKS, + chainHealthy: debugManager.isChainHealthy?.(), + blockSource: process.env.BLOCK_SOURCE || (debugManager.SIMULATED_BLOCKS ? 'simulated' : 'daemon'), network: process.env.MONERO_NETWORK || 'mainnet', - - // Wallet status - wallet: { - available: gameModeManager.paymentsEnabled, - status: walletRPCService.isHealthy ? 'connected' : 'disconnected', - endpoint: process.env.PRIMARY_WALLET_ENDPOINT ? '...' + process.env.PRIMARY_WALLET_ENDPOINT.slice(-15) : null, - pendingPayouts: 0, - balance: walletBalance, - lowBalanceWarning - }, - - // Rate limiter stats (if available) - rateLimiter: socketHandlers?.rateLimiter ? { - trackedEntries: socketHandlers.rateLimiter.getTrackedCount?.() || 0, - blockedCount: socketHandlers.rateLimiter.getBlockedCount?.() || 0 - } : null, - - // Limits - limits: { - maxGamesPerHour: parseInt(process.env.MAX_GAMES_PER_HOUR) || 60, - maxPayoutsPerDay: parseInt(process.env.MAX_PAYOUTS_PER_DAY) || 100 - }, - - // Legacy fields for compatibility - gameMode: gameModeManager.gameMode, paymentsEnabled: gameModeManager.paymentsEnabled, - directModeEnabled: gameModeManager.directModeEnabled, - creditsModeEnabled: gameModeManager.creditsModeEnabled, - walletHealthy: walletRPCService.isHealthy, + payoutsEnabled: isPayoutProcessingEnabled(), + walletHealthy: walletHealth.healthy, + identityRequired: process.env.NODE_ENV === 'production', + daemonIdentity: debugManager.getChainIdentity(), + walletIdentity: walletHealth.identity, + financialRecoveryReady: isFinancialRecoveryReady(), activeGames: activeGames.size, - debugMode: debugManager.getDebugStatus().debugMode - }; - res.json(health); + queuedGames: socketHandlers?.queueManager?.getQueueLength?.() || 0, + connectedPlayers: io.sockets.sockets.size || 0, + gameMode: gameModeManager.gameMode, + uptime: process.uptime() + }); +} + +// Public probes intentionally omit balances, RPC addresses, secrets, memory details, and abuse +// thresholds. Those remain available through the authenticated admin stats endpoints. +app.get('/health', (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.json(publicHealthSnapshot()); +}); + +app.get('/health/live', (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() }); +}); + +app.get('/health/ready', (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + const health = publicHealthSnapshot(); + res.status(health.ready ? 200 : 503).json(health); }); // Get payment options for a user (mixed mode support) @@ -467,7 +558,7 @@ app.get('/api/user/:socketId/payments', requireSessionOwnership(401), asyncHandl [user.id] ); - const totalPaid = parseInt(countResult.rows[0].total_paid, 10) || 0; + const totalPaid = money.toSafe(money.toBig(countResult.rows[0].total_paid || 0)); res.json({ payments: result.rows.map(row => ({ @@ -558,7 +649,7 @@ app.get('/api/user/:socketId/payouts', requireSessionOwnership(401), asyncHandle gameOutcome: row.game_outcome })), total: parseInt(countResult.rows[0].total, 10), - totalReceived: parseInt(totalResult.rows[0].total_received, 10), + totalReceived: money.toSafe(money.toBig(totalResult.rows[0].total_received || 0)), totalReceivedFormatted: gameModeManager.formatAtomicHuman(totalResult.rows[0].total_received, 4), limit, offset, @@ -578,25 +669,38 @@ app.get('/api/user/:socketId/payouts', requireSessionOwnership(401), asyncHandle // ============================================================================= // Smirk is a browser extension wallet for Wownero/Monero (mainnet only). // Forced off on any test network (stagenet/testnet), regardless of SMIRK_ENABLED. -const { isTestNetworkFor } = require('./game/helpers/gameModeUtils'); -const smirkEnabled = process.env.SMIRK_ENABLED !== 'false' && !isTestNetworkFor(process.env.MONERO_NETWORK); +const smirkEnabled = isSmirkEnabled(process.env); if (smirkEnabled) { - app.use(createAuthRoutes({ db: databaseManager })); + app.use(createAuthRoutes({ + db: databaseManager, + sessionManager: socketHandlers.sessionManager + })); } // ============================================================================= // Admin API Endpoints (see src/routes/admin.js) — DI ctx; alertService is set later // ============================================================================= -const adminRouteCtx = { db, gameModeManager, walletRPCService, socketHandlers, io, alertService: null }; +const adminRouteCtx = { + db, + gameModeManager, + walletRPCService, + socketHandlers, + io, + alertService: null, + canDispatchPayouts +}; app.use(createAdminRoutes(adminRouteCtx)); // ============================================================================= // LEADERBOARD // ============================================================================= app.get('/api/leaderboard', asyncHandler(async (req, res) => { - const period = req.query.period || 'all'; - const limit = Math.min(parseInt(req.query.limit) || 20, 50); + const period = normalizeLeaderboardPeriod(req.query.period); + const requestedLimit = Number.parseInt(req.query.limit, 10); + const limit = Number.isFinite(requestedLimit) + ? Math.max(1, Math.min(requestedLimit, 50)) + : 20; let timeFilter = ''; if (period === 'week') timeFilter = "AND g.completed_at > NOW() - INTERVAL '7 days'"; @@ -613,17 +717,7 @@ app.get('/api/leaderboard', asyncHandler(async (req, res) => { else if (board === 'pleb') boardFilter = "AND g.game_mode = 'FREE'"; if (board === 'prestige') { - const result = await db.query(` - SELECT - user_id as id, - COALESCE(display_name, 'Anon#' || user_id) as name, - best_score, - wins, - races as games_played - FROM prestige_leaderboard - ORDER BY best_score DESC - LIMIT $1 - `, [limit]); + const result = await queryPrestigeLeaderboard(db, { period, limit }); return res.json({ leaderboard: result.rows, period, board }); } @@ -664,7 +758,6 @@ app.get('/api/stats', asyncHandler(async (req, res) => { const config = paymentConfigManager.getConfig(); const decimals = Number(config.currency?.decimals ?? 12); - const divisor = Number.isFinite(decimals) ? Math.pow(10, decimals) : 1; const currencyLabel = gameModeManager.currencyLabel || config.currency?.symbol || 'WOW'; let gamesToday = 0, escapesToday = 0, totalPaidOut = 0; @@ -678,8 +771,9 @@ app.get('/api/stats', asyncHandler(async (req, res) => { ]); gamesToday = parseInt(games.rows[0]?.games || 0, 10); escapesToday = parseInt(games.rows[0]?.escapes || 0, 10); - // amount is atomic units (BIGINT); divide for display. Number() is safe at these magnitudes. - totalPaidOut = Number(payouts.rows[0]?.total || 0) / divisor; + // Keep the aggregate exact across PostgreSQL BIGINT -> JSON. It is a decimal string in + // whole currency units; browsers can display/coerce it without losing server-side units. + totalPaidOut = money.format(payouts.rows[0]?.total || 0, decimals); } catch (e) { // Best-effort; serve whatever we have rather than 500 on a public widget. } @@ -690,7 +784,7 @@ app.get('/api/stats', asyncHandler(async (req, res) => { totalPaidOut, currencyLabel, // Paid out is only meaningful where payouts are enabled (stagenet / payout instances). - payoutsEnabled: !!(gameModeManager.directPayoutEnabled || gameModeManager.creditsPayoutEnabled) + payoutsEnabled: isPayoutProcessingEnabled() }; _statsCache = { at: now, data }; res.json({ ...data, online }); @@ -699,13 +793,15 @@ app.get('/api/stats', asyncHandler(async (req, res) => { app.get('/api/game-modes', (req, res) => { const config = paymentConfigManager.getConfig(); const decimals = Number(config.currency?.decimals ?? 12); - const divisor = Number.isFinite(decimals) ? Math.pow(10, decimals) : 1; const toDisplay = (value) => { if (value === null || value === undefined) { return 0; } - const numeric = typeof value === 'bigint' ? Number(value) : Number(value); - return Number.isFinite(numeric) ? numeric / divisor : 0; + try { + return money.format(value, decimals); + } catch (_) { + return 0; + } }; const directMode = config.modes.direct; @@ -714,18 +810,17 @@ app.get('/api/game-modes', (req, res) => { // Include hosted by info if configured let hostedBy = null; if (process.env.HOSTED_BY) { - let hostname = process.env.HOSTED_BY_NAME; - if (!hostname) { - try { - hostname = new URL(process.env.HOSTED_BY).hostname; - } catch (e) { - hostname = process.env.HOSTED_BY; // Use raw value if URL parsing fails + try { + const hostedUrl = new URL(process.env.HOSTED_BY); + if (hostedUrl.protocol === 'https:' || hostedUrl.protocol === 'http:') { + hostedBy = { + url: hostedUrl.href, + name: process.env.HOSTED_BY_NAME || hostedUrl.hostname + }; } + } catch (_) { + // Environment validation reports malformed production values. Never reflect an unsafe URL. } - hostedBy = { - url: process.env.HOSTED_BY, - name: hostname - }; } res.json({ @@ -742,8 +837,8 @@ app.get('/api/game-modes', (req, res) => { name: 'Paid Single Game', cost: toDisplay(directMode.price), enabled: !!directMode.enabled, - payoutMultiplier: (directMode.enabled && config.payouts.rules.direct.enabled) - ? config.payouts.rules.direct.multipliers + payoutMultiplier: (isPayoutProcessingEnabled() && directMode.enabled && config.payouts.rules.direct.enabled) + ? gameModeManager.getImplementedPayoutMultipliersForMode('PAID_SINGLE') : 0 }, PAID_CREDITS: { @@ -751,10 +846,11 @@ app.get('/api/game-modes', (req, res) => { cost: toDisplay(creditsMode.packages?.[0]?.price ?? 0), credits: creditsMode.packages?.[0]?.credits ?? creditsMode.creditsPerGame, enabled: !!creditsMode.enabled, - payoutMultiplier: creditsMode.enabled && config.payouts.rules.credits.enabled - ? config.payouts.rules.credits.multipliers + payoutMultiplier: isPayoutProcessingEnabled() && creditsMode.enabled && config.payouts.rules.credits.enabled + ? gameModeManager.getImplementedPayoutMultipliersForMode('PAID_CREDITS') : 0 }, + rendererCdnEnabled, hostedBy }); }); @@ -776,7 +872,12 @@ app.get('/verify/:gameId', asyncHandler(async (req, res) => { let gameRecord = null; try { const result = await databaseManager.query( - 'SELECT dungeon_seed, status, treasure_found, moves_made, duration_seconds, created_at FROM games WHERE dungeon_seed = $1', + `SELECT dungeon_seed, status, treasure_found, moves_made, duration_seconds, created_at, + completed_at, proof_version, fairness_offer_id, fairness_offer_issued_at, + proof_commitment, client_seed, effective_seed, layout_fingerprint, + layout_fingerprints, generator_version, proof_context, + CASE WHEN completed_at IS NOT NULL THEN server_seed ELSE NULL END AS server_seed + FROM games WHERE dungeon_seed = $1`, [gameId] ); if (result.rows.length > 0) { @@ -798,9 +899,59 @@ app.get('/verify/:gameId', asyncHandler(async (req, res) => { })); })); +// Authoritative one-click verification by game UUID. Active games never reveal serverSeed. +app.get('/api/verify/:gameId', asyncHandler(async (req, res) => { + const proofResult = await databaseManager.query( + `SELECT dungeon_seed, status, completed_at, proof_version, fairness_offer_id, + fairness_offer_issued_at, proof_commitment, server_seed, client_seed, + effective_seed, layout_fingerprint, layout_fingerprints, + generator_version, proof_context + FROM games WHERE dungeon_seed = $1 LIMIT 1`, + [req.params.gameId] + ); + const record = proofResult.rows[0]; + if (!record) throw new NotFoundError('Game proof not found', { safeMessage: 'Game proof not found.' }); + if (!record.completed_at) { + return res.status(409).json({ valid: false, error: 'Proof is revealed only after the game ends.' }); + } + if (!record.server_seed || !record.proof_commitment || !record.effective_seed) { + return res.status(422).json({ valid: false, error: 'This legacy game does not have a complete v2 proof.' }); + } + + const result = verifyGameProof({ + serverSeed: record.server_seed.trim(), + clientSeed: record.client_seed || '', + effectiveSeed: record.effective_seed.trim(), + commitment: record.proof_commitment.trim() + }); + attachDungeonVerification(result, { + effectiveSeed: record.effective_seed.trim(), + proofContext: record.proof_context, + expectedFingerprint: record.layout_fingerprint?.trim(), + expectedFingerprints: record.layout_fingerprints, + generatorVersion: record.generator_version + }); + result.gameId = record.dungeon_seed; + result.proofVersion = record.proof_version; + result.offerId = record.fairness_offer_id; + result.offerIssuedAt = record.fairness_offer_issued_at; + res.json(result); +})); + // API endpoint for programmatic verification app.get('/api/verify', (req, res) => { - const { seed, commitment } = req.query; + const { seed, serverSeed, clientSeed, effectiveSeed, commitment } = req.query; + + // V2: verify the server's precommit AND the client-derived effective dungeon seed. + if (serverSeed || effectiveSeed || clientSeed !== undefined) { + if (!serverSeed || !effectiveSeed || !commitment) { + return res.status(400).json({ + valid: false, + error: 'Missing required parameters: serverSeed, effectiveSeed, and commitment' + }); + } + return res.json(verifyGameProof({ serverSeed, clientSeed: clientSeed || '', effectiveSeed, commitment })); + } if (!seed || !commitment) { return res.status(400).json({ @@ -811,7 +962,7 @@ app.get('/api/verify', (req, res) => { const result = verifyGame(seed, commitment); - // PROVABLY FAIR (Phase 0.2): when the seed matches the commitment, regenerate the + // Legacy v1 path: when the seed matches the commitment, regenerate the // dungeon from the seed and return a layout fingerprint so the player can confirm // the layout is a deterministic function of the committed seed (not crafted after payment). if (result.valid) { @@ -839,41 +990,110 @@ debugManager.onNewBlockCallback((blockHeight) => { Promise.resolve(socketHandlers.checkGamesTimeout(blockHeight)).catch(err => { console.error('❌ checkGamesTimeout error:', err.message); }); - // Flush any pending payouts (safety net for batch timer) - gameModeManager._processPendingPayouts().catch(err => { - console.error('❌ Block-triggered payout processing error:', err.message); - }); + // Flush pending payouts only when the master switch and at least one per-mode policy are on. + if (canDispatchPayouts()) { + gameModeManager._processPendingPayouts().catch(err => { + console.error('❌ Block-triggered payout processing error:', err.message); + }); + } }); // Initialize payment system async function initializePaymentSystem() { try { - console.log('🔧 Initializing payment system...'); - const walletInitialized = await walletRPCService.initialize(); - if (!walletInitialized) { - console.log('⚠️ Wallet RPC not available - falling back to FREE mode'); - process.env.GAME_MODE = 'FREE'; - paymentConfigManager.refresh(); - gameModeManager.setLegacyGameMode('FREE'); - gameModeManager.paymentsEnabled = false; - gameModeManager.directModeEnabled = false; - gameModeManager.creditsModeEnabled = false; + const production = process.env.NODE_ENV === 'production'; + let paymentsRequested = Boolean(paymentConfigManager.getConfig().paymentsEnabled); + const walletRequired = RuntimePolicy.isWalletRequired( + paymentConfigManager.getConfig(), + gameModeManager, + { settleAcceptedLiabilities: true } + ); + let walletReady = false; + + if (walletRequired) { + console.log('🔧 Initializing authenticated wallet boundary...'); + walletReady = Boolean(await walletRPCService.initialize()); + if (!walletReady) { + if (production) { + throw new Error('Wallet RPC is unavailable while payment intake or payout settlement is enabled.'); + } + if (paymentsRequested) { + console.log('⚠️ Wallet RPC not available - falling back to FREE mode'); + process.env.GAME_MODE = 'FREE'; + paymentConfigManager.refresh(); + gameModeManager.setLegacyGameMode('FREE'); + gameModeManager.paymentsEnabled = false; + gameModeManager.directModeEnabled = false; + gameModeManager.creditsModeEnabled = false; + paymentsRequested = false; + } else { + console.log('⚠️ Wallet RPC not available - payout-only settlement remains stopped'); + } + } + // A successful startup probe is not permanent health. Refresh the wallet liveness + // bit in the background so /health/ready turns degraded after an RPC outage. + let walletProbeRunning = false; + const requestedWalletProbeMs = Number(process.env.WALLET_HEALTH_INTERVAL_MS); + const walletProbeMs = Math.max(1000, Math.min( + Number.isFinite(requestedWalletProbeMs) && requestedWalletProbeMs > 0 + ? requestedWalletProbeMs + : 10000, + Math.max(1000, Math.floor(walletRPCService.identityMaxAgeMs / 2)) + )); + walletHealthInterval = setInterval(async () => { + if (walletProbeRunning) return; + walletProbeRunning = true; + try { + // A transport-only get_version response must never revive readiness. Recheck + // the wallet's validated chain/nettype identity on every liveness probe. + await walletRPCService.ensureNetworkIdentity({ force: true }); + } catch (_) { + // ensureNetworkIdentity records isHealthy=false; readiness is fail-closed. + } finally { + walletProbeRunning = false; + } + }, walletProbeMs); + walletHealthInterval.unref?.(); + } else { + console.log('ℹ️ Payments disabled; wallet RPC initialization skipped.'); } // Initialize database connection and run migrations await databaseManager.initialize(); + if (!databaseManager.isConnected()) { + throw new Error('PostgreSQL is unavailable.'); + } console.log('✅ Database initialized successfully'); + databaseHealthInterval = setInterval(() => { + databaseManager.healthCheck().catch?.(() => {}); + }, Number(process.env.DB_HEALTH_INTERVAL_MS) || 15000); + databaseHealthInterval.unref?.(); - // Test RPC service connectivity - const rpcHealth = await rpcService.healthCheck(); - console.log(`✅ RPC Service: ${rpcHealth.healthy ? 'Connected' : 'Warning - using fallback'}`); + // Probe the exact RpcService instance that drives block callbacks. A second independent + // client could pass startup while the polling instance points at the wrong chain. + const rpcHealth = debugManager.SIMULATED_BLOCKS + ? { healthy: true, identity: debugManager.getChainIdentity() } + : await debugManager.rpcService.healthCheck(); + if (production && (!rpcHealth.healthy || rpcHealth.identity?.verified !== true)) { + throw new Error('No configured blockchain daemon RPC has the expected chain/network identity.'); + } + console.log(`${rpcHealth.healthy ? '✅' : '⚠️'} RPC Service: ${rpcHealth.healthy ? 'Connected' : 'Unavailable'}`); - console.log('🚀 Payment system ready!'); - return true; + const paymentSystemReady = paymentsRequested && walletReady; + console.log(paymentSystemReady ? '🚀 Payment system ready!' : '🚀 Free-play services ready!'); + return { paymentSystemReady, paymentsRequested, walletRequired, walletReady }; } catch (error) { - console.error('❌ Failed to initialize payment system:', error); + console.error('❌ Failed to initialize payment system:', error?.message || String(error)); + if (process.env.NODE_ENV === 'production') { + throw error; + } console.log('⚠️ Server will continue in FREE mode only'); - return false; + return { + paymentSystemReady: false, + paymentsRequested: false, + walletRequired: false, + walletReady: false + }; } } @@ -901,19 +1121,23 @@ setInterval(debugRegisteredUsers, 10000); async function startServer() { try { // Initialize payment system first - const paymentSystemReady = await initializePaymentSystem(); + const financialRuntime = await initializePaymentSystem(); + const { paymentSystemReady } = financialRuntime; - // C6: right after payment/recovery init at startup, finalize/refund any games left - // status='active' by a previous crash or restart. recoverOrphanedGames() is idempotent - // so it is safe to run on every boot; failures here must not block server startup. - try { - const sm = socketHandlers.sessionManager; - if (sm && typeof sm.recoverOrphanedGames === 'function') { - await sm.recoverOrphanedGames(); - } - } catch (recoverErr) { - console.error('❌ recoverOrphanedGames failed at startup:', recoverErr.message); + // No listener or paid dispatcher is enabled until every due financial row has been + // reconciled. Recovery is idempotent and throws FINANCIAL_RECOVERY_INCOMPLETE with a + // sanitized unresolved-row summary on either a scan or per-row failure. + const sm = socketHandlers.sessionManager; + if (!sm || typeof sm.recoverOrphanedGames !== 'function') { + throw new Error('Orphaned solo-game financial recovery is unavailable.'); } + await sm.recoverOrphanedGames(); + + // Match queue recovery and payout-liability reconciliation require the migrations that + // initializePaymentSystem() just applied. Await them before accepting any sockets. + await socketHandlers.initializeMatchMode(); + financialRecoveryReady = true; + payoutDispatchReady = financialRuntime.walletReady && isPayoutProcessingEnabled(); // Socket.io connection handler - ONLY after payment system is ready io.on('connection', function(socket) { @@ -950,8 +1174,10 @@ async function startServer() { console.log(`🎮 Available game modes: ${enabledModes.join(', ')}`); }); - // Start batch payout processing if payment system is ready - if (paymentSystemReady) { + // Payout dispatch/retry is intentionally independent from payment intake. A prestige + // instance may accept paid credits while PAYOUTS_ENABLED=false and must never start a + // worker capable of sending old queued rows. + if (canDispatchPayouts()) { const payoutConfig = paymentConfigManager.getConfig().payouts?.processing || {}; const payoutIntervalSeconds = Math.max(1, Number(payoutConfig.batchInterval || 300)); const payoutIntervalMs = payoutIntervalSeconds * 1000; @@ -959,7 +1185,7 @@ async function startServer() { try { await gameModeManager._processPendingPayouts(); } catch (error) { - console.error('Error in batch payout processing:', error); + console.error('Error in batch payout processing:', error?.message || String(error)); } }, payoutIntervalMs); @@ -984,7 +1210,8 @@ async function startServer() { debugManager, maxRetries, retryIntervalMs, - alertService // Pass alert service for failure notifications + alertService, // Pass alert service for failure notifications + isEnabled: canDispatchPayouts }); payoutRetryService.start(); @@ -992,9 +1219,25 @@ async function startServer() { alertService.startPeriodicChecks(300000); } - // Periodic job: expire old pending payments (every 5 minutes) + // Reconcile transfers that mined just after invoice expiry into durable manual review. + // This worker never confirms an invoice or grants an entitlement. + if (financialRuntime.walletReady) { + latePaymentReconciler = new LatePaymentReconciler({ + db: databaseManager, + gameModeManager, + walletService: walletRPCService, + lookbackHours: process.env.LATE_PAYMENT_RECONCILE_HOURS, + batchSize: process.env.LATE_PAYMENT_RECONCILE_BATCH + }); + latePaymentReconciler.runOnce().catch(error => { + console.error('[LatePayment] Startup reconciliation error:', error.message); + }); + } + + // Periodic job: reconcile, then expire old pending payments (every 5 minutes). paymentExpiryInterval = setInterval(async () => { try { + if (latePaymentReconciler) await latePaymentReconciler.runOnce(); const result = await databaseManager.query(` UPDATE payments SET status = 'expired' WHERE status = 'pending' AND expires_at < NOW() @@ -1031,16 +1274,46 @@ app.use(createErrorMiddleware({ logger: console })); startServer(); // Graceful shutdown — clean up timers, wait for in-flight payouts, close DB pool -async function gracefulShutdown(signal) { +let shutdownStarted = false; +async function gracefulShutdown(signal, exitCode = 0) { + if (shutdownStarted) return; + shutdownStarted = true; + payoutDispatchReady = false; + // Freeze solo input first. Any game-over callback already executing has synchronously + // installed its settlement intent; no new terminal result can appear after the drain sees an + // empty map. + socketHandlers?.beginShutdown?.(); console.log(`\n${signal} received. Shutting down gracefully...`); + // A broken dependency must not leave a corrupt process hanging forever. systemd will + // restart the service after this bounded drain window. + const forcedExit = setTimeout(() => { + console.error('Graceful shutdown timed out; forcing process exit.'); + process.exit(exitCode || 1); + }, 15000); + forcedExit.unref?.(); + // Stop accepting new connections http.close(); // Stop scheduled timers if (batchPayoutInterval) clearInterval(batchPayoutInterval); if (paymentExpiryInterval) clearInterval(paymentExpiryInterval); - if (gameModeManager._batchPayoutTimer) clearTimeout(gameModeManager._batchPayoutTimer); + if (walletHealthInterval) clearInterval(walletHealthInterval); + if (databaseHealthInterval) clearInterval(databaseHealthInterval); + + // Terminal solo games are held in a serialized settlement-pending state when their atomic + // completion transaction fails. Drain those retries before disabling GameModeManager or + // closing PostgreSQL so a normal deploy/restart cannot turn a known result into an orphan. + if (socketHandlers?.gameManager?.shutdown) { + const soloDrain = await socketHandlers.gameManager.shutdown({ timeoutMs: 4000 }); + if (soloDrain.pending > 0) { + console.error(` ${soloDrain.pending} solo settlement(s) remain unresolved after shutdown drain`); + } else if (soloDrain.initial > 0) { + console.log(` Settled ${soloDrain.settled} terminal solo game(s) during shutdown drain`); + } + } + gameModeManager.shutdown(); if (payoutRetryService) payoutRetryService.stop(); if (alertService) alertService.stopPeriodicChecks?.(); @@ -1063,35 +1336,34 @@ async function gracefulShutdown(signal) { } console.log('Shutdown complete.'); - process.exit(0); + clearTimeout(forcedExit); + process.exit(exitCode); } -// Last-resort process guards. A single bad socket event or an unhandled promise rejection -// must NOT crash this process, because it holds the live wallet RPC session and in-flight -// payout/batch state — being killed mid-payout is a far worse failure than leaking one -// broken request. TRADEOFF: standard Node guidance is to exit on 'uncaughtException' (process -// state may be corrupt); here we deliberately stay up so payouts can settle and sockets stay -// alive, and we log (and alert, if the alert service is up) so nothing fails silently. +// Last-resort process guards. After an uncaught exception, Node process state is not trustworthy. +// Payout claims are durable/ambiguous-safe, so drain briefly and let the service manager restart. process.on('uncaughtException', (err) => { - console.error('❌ uncaughtException (kept alive):', err && err.stack ? err.stack : err); + console.error('❌ uncaughtException (fatal):', err && err.stack ? err.stack : err); try { alertService?.sendAlert?.('process_uncaught_exception', { - subject: 'uncaughtException (server kept alive)', + subject: 'uncaughtException (server restarting)', body: String(err && err.stack ? err.stack : err), level: 'error' })?.catch?.(() => {}); } catch (_) {} + gracefulShutdown('uncaughtException', 1).catch(() => process.exit(1)); }); process.on('unhandledRejection', (reason) => { - console.error('❌ unhandledRejection (kept alive):', reason && reason.stack ? reason.stack : reason); + console.error('❌ unhandledRejection (fatal):', reason && reason.stack ? reason.stack : reason); try { alertService?.sendAlert?.('process_unhandled_rejection', { - subject: 'unhandledRejection (server kept alive)', + subject: 'unhandledRejection (server restarting)', body: String(reason && reason.stack ? reason.stack : reason), level: 'error' })?.catch?.(() => {}); } catch (_) {} + gracefulShutdown('unhandledRejection', 1).catch(() => process.exit(1)); }); -process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); -process.on('SIGINT', () => gracefulShutdown('SIGINT')); +process.on('SIGTERM', () => gracefulShutdown('SIGTERM', 0)); +process.on('SIGINT', () => gracefulShutdown('SIGINT', 0)); diff --git a/src/middleware/noStore.js b/src/middleware/noStore.js new file mode 100644 index 0000000..25e8849 --- /dev/null +++ b/src/middleware/noStore.js @@ -0,0 +1,11 @@ +'use strict'; + +/** Prevent browsers and intermediary caches from retaining authenticated API responses. */ +function noStore(_req, res, next) { + res.setHeader('Cache-Control', 'no-store, max-age=0'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + next(); +} + +module.exports = noStore; diff --git a/src/migrations/022_match_mode.sql b/src/migrations/022_match_mode.sql index 26d6445..53dd9cf 100644 --- a/src/migrations/022_match_mode.sql +++ b/src/migrations/022_match_mode.sql @@ -6,7 +6,8 @@ -- Design notes: -- - One fact, one table: matches, entrants, events, queue, and ticket ledger are separate. -- - All money movement uses DatabaseManager.withTransaction() at the application layer. --- - Provably fair: seed_hash stored at creation, seed revealed on finish (like solo games). +-- - Verifiable commitment: seed_hash stored at creation and seed revealed on finish. Production +-- paid matches additionally persist chain-block derivation metadata inside dungeon JSON. -- - Tickets (race_entries) are non-refundable crypto-race entry tokens; they avoid the -- need for on-chain refunds when a player leaves the queue before a match starts. diff --git a/src/migrations/029_match_ruleset_id.sql b/src/migrations/029_match_ruleset_id.sql new file mode 100644 index 0000000..35d381c --- /dev/null +++ b/src/migrations/029_match_ruleset_id.sql @@ -0,0 +1,10 @@ +-- Persist the operator-selected gameplay ruleset independently from the coarse legacy +-- matches.variant ('race'/'pvp'). Existing rows are classic races; new built-ins such as +-- score-attack and coop-escape remain fully auditable without widening money/economy keys. + +ALTER TABLE matches + ADD COLUMN IF NOT EXISTS ruleset_id VARCHAR(64) NOT NULL DEFAULT 'race'; + +CREATE INDEX IF NOT EXISTS idx_matches_ruleset_finished + ON matches (ruleset_id, ended_at DESC) + WHERE status = 'finished'; diff --git a/src/migrations/030_solo_fairness_proof.sql b/src/migrations/030_solo_fairness_proof.sql new file mode 100644 index 0000000..1177bd2 --- /dev/null +++ b/src/migrations/030_solo_fairness_proof.sql @@ -0,0 +1,22 @@ +-- Durable two-party proof material for solo games. The server seed is stored privately for +-- crash/audit recovery but is exposed by verification routes only after the game completes. + +ALTER TABLE games + ADD COLUMN IF NOT EXISTS proof_version SMALLINT NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS fairness_offer_id VARCHAR(64), + ADD COLUMN IF NOT EXISTS fairness_offer_issued_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS proof_commitment CHAR(64), + ADD COLUMN IF NOT EXISTS server_seed CHAR(64), + ADD COLUMN IF NOT EXISTS client_seed VARCHAR(64) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS effective_seed CHAR(64), + ADD COLUMN IF NOT EXISTS layout_fingerprint CHAR(64), + ADD COLUMN IF NOT EXISTS proof_context JSONB, + ADD COLUMN IF NOT EXISTS proof_revealed_at TIMESTAMPTZ; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_games_fairness_offer_once + ON games (fairness_offer_id) + WHERE fairness_offer_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_games_proof_commitment + ON games (proof_commitment) + WHERE proof_commitment IS NOT NULL; diff --git a/src/migrations/031_payment_refund_outbox.sql b/src/migrations/031_payment_refund_outbox.sql new file mode 100644 index 0000000..18be721 --- /dev/null +++ b/src/migrations/031_payment_refund_outbox.sql @@ -0,0 +1,56 @@ +-- Durable, idempotent admin payment refunds. +-- +-- Only `requested` rows may be claimed by the application. A `processing` row means a +-- wallet call may have been made and must never be retried automatically: if the process +-- dies or loses the wallet response, an operator must reconcile it first. + +CREATE TABLE IF NOT EXISTS payment_refunds ( + id BIGSERIAL PRIMARY KEY, + payment_id INTEGER NOT NULL REFERENCES payments(id) ON DELETE RESTRICT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + status VARCHAR(20) NOT NULL, + amount BIGINT NOT NULL, + payout_address VARCHAR(200), + credits_deducted BIGINT NOT NULL DEFAULT 0, + reason TEXT NOT NULL, + tx_hash VARCHAR(128), + error_message TEXT, + requested_at TIMESTAMPTZ, + processing_started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + needs_review_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT payment_refunds_payment_once UNIQUE (payment_id), + CONSTRAINT payment_refunds_status_check + CHECK (status IN ('recorded', 'requested', 'processing', 'completed', 'needs_review')), + CONSTRAINT payment_refunds_amount_check CHECK (amount >= 0), + CONSTRAINT payment_refunds_credits_check CHECK (credits_deducted >= 0), + CONSTRAINT payment_refunds_state_shape_check CHECK ( + (status = 'recorded') + OR (status = 'requested' AND requested_at IS NOT NULL) + OR ( + status = 'processing' + AND requested_at IS NOT NULL + AND processing_started_at IS NOT NULL + ) + OR ( + status = 'completed' + AND requested_at IS NOT NULL + AND processing_started_at IS NOT NULL + AND completed_at IS NOT NULL + AND tx_hash IS NOT NULL + AND LENGTH(tx_hash) > 0 + ) + OR ( + status = 'needs_review' + AND requested_at IS NOT NULL + AND error_message IS NOT NULL + AND LENGTH(error_message) > 0 + ) + ) +); + +CREATE INDEX IF NOT EXISTS idx_payment_refunds_status + ON payment_refunds(status, created_at); diff --git a/src/migrations/032_solo_liability_invariants.sql b/src/migrations/032_solo_liability_invariants.sql new file mode 100644 index 0000000..7235235 --- /dev/null +++ b/src/migrations/032_solo_liability_invariants.sql @@ -0,0 +1,91 @@ +-- Migration 032: immutable solo payout commitments and unconditional payout identity +-- +-- A paid game's payout eligibility and exact terms are committed in the same transaction +-- that consumes its entry. Runtime kill switches may pause dispatch, but cannot erase or +-- retroactively create an already-committed liability. +ALTER TABLE games + ADD COLUMN IF NOT EXISTS payout_eligible BOOLEAN, + ADD COLUMN IF NOT EXISTS payout_terms JSONB, + ADD COLUMN IF NOT EXISTS payout_committed_at TIMESTAMPTZ; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'games_payout_commitment_complete' + ) THEN + ALTER TABLE games + ADD CONSTRAINT games_payout_commitment_complete + CHECK ( + payout_committed_at IS NULL + OR (payout_eligible IS NOT NULL AND payout_terms IS NOT NULL) + ) NOT VALID; + END IF; +END $$; + +CREATE OR REPLACE FUNCTION reject_game_payout_commitment_mutation() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.payout_committed_at IS NOT NULL AND ( + NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.payout_eligible IS DISTINCT FROM OLD.payout_eligible + OR NEW.payout_terms IS DISTINCT FROM OLD.payout_terms + OR NEW.payout_committed_at IS DISTINCT FROM OLD.payout_committed_at + OR NEW.payout_escape_amount IS DISTINCT FROM OLD.payout_escape_amount + OR NEW.payout_treasure_amount IS DISTINCT FROM OLD.payout_treasure_amount + OR NEW.payout_escape_mult IS DISTINCT FROM OLD.payout_escape_mult + OR NEW.payout_treasure_mult IS DISTINCT FROM OLD.payout_treasure_mult + OR NEW.payout_address IS DISTINCT FROM OLD.payout_address + OR NEW.game_mode IS DISTINCT FROM OLD.game_mode + OR NEW.payment_id IS DISTINCT FROM OLD.payment_id + ) THEN + RAISE EXCEPTION 'committed solo payout terms are immutable for game %', OLD.id + USING ERRCODE = '23514'; + END IF; + IF OLD.payout_committed_at IS NULL + AND NEW.payout_committed_at IS NOT NULL + AND NEW.user_id IS DISTINCT FROM OLD.user_id + THEN + RAISE EXCEPTION 'solo payout owner cannot change while committing game %', OLD.id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_games_immutable_payout_commitment ON games; +CREATE TRIGGER trg_games_immutable_payout_commitment + BEFORE UPDATE ON games + FOR EACH ROW + EXECUTE FUNCTION reject_game_payout_commitment_mutation(); + +-- Refuse to weaken identity silently if historical duplicate obligations exist. Operators +-- must reconcile them explicitly before this migration can complete. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM payouts + WHERE game_id IS NOT NULL + GROUP BY game_id HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'Cannot enforce one payout per game: duplicate game_id obligations exist'; + END IF; + IF EXISTS ( + SELECT 1 FROM payouts + WHERE match_id IS NOT NULL + GROUP BY match_id HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'Cannot enforce one payout per match: duplicate match_id obligations exist'; + END IF; +END $$; + +-- Status changes (including failed/needs_review) never release the identity. A possibly +-- broadcast payout must not permit a replacement row for the same game or match. +DROP INDEX IF EXISTS idx_payouts_one_per_game; +CREATE UNIQUE INDEX idx_payouts_one_per_game + ON payouts(game_id) + WHERE game_id IS NOT NULL; + +DROP INDEX IF EXISTS idx_payouts_one_per_match; +CREATE UNIQUE INDEX idx_payouts_one_per_match + ON payouts(match_id) + WHERE match_id IS NOT NULL; diff --git a/src/migrations/033_match_payout_liability.sql b/src/migrations/033_match_payout_liability.sql new file mode 100644 index 0000000..3701123 --- /dev/null +++ b/src/migrations/033_match_payout_liability.sql @@ -0,0 +1,243 @@ +-- Durable match payout terms. +-- +-- A crypto match consumes escrow only after these fields are written in the same transaction. +-- They are an immutable acceptance snapshot: later configuration changes may stop new matches +-- or pause dispatch, but cannot erase or manufacture a liability for an already accepted match. + +ALTER TABLE matches + ADD COLUMN IF NOT EXISTS payout_liability_amount_atomic BIGINT, + ADD COLUMN IF NOT EXISTS payout_liability_cap_atomic BIGINT, + ADD COLUMN IF NOT EXISTS payout_liability_terms JSONB, + ADD COLUMN IF NOT EXISTS payout_liability_accepted_at TIMESTAMPTZ; + +-- Paid ticket lots preserve the accepted atomic backing for every future crypto entry. Fungible +-- user.race_entries remains the UI balance, but only a ticket with a funded lot can enter a crypto +-- payout match; legacy/admin-granted tickets remain usable only outside that liability path. +CREATE TABLE IF NOT EXISTS race_entry_lots ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + payment_id BIGINT NOT NULL REFERENCES payments(id) ON DELETE RESTRICT, + unit_value_atomic BIGINT NOT NULL CHECK (unit_value_atomic > 0), + original_entries INT NOT NULL CHECK (original_entries > 0), + remaining_entries INT NOT NULL CONSTRAINT race_entry_lots_remaining_within_original CHECK ( + remaining_entries >= 0 AND remaining_entries <= original_entries + ), + product_id VARCHAR(100), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (payment_id) +); + +CREATE INDEX IF NOT EXISTS idx_race_entry_lots_user_available + ON race_entry_lots (user_id, created_at, id) + WHERE remaining_entries > 0; + +-- The backing identity and accepted economics of a lot are append-only. Redemption/refund +-- workflows may only reduce remaining_entries (and later migrations may add terminal markers). +CREATE OR REPLACE FUNCTION reject_race_entry_lot_backing_mutation() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.payment_id IS DISTINCT FROM OLD.payment_id + OR NEW.unit_value_atomic IS DISTINCT FROM OLD.unit_value_atomic + OR NEW.original_entries IS DISTINCT FROM OLD.original_entries + OR NEW.product_id IS DISTINCT FROM OLD.product_id + OR NEW.created_at IS DISTINCT FROM OLD.created_at + THEN + RAISE EXCEPTION 'race entry lot backing is immutable for lot %', OLD.id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_race_entry_lot_immutable_backing ON race_entry_lots; +CREATE TRIGGER trg_race_entry_lot_immutable_backing + BEFORE UPDATE ON race_entry_lots + FOR EACH ROW + EXECUTE FUNCTION reject_race_entry_lot_backing_mutation(); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'race_entry_lots_remaining_within_original' + ) THEN + ALTER TABLE race_entry_lots + ADD CONSTRAINT race_entry_lots_remaining_within_original + CHECK (remaining_entries >= 0 AND remaining_entries <= original_entries) NOT VALID; + END IF; +END $$; + +-- One durable queue row is the idempotency anchor for one escrow hold/refund. `consumed` is a +-- terminal successful-start state; stale queue cleanup must never treat it as refundable merely +-- because time passed. The exact held amount survives config changes and concurrent re-queues. +ALTER TABLE match_queue_entries + ADD COLUMN IF NOT EXISTS escrow_amount BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS escrow_value_atomic BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS race_entry_lot_id BIGINT + REFERENCES race_entry_lots(id) ON DELETE RESTRICT, + ADD COLUMN IF NOT EXISTS consumed_at TIMESTAMPTZ; + +ALTER TABLE match_queue_entries + DROP CONSTRAINT IF EXISTS match_queue_entries_status_check; +ALTER TABLE match_queue_entries + ADD CONSTRAINT match_queue_entries_status_check + CHECK (status IN ('queued', 'matched', 'consumed', 'cancelled')) NOT VALID; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'match_queue_entries_escrow_nonnegative' + ) THEN + ALTER TABLE match_queue_entries + ADD CONSTRAINT match_queue_entries_escrow_nonnegative + CHECK (escrow_amount >= 0 AND escrow_value_atomic >= 0) NOT VALID; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'match_queue_committed_escrow_complete' + ) THEN + ALTER TABLE match_queue_entries + ADD CONSTRAINT match_queue_committed_escrow_complete CHECK ( + status NOT IN ('matched', 'consumed') + OR economy = 'free' + OR (economy = 'credits_prestige' AND escrow_amount > 0) + OR ( + economy = 'crypto_race' + AND escrow_amount = 1 + AND escrow_value_atomic > 0 + AND race_entry_lot_id IS NOT NULL + ) + ) NOT VALID; + END IF; +END $$; + +CREATE OR REPLACE FUNCTION reject_committed_match_queue_escrow_mutation() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.status IN ('matched', 'consumed', 'cancelled') AND ( + NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.economy IS DISTINCT FROM OLD.economy + OR NEW.escrow_amount IS DISTINCT FROM OLD.escrow_amount + OR NEW.escrow_value_atomic IS DISTINCT FROM OLD.escrow_value_atomic + OR NEW.race_entry_lot_id IS DISTINCT FROM OLD.race_entry_lot_id + ) THEN + RAISE EXCEPTION 'committed queue escrow is immutable for queue entry %', OLD.id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_match_queue_immutable_committed_escrow ON match_queue_entries; +CREATE TRIGGER trg_match_queue_immutable_committed_escrow + BEFORE UPDATE ON match_queue_entries + FOR EACH ROW + EXECUTE FUNCTION reject_committed_match_queue_escrow_mutation(); + +ALTER TABLE match_entrants + ADD COLUMN IF NOT EXISTS queue_entry_id BIGINT + REFERENCES match_queue_entries(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS entry_refunded_at TIMESTAMPTZ; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_match_entrants_queue_entry + ON match_entrants (queue_entry_id) + WHERE queue_entry_id IS NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'matches_payout_liability_amount_nonnegative' + ) THEN + ALTER TABLE matches + ADD CONSTRAINT matches_payout_liability_amount_nonnegative + CHECK (payout_liability_amount_atomic IS NULL OR payout_liability_amount_atomic >= 0) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'matches_payout_liability_cap_positive' + ) THEN + ALTER TABLE matches + ADD CONSTRAINT matches_payout_liability_cap_positive + CHECK (payout_liability_cap_atomic IS NULL OR payout_liability_cap_atomic > 0) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'matches_payout_liability_complete' + ) THEN + ALTER TABLE matches + ADD CONSTRAINT matches_payout_liability_complete + CHECK ( + payout_liability_accepted_at IS NULL + OR ( + economy = 'crypto_race' + AND payout_liability_amount_atomic IS NOT NULL + AND payout_liability_amount_atomic > 0 + AND payout_liability_cap_atomic IS NOT NULL + AND payout_liability_amount_atomic <= payout_liability_cap_atomic + AND payout_liability_terms IS NOT NULL + ) + ) NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'matches_payout_liability_economics_consistent' + ) THEN + ALTER TABLE matches + ADD CONSTRAINT matches_payout_liability_economics_consistent + CHECK ( + payout_liability_accepted_at IS NULL + OR ( + entry_fee_atomic > 0 + AND pot_atomic > 0 + AND house_fee_atomic >= 0 + AND house_fee_atomic < pot_atomic + AND payout_liability_amount_atomic = pot_atomic - house_fee_atomic + ) + ) NOT VALID; + END IF; +END $$; + +CREATE OR REPLACE FUNCTION reject_match_payout_liability_mutation() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.payout_liability_accepted_at IS NOT NULL AND ( + NEW.payout_liability_amount_atomic IS DISTINCT FROM OLD.payout_liability_amount_atomic + OR NEW.payout_liability_cap_atomic IS DISTINCT FROM OLD.payout_liability_cap_atomic + OR NEW.payout_liability_terms IS DISTINCT FROM OLD.payout_liability_terms + OR NEW.payout_liability_accepted_at IS DISTINCT FROM OLD.payout_liability_accepted_at + OR NEW.entry_fee_atomic IS DISTINCT FROM OLD.entry_fee_atomic + OR NEW.pot_atomic IS DISTINCT FROM OLD.pot_atomic + OR NEW.house_fee_atomic IS DISTINCT FROM OLD.house_fee_atomic + OR NEW.house_fee_percent IS DISTINCT FROM OLD.house_fee_percent + ) THEN + RAISE EXCEPTION 'accepted match payout liability is immutable for match %', OLD.id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_matches_immutable_payout_liability ON matches; +CREATE TRIGGER trg_matches_immutable_payout_liability + BEFORE UPDATE ON matches + FOR EACH ROW + EXECUTE FUNCTION reject_match_payout_liability_mutation(); + +CREATE INDEX IF NOT EXISTS idx_matches_unsettled_payout_liability + ON matches (ended_at, id) + WHERE economy = 'crypto_race' + AND status = 'finished' + AND winner_user_id IS NOT NULL + AND payout_liability_accepted_at IS NOT NULL; diff --git a/src/migrations/034_solo_fairness_levels.sql b/src/migrations/034_solo_fairness_levels.sql new file mode 100644 index 0000000..849a55f --- /dev/null +++ b/src/migrations/034_solo_fairness_levels.sql @@ -0,0 +1,19 @@ +-- Migration 034: versioned, all-depth solo fairness manifest +-- +-- `layout_fingerprint` remains the legacy level-one alias. New games persist one +-- generator/fingerprint-versioned entry for every depth advertised at game creation. +ALTER TABLE games + ADD COLUMN IF NOT EXISTS generator_version VARCHAR(64), + ADD COLUMN IF NOT EXISTS layout_fingerprints JSONB; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'games_layout_fingerprints_array_check' + ) THEN + ALTER TABLE games + ADD CONSTRAINT games_layout_fingerprints_array_check + CHECK (layout_fingerprints IS NULL OR jsonb_typeof(layout_fingerprints) = 'array') + NOT VALID; + END IF; +END $$; diff --git a/src/migrations/035_payment_entitlements_and_entry_evidence.sql b/src/migrations/035_payment_entitlements_and_entry_evidence.sql new file mode 100644 index 0000000..5bdccf8 --- /dev/null +++ b/src/migrations/035_payment_entitlements_and_entry_evidence.sql @@ -0,0 +1,262 @@ +-- Durable payment entitlements, entry-consumption evidence, and payment-bound fairness. +-- +-- A confirmed invoice must never become claimable again after it has been converted into +-- another entitlement. Product refunds need an exact, payment-scoped grant record so every +-- reversible benefit can be locked and revoked before any wallet transfer is attempted. + +CREATE TABLE IF NOT EXISTS payment_entitlement_grants ( + payment_id INTEGER PRIMARY KEY REFERENCES payments(id) ON DELETE RESTRICT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + source VARCHAR(40) NOT NULL, + credits_granted BIGINT NOT NULL DEFAULT 0 CHECK (credits_granted >= 0), + purchase_progress_granted BIGINT NOT NULL DEFAULT 0 CHECK (purchase_progress_granted >= 0), + race_entries_granted INTEGER NOT NULL DEFAULT 0 CHECK (race_entries_granted >= 0), + packs_granted JSONB NOT NULL DEFAULT '[]'::jsonb, + premium_level_granted VARCHAR(32), + premium_level_previous VARCHAR(32), + status VARCHAR(20) NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'reversed', 'needs_review')), + credits_reversed BIGINT NOT NULL DEFAULT 0 CHECK (credits_reversed >= 0), + purchase_progress_reversed BIGINT NOT NULL DEFAULT 0 + CHECK (purchase_progress_reversed >= 0), + race_entries_reversed INTEGER NOT NULL DEFAULT 0 CHECK (race_entries_reversed >= 0), + packs_reversed JSONB NOT NULL DEFAULT '[]'::jsonb, + reversal_reason TEXT, + reversed_at TIMESTAMPTZ, + needs_review_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT payment_entitlement_packs_array + CHECK (jsonb_typeof(packs_granted) = 'array' AND jsonb_typeof(packs_reversed) = 'array'), + CONSTRAINT payment_entitlement_reversal_bounds + CHECK (credits_reversed <= credits_granted + AND purchase_progress_reversed <= purchase_progress_granted + AND race_entries_reversed <= race_entries_granted), + CONSTRAINT payment_entitlement_state_shape CHECK ( + (status = 'active' AND reversed_at IS NULL) + OR (status = 'reversed' AND reversed_at IS NOT NULL) + OR (status = 'needs_review' AND needs_review_at IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS idx_payment_entitlement_user + ON payment_entitlement_grants(user_id, created_at DESC); + +-- Tie fungible ledgers to their invoice. Partial indexes are final idempotency backstops in +-- addition to the payment/refund row locks used by the application. +ALTER TABLE credit_transactions + ADD COLUMN IF NOT EXISTS payment_id INTEGER REFERENCES payments(id) ON DELETE SET NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_credit_tx_payment_grant_once + ON credit_transactions(payment_id) + WHERE payment_id IS NOT NULL AND transaction_type IN ('purchase', 'recovery'); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_credit_tx_payment_refund_once + ON credit_transactions(payment_id) + WHERE payment_id IS NOT NULL AND transaction_type = 'refund'; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_race_entry_tx_payment_refund_once + ON race_entry_transactions(payment_id) + WHERE payment_id IS NOT NULL AND reason = 'refund'; + +ALTER TABLE race_entry_lots + ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMPTZ; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'race_entry_lots_refund_shape' + ) THEN + ALTER TABLE race_entry_lots + ADD CONSTRAINT race_entry_lots_refund_shape + CHECK (refunded_at IS NULL OR remaining_entries = 0) NOT VALID; + END IF; +END $$; + +ALTER TABLE payment_refunds + ADD COLUMN IF NOT EXISTS purchase_progress_deducted BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS race_entries_deducted INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS packs_revoked JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS premium_level_restored VARCHAR(32), + ADD COLUMN IF NOT EXISTS entitlement_grant_payment_id INTEGER + REFERENCES payment_entitlement_grants(payment_id) ON DELETE RESTRICT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'payment_refunds_progress_nonnegative' + ) THEN + ALTER TABLE payment_refunds + ADD CONSTRAINT payment_refunds_progress_nonnegative + CHECK (purchase_progress_deducted >= 0) NOT VALID; + END IF; +END $$; + +-- A game mode label is not evidence that value was taken. Startup recovery may refund only the +-- exact entry snapshot committed in the same transaction that debited/linked the entry. +ALTER TABLE games + ADD COLUMN IF NOT EXISTS entry_consumed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS entry_credits_spent INTEGER; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'games_entry_consumption_shape' + ) THEN + ALTER TABLE games + ADD CONSTRAINT games_entry_consumption_shape CHECK ( + entry_credits_spent IS NULL OR entry_credits_spent > 0 + ) NOT VALID; + END IF; +END $$; + +-- Bind the secret half of a v2 fairness offer to the invoice before an address is shown. The +-- server seed remains private until the linked game ends; only its hash is sent to the client. +ALTER TABLE payments + ADD COLUMN IF NOT EXISTS fairness_proof_version SMALLINT, + ADD COLUMN IF NOT EXISTS fairness_offer_id VARCHAR(64), + ADD COLUMN IF NOT EXISTS fairness_offer_issued_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS fairness_commitment CHAR(64), + ADD COLUMN IF NOT EXISTS fairness_server_seed CHAR(64), + ADD COLUMN IF NOT EXISTS fairness_client_seed VARCHAR(64), + ADD COLUMN IF NOT EXISTS fairness_bound_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS fairness_consumed_at TIMESTAMPTZ; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_fairness_offer_once + ON payments(fairness_offer_id) + WHERE fairness_offer_id IS NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'payments_fairness_binding_complete' + ) THEN + ALTER TABLE payments + ADD CONSTRAINT payments_fairness_binding_complete CHECK ( + fairness_bound_at IS NULL OR ( + payment_type = 'single_game' + AND fairness_proof_version = 2 + AND fairness_offer_id IS NOT NULL + AND fairness_offer_issued_at IS NOT NULL + AND fairness_commitment ~ '^[0-9a-f]{64}$' + AND fairness_server_seed ~ '^[0-9a-f]{64}$' + AND fairness_client_seed IS NOT NULL + ) + ) NOT VALID; + END IF; +END $$; + +CREATE OR REPLACE FUNCTION reject_payment_fairness_binding_mutation() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.fairness_bound_at IS NOT NULL AND ( + NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.payment_type IS DISTINCT FROM OLD.payment_type + OR NEW.expected_amount IS DISTINCT FROM OLD.expected_amount + OR NEW.subaddress IS DISTINCT FROM OLD.subaddress + OR NEW.address_index IS DISTINCT FROM OLD.address_index + OR NEW.fairness_proof_version IS DISTINCT FROM OLD.fairness_proof_version + OR NEW.fairness_offer_id IS DISTINCT FROM OLD.fairness_offer_id + OR NEW.fairness_offer_issued_at IS DISTINCT FROM OLD.fairness_offer_issued_at + OR NEW.fairness_commitment IS DISTINCT FROM OLD.fairness_commitment + OR NEW.fairness_server_seed IS DISTINCT FROM OLD.fairness_server_seed + OR NEW.fairness_client_seed IS DISTINCT FROM OLD.fairness_client_seed + OR NEW.fairness_bound_at IS DISTINCT FROM OLD.fairness_bound_at + OR (OLD.fairness_consumed_at IS NOT NULL + AND NEW.fairness_consumed_at IS DISTINCT FROM OLD.fairness_consumed_at) + ) THEN + RAISE EXCEPTION 'payment fairness binding is immutable for payment %', OLD.id + USING ERRCODE = '23514'; + END IF; + IF OLD.fairness_bound_at IS NULL AND NEW.fairness_bound_at IS NOT NULL AND ( + NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.payment_type IS DISTINCT FROM OLD.payment_type + OR NEW.expected_amount IS DISTINCT FROM OLD.expected_amount + OR NEW.subaddress IS DISTINCT FROM OLD.subaddress + OR NEW.address_index IS DISTINCT FROM OLD.address_index + ) THEN + RAISE EXCEPTION 'payment identity cannot change while binding fairness proof for payment %', OLD.id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_payments_immutable_fairness_binding ON payments; +CREATE TRIGGER trg_payments_immutable_fairness_binding + BEFORE UPDATE ON payments + FOR EACH ROW + EXECUTE FUNCTION reject_payment_fairness_binding_mutation(); + +-- Runtime fallback queries still recognize the legacy reason key, but backfill it into the +-- canonical marker and ledger FK so upgraded installations immediately get the same invariant. +WITH recovered AS ( + SELECT DISTINCT ON ((substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer) + (substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer AS payment_id, + ct.user_id, + GREATEST(ct.amount, 0)::bigint AS credits_granted, + ct.id AS credit_transaction_id + FROM credit_transactions ct + WHERE ct.reason ~ '^single_game_recovered:[0-9]+$' + ORDER BY (substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer, ct.id +) +INSERT INTO payment_entitlement_grants ( + payment_id, user_id, source, credits_granted, metadata +) +SELECT r.payment_id, r.user_id, 'single_game_recovery', r.credits_granted, + jsonb_build_object('legacyBackfill', true, 'creditTransactionId', r.credit_transaction_id) +FROM recovered r +JOIN payments p ON p.id = r.payment_id AND p.user_id = r.user_id +ON CONFLICT (payment_id) DO NOTHING; + +-- Some legacy deployments may already contain duplicate recovery ledger rows from a race +-- between instances. Attach only the earliest row to the invoice; the unique payment ledger +-- index then remains a backstop instead of making this upgrade itself fail. +WITH canonical_recovery_ledger AS ( + SELECT DISTINCT ON ((substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer) + ct.id, + (substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer AS payment_id + FROM credit_transactions ct + JOIN payments p + ON p.id = (substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer + AND p.user_id = ct.user_id + WHERE ct.payment_id IS NULL + AND ct.reason ~ '^single_game_recovered:[0-9]+$' + ORDER BY (substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer, ct.id +) +UPDATE credit_transactions ct +SET payment_id = canonical.payment_id +FROM canonical_recovery_ledger canonical +WHERE ct.id = canonical.id; + +-- Historical confirmed products did not have an entitlement marker. Snapshot the durable grant +-- JSON conservatively; pack revocation still requires payment-specific entitlement provenance, +-- otherwise the refund service sends the row to manual review. +INSERT INTO payment_entitlement_grants ( + payment_id, user_id, source, credits_granted, purchase_progress_granted, + race_entries_granted, packs_granted, premium_level_granted, metadata +) +SELECT + p.id, + p.user_id, + 'legacy_product_backfill', + CASE + WHEN COALESCE(p.product_grants->>'credits', '') ~ '^[0-9]+$' + THEN (p.product_grants->>'credits')::bigint + ELSE GREATEST(COALESCE(p.credits_purchased, 0), 0)::bigint + END, + GREATEST(COALESCE(p.credits_purchased, 0), 0)::bigint, + CASE + WHEN COALESCE(p.product_grants->>'raceEntries', '') ~ '^[0-9]+$' + THEN (p.product_grants->>'raceEntries')::integer + ELSE 0 + END, + CASE WHEN jsonb_typeof(p.product_grants->'packs') = 'array' + THEN p.product_grants->'packs' ELSE '[]'::jsonb END, + NULLIF(p.product_grants->>'premiumLevel', ''), + jsonb_build_object('legacyBackfill', true) +FROM payments p +WHERE p.status = 'confirmed' + AND p.payment_type IN ('credits_package', 'cosmetic_pack') + AND p.user_id IS NOT NULL +ON CONFLICT (payment_id) DO NOTHING; diff --git a/src/migrations/036_smirk_challenge_user_binding.sql b/src/migrations/036_smirk_challenge_user_binding.sql new file mode 100644 index 0000000..84dfebb --- /dev/null +++ b/src/migrations/036_smirk_challenge_user_binding.sql @@ -0,0 +1,36 @@ +-- Bind Smirk authentication nonces to a stable authenticated user, not merely to the +-- public Socket.IO id that Tavern/match state broadcasts to other players. + +ALTER TABLE smirk_challenges + ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; + +-- Challenges are five-minute ephemeral nonces. Invalidate any nonce issued by the old, +-- socket-only application before making the stable binding mandatory; clients simply retry. +DELETE FROM smirk_challenges WHERE user_id IS NULL; + +ALTER TABLE smirk_challenges + ALTER COLUMN user_id SET NOT NULL; + +-- Socket ownership is an authentication relation throughout the paid-play code. Refuse to +-- continue an upgrade with ambiguous historical rows, then make that invariant structural. +DO $$ +BEGIN + IF EXISTS ( + SELECT socket_id + FROM users + WHERE socket_id IS NOT NULL + GROUP BY socket_id + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'cannot enforce unique socket ownership: duplicate users.socket_id rows exist' + USING ERRCODE = '23505'; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_socket_id_unique_nonnull + ON users (socket_id) + WHERE socket_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_smirk_challenges_user_active + ON smirk_challenges (user_id, expires_at) + WHERE used = FALSE; diff --git a/src/migrations/037_payment_receipt_evidence.sql b/src/migrations/037_payment_receipt_evidence.sql new file mode 100644 index 0000000..49ebd27 --- /dev/null +++ b/src/migrations/037_payment_receipt_evidence.sql @@ -0,0 +1,134 @@ +-- Incoming-payment evidence must be unique per chain output, not per transaction or address. +-- One Monero transaction may contain several incoming outputs (including several for the same +-- subaddress), and one invoice may be completed by several top-up transactions. + +ALTER TABLE payments + ADD COLUMN IF NOT EXISTS provider_id VARCHAR(40) NOT NULL DEFAULT 'native-monero', + ADD COLUMN IF NOT EXISTS provider_invoice_id VARCHAR(160), + ADD COLUMN IF NOT EXISTS confirmation_evidence_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS late_receipt_checked_at TIMESTAMPTZ; + +-- Migration 008 assumed one incoming transaction == one invoice. Keep tx_hash as display/search +-- evidence only. Legacy data also contains expired/retried rows that intentionally reused an +-- address, so neither transaction hashes nor invoice destinations are unique payment identities. +DROP INDEX IF EXISTS idx_payments_tx_hash_unique; +DROP INDEX IF EXISTS idx_payments_tx_hash_output_unique; + +CREATE TABLE IF NOT EXISTS payment_receipts ( + id BIGSERIAL PRIMARY KEY, + payment_id INTEGER NOT NULL REFERENCES payments(id) ON DELETE RESTRICT, + provider_id VARCHAR(40) NOT NULL, + evidence_type VARCHAR(24) NOT NULL + CHECK (evidence_type IN ('chain_output', 'provider_invoice')), + evidence_id VARCHAR(160) NOT NULL, + tx_hash VARCHAR(64), + output_id VARCHAR(80), + address_index INTEGER, + amount BIGINT NOT NULL CHECK (amount > 0), + confirmed BOOLEAN NOT NULL DEFAULT TRUE, + observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT payment_receipt_shape CHECK ( + (evidence_type = 'chain_output' + AND provider_id = 'native-monero' + AND tx_hash ~ '^[0-9a-f]{64}$' + AND (output_id ~ '^[0-9a-f]{64}$' OR output_id ~ '^global:(0|[1-9][0-9]*)$') + AND evidence_id = tx_hash || ':' || output_id + AND address_index IS NOT NULL AND address_index >= 0) + OR + (evidence_type = 'provider_invoice' + AND provider_id <> 'native-monero' + AND tx_hash IS NULL + AND output_id IS NULL + AND address_index IS NULL + AND LENGTH(evidence_id) > 0) + ) +); + +-- Output/invoice evidence is globally consumable once for its provider. In particular, tx_hash +-- and address_index are deliberately absent: sibling outputs can share both values. +DROP INDEX IF EXISTS idx_payment_receipts_chain_output_once; +DROP INDEX IF EXISTS idx_payment_receipts_provider_invoice_once; +CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_receipts_evidence_once + ON payment_receipts(provider_id, evidence_id); + +CREATE INDEX IF NOT EXISTS idx_payment_receipts_payment + ON payment_receipts(payment_id, observed_at); + +-- Late receipts are intentionally not entitlements. The bounded reconciliation worker records +-- their durable output evidence here for an operator to refund or adjudicate manually, while the +-- underlying payment remains expired. +CREATE TABLE IF NOT EXISTS payment_late_reviews ( + id BIGSERIAL PRIMARY KEY, + payment_id INTEGER NOT NULL UNIQUE REFERENCES payments(id) ON DELETE RESTRICT, + provider_id VARCHAR(40) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'needs_review' + CHECK (status IN ('needs_review', 'resolved', 'ignored')), + reason VARCHAR(40) NOT NULL DEFAULT 'late_confirmed_receipt' + CHECK (reason IN ('late_confirmed_receipt')), + observed_amount BIGINT NOT NULL CHECK (observed_amount > 0), + expected_amount BIGINT NOT NULL CHECK (expected_amount > 0), + receipt_count INTEGER NOT NULL CHECK (receipt_count > 0), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + first_observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_payment_late_reviews_status + ON payment_late_reviews(status, last_observed_at); + +CREATE INDEX IF NOT EXISTS idx_payments_late_receipt_scan + ON payments(late_receipt_checked_at, expires_at) + WHERE status IN ('pending', 'expired'); + +-- Forward-only guard. Existing confirmed legacy rows (including rows with NULL tx_hash or an +-- old received_amount) remain readable because this trigger runs only on a new transition into +-- confirmed. Every new confirmation must have exact durable evidence covering the invoice. +CREATE OR REPLACE FUNCTION enforce_payment_confirmation_evidence() +RETURNS TRIGGER AS $$ +DECLARE + receipt_total BIGINT; + receipt_count BIGINT; +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.status = 'confirmed' THEN + RAISE EXCEPTION 'payment % must be inserted pending before receipt-backed confirmation', NEW.id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END IF; + + IF NEW.status = 'confirmed' AND OLD.status IS DISTINCT FROM 'confirmed' THEN + SELECT COALESCE(SUM(r.amount), 0), COUNT(*) + INTO receipt_total, receipt_count + FROM payment_receipts r + WHERE r.payment_id = NEW.id + AND r.provider_id = NEW.provider_id + AND r.confirmed = TRUE + AND ( + (NEW.provider_id = 'native-monero' AND r.evidence_type = 'chain_output') + OR (NEW.provider_id <> 'native-monero' AND r.evidence_type = 'provider_invoice') + ); + + IF receipt_count = 0 OR receipt_total < NEW.expected_amount THEN + RAISE EXCEPTION 'payment % lacks confirmed receipt coverage (% / %)', + NEW.id, receipt_total, NEW.expected_amount + USING ERRCODE = '23514'; + END IF; + IF NEW.received_amount IS NULL OR NEW.received_amount < NEW.expected_amount + OR NEW.received_amount > receipt_total THEN + RAISE EXCEPTION 'payment % received_amount is not supported by receipts', NEW.id + USING ERRCODE = '23514'; + END IF; + NEW.confirmation_evidence_at = COALESCE(NEW.confirmation_evidence_at, NOW()); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_payments_require_confirmation_evidence ON payments; +CREATE TRIGGER trg_payments_require_confirmation_evidence + BEFORE INSERT OR UPDATE OF status ON payments + FOR EACH ROW + EXECUTE FUNCTION enforce_payment_confirmation_evidence(); diff --git a/src/migrations/038_economic_identity_immutability.sql b/src/migrations/038_economic_identity_immutability.sql new file mode 100644 index 0000000..e73fdac --- /dev/null +++ b/src/migrations/038_economic_identity_immutability.sql @@ -0,0 +1,181 @@ +-- Close economic-identity mutation gaps for databases that already applied migrations 032/035. +-- Operational status, transaction evidence, retry counters, fees, and timestamps remain mutable; +-- owners, promise amounts, linkage, and destinations do not. + +CREATE OR REPLACE FUNCTION reject_game_payout_commitment_mutation() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.payout_committed_at IS NOT NULL AND ( + NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.payout_eligible IS DISTINCT FROM OLD.payout_eligible + OR NEW.payout_terms IS DISTINCT FROM OLD.payout_terms + OR NEW.payout_committed_at IS DISTINCT FROM OLD.payout_committed_at + OR NEW.payout_escape_amount IS DISTINCT FROM OLD.payout_escape_amount + OR NEW.payout_treasure_amount IS DISTINCT FROM OLD.payout_treasure_amount + OR NEW.payout_escape_mult IS DISTINCT FROM OLD.payout_escape_mult + OR NEW.payout_treasure_mult IS DISTINCT FROM OLD.payout_treasure_mult + OR NEW.payout_address IS DISTINCT FROM OLD.payout_address + OR NEW.game_mode IS DISTINCT FROM OLD.game_mode + OR NEW.payment_id IS DISTINCT FROM OLD.payment_id + ) THEN + RAISE EXCEPTION 'committed solo payout terms are immutable for game %', OLD.id + USING ERRCODE = '23514'; + END IF; + IF OLD.payout_committed_at IS NULL + AND NEW.payout_committed_at IS NOT NULL + AND NEW.user_id IS DISTINCT FROM OLD.user_id + THEN + RAISE EXCEPTION 'solo payout owner cannot change while committing game %', OLD.id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION reject_payment_fairness_binding_mutation() +RETURNS TRIGGER AS $$ +DECLARE + identity_locked BOOLEAN; +BEGIN + -- A receipt is authorization evidence even before the final status update. Once evidence + -- exists (or the invoice has reached a terminal money state), its economic promise and + -- provider/destination identity may no longer be rewritten. Operational settlement fields + -- such as status, received_amount, confirmations, and refund timestamps remain mutable. + identity_locked := OLD.fairness_bound_at IS NOT NULL + OR OLD.status IN ('confirmed', 'refunded', 'expired') + OR EXISTS (SELECT 1 FROM payment_receipts r WHERE r.payment_id = OLD.id); + + IF identity_locked AND ( + NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.payment_type IS DISTINCT FROM OLD.payment_type + OR NEW.expected_amount IS DISTINCT FROM OLD.expected_amount + OR NEW.subaddress IS DISTINCT FROM OLD.subaddress + OR NEW.address_index IS DISTINCT FROM OLD.address_index + OR NEW.provider_id IS DISTINCT FROM OLD.provider_id + OR NEW.provider_invoice_id IS DISTINCT FROM OLD.provider_invoice_id + OR NEW.payment_mode IS DISTINCT FROM OLD.payment_mode + OR NEW.credit_package_id IS DISTINCT FROM OLD.credit_package_id + OR NEW.product_id IS DISTINCT FROM OLD.product_id + OR NEW.product_grants IS DISTINCT FROM OLD.product_grants + OR NEW.description IS DISTINCT FROM OLD.description + OR NEW.expires_at IS DISTINCT FROM OLD.expires_at + ) THEN + RAISE EXCEPTION 'payment economic identity is immutable for payment %', OLD.id + USING ERRCODE = '23514'; + END IF; + + IF OLD.fairness_bound_at IS NOT NULL AND ( + NEW.fairness_proof_version IS DISTINCT FROM OLD.fairness_proof_version + OR NEW.fairness_offer_id IS DISTINCT FROM OLD.fairness_offer_id + OR NEW.fairness_offer_issued_at IS DISTINCT FROM OLD.fairness_offer_issued_at + OR NEW.fairness_commitment IS DISTINCT FROM OLD.fairness_commitment + OR NEW.fairness_server_seed IS DISTINCT FROM OLD.fairness_server_seed + OR NEW.fairness_client_seed IS DISTINCT FROM OLD.fairness_client_seed + OR NEW.fairness_bound_at IS DISTINCT FROM OLD.fairness_bound_at + OR (OLD.fairness_consumed_at IS NOT NULL + AND NEW.fairness_consumed_at IS DISTINCT FROM OLD.fairness_consumed_at) + ) THEN + RAISE EXCEPTION 'payment fairness binding is immutable for payment %', OLD.id + USING ERRCODE = '23514'; + END IF; + IF OLD.fairness_bound_at IS NULL AND NEW.fairness_bound_at IS NOT NULL AND ( + NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.payment_type IS DISTINCT FROM OLD.payment_type + OR NEW.expected_amount IS DISTINCT FROM OLD.expected_amount + OR NEW.subaddress IS DISTINCT FROM OLD.subaddress + OR NEW.address_index IS DISTINCT FROM OLD.address_index + OR NEW.provider_id IS DISTINCT FROM OLD.provider_id + OR NEW.provider_invoice_id IS DISTINCT FROM OLD.provider_invoice_id + OR NEW.payment_mode IS DISTINCT FROM OLD.payment_mode + OR NEW.credit_package_id IS DISTINCT FROM OLD.credit_package_id + OR NEW.product_id IS DISTINCT FROM OLD.product_id + OR NEW.product_grants IS DISTINCT FROM OLD.product_grants + OR NEW.description IS DISTINCT FROM OLD.description + OR NEW.expires_at IS DISTINCT FROM OLD.expires_at + ) THEN + RAISE EXCEPTION 'payment identity cannot change while binding fairness proof for payment %', OLD.id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Receipt evidence is an append-only authorization ledger. If an observation is later found to +-- be wrong, add adjudication metadata elsewhere; never rewrite or erase the evidence that allowed +-- an invoice to confirm. +CREATE OR REPLACE FUNCTION reject_payment_receipt_mutation() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'payment receipt evidence is append-only for receipt %', OLD.id + USING ERRCODE = '23514'; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_payment_receipts_append_only ON payment_receipts; +CREATE TRIGGER trg_payment_receipts_append_only + BEFORE UPDATE OR DELETE ON payment_receipts + FOR EACH ROW + EXECUTE FUNCTION reject_payment_receipt_mutation(); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'payouts_no_address_review_shape' + ) THEN + ALTER TABLE payouts + ADD CONSTRAINT payouts_no_address_review_shape CHECK ( + payout_address <> 'PENDING_NO_ADDRESS' + OR ( + status = 'needs_review' + AND reason IN ('match_winner_no_address', 'solo_winner_no_address') + AND tx_hash IS NULL + ) + ) NOT VALID; + END IF; +END $$; + +CREATE OR REPLACE FUNCTION reject_payout_obligation_mutation() +RETURNS TRIGGER AS $$ +DECLARE + allowed_address_claim BOOLEAN := FALSE; +BEGIN + IF NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.game_id IS DISTINCT FROM OLD.game_id + OR NEW.match_id IS DISTINCT FROM OLD.match_id + OR NEW.amount IS DISTINCT FROM OLD.amount + OR NEW.multiplier IS DISTINCT FROM OLD.multiplier + OR NEW.reason IS DISTINCT FROM OLD.reason + THEN + RAISE EXCEPTION 'payout economic identity is immutable for payout %', OLD.id + USING ERRCODE = '23514'; + END IF; + + IF NEW.payout_address IS DISTINCT FROM OLD.payout_address THEN + allowed_address_claim := + OLD.payout_address = 'PENDING_NO_ADDRESS' + AND OLD.status = 'needs_review' + AND OLD.reason IN ('match_winner_no_address', 'solo_winner_no_address') + AND OLD.tx_hash IS NULL + AND NEW.status = 'pending' + AND NEW.tx_hash IS NULL + AND BTRIM(COALESCE(NEW.payout_address, '')) <> '' + AND NEW.payout_address <> 'PENDING_NO_ADDRESS' + AND EXISTS ( + SELECT 1 FROM users u + WHERE u.id = OLD.user_id + AND u.payout_address = NEW.payout_address + ); + IF NOT allowed_address_claim THEN + RAISE EXCEPTION 'payout destination is immutable for payout %', OLD.id + USING ERRCODE = '23514'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_payouts_immutable_obligation ON payouts; +CREATE TRIGGER trg_payouts_immutable_obligation + BEFORE UPDATE ON payouts + FOR EACH ROW + EXECUTE FUNCTION reject_payout_obligation_mutation(); diff --git a/src/migrations/039_unresolved_solo_payout_review.sql b/src/migrations/039_unresolved_solo_payout_review.sql new file mode 100644 index 0000000..f18d068 --- /dev/null +++ b/src/migrations/039_unresolved_solo_payout_review.sql @@ -0,0 +1,26 @@ +-- Migration 039: Preserve unresolved solo-win payout obligations for manual review. +-- +-- completeGame records a paid win even when its committed user identity cannot be resolved. That +-- row must remain a durable, non-dispatchable obligation rather than rolling back the terminal +-- game transaction. Migration 038's first constraint omitted this explicit review reason. +-- +-- This forward replacement deliberately changes only the accepted row shape. The immutable +-- payout trigger continues to exclude solo_winner_identity_review from its saved-address claim +-- exception, so an operator cannot accidentally turn an ownerless obligation into a transfer. + +ALTER TABLE payouts + DROP CONSTRAINT IF EXISTS payouts_no_address_review_shape; + +ALTER TABLE payouts + ADD CONSTRAINT payouts_no_address_review_shape CHECK ( + payout_address <> 'PENDING_NO_ADDRESS' + OR ( + status = 'needs_review' + AND reason IN ( + 'match_winner_no_address', + 'solo_winner_no_address', + 'solo_winner_identity_review' + ) + AND tx_hash IS NULL + ) + ) NOT VALID; diff --git a/src/migrations/040_paid_match_entropy_precommit.sql b/src/migrations/040_paid_match_entropy_precommit.sql new file mode 100644 index 0000000..69b6947 --- /dev/null +++ b/src/migrations/040_paid_match_entropy_precommit.sql @@ -0,0 +1,90 @@ +-- Migration 040: durable proof that a paid entrant freeze committed before its entropy block. +-- +-- A pending match first commits its exact entrants and future target. Only after that transaction +-- is durably visible may the server read a fresh daemon tip and record that the target still did +-- not exist. These two columns are that post-commit witness. Legacy/unverified freezes are refunded +-- at startup and may never activate. + +ALTER TABLE matches + ADD COLUMN IF NOT EXISTS entropy_precommit_tip_height BIGINT, + ADD COLUMN IF NOT EXISTS entropy_precommit_verified_at TIMESTAMPTZ; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'matches_entropy_precommit_shape' + AND conrelid = 'matches'::regclass + ) THEN + ALTER TABLE matches + ADD CONSTRAINT matches_entropy_precommit_shape CHECK ( + (entropy_precommit_tip_height IS NULL + AND entropy_precommit_verified_at IS NULL) + OR + (entropy_precommit_tip_height IS NOT NULL + AND entropy_precommit_verified_at IS NOT NULL + AND entropy_precommit_tip_height >= 0 + AND start_block_height IS NOT NULL + AND entropy_precommit_tip_height < start_block_height + AND economy IN ('credits_prestige', 'crypto_race')) + ); + END IF; +END $$; + +CREATE OR REPLACE FUNCTION reject_match_entropy_precommit_mutation() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.entropy_precommit_verified_at IS NOT NULL AND ( + NEW.entropy_precommit_tip_height IS DISTINCT FROM OLD.entropy_precommit_tip_height + OR NEW.entropy_precommit_verified_at IS DISTINCT FROM OLD.entropy_precommit_verified_at + ) THEN + RAISE EXCEPTION 'paid entropy precommit proof is immutable for match %', OLD.id + USING ERRCODE = '23514'; + END IF; + + -- Recording the proof locks the paid freeze identity. The one permitted identity-changing + -- transition is the application's pending -> starting activation, whose conditional UPDATE + -- proves the commitment, target, economy, and marker. Once starting, normal lifecycle writes + -- change only status/times, seed reveal, winner, and liability fields. + IF OLD.entropy_precommit_verified_at IS NOT NULL + AND NOT (OLD.status = 'pending' AND NEW.status = 'starting') + AND ( + NEW.id IS DISTINCT FROM OLD.id + OR NEW.economy IS DISTINCT FROM OLD.economy + OR NEW.variant IS DISTINCT FROM OLD.variant + OR NEW.ruleset_id IS DISTINCT FROM OLD.ruleset_id + OR NEW.difficulty_preset IS DISTINCT FROM OLD.difficulty_preset + OR NEW.max_players IS DISTINCT FROM OLD.max_players + OR NEW.seed_hash IS DISTINCT FROM OLD.seed_hash + OR NEW.dungeon IS DISTINCT FROM OLD.dungeon + OR NEW.start_block_height IS DISTINCT FROM OLD.start_block_height + ) + THEN + RAISE EXCEPTION 'paid match fairness identity is immutable after precommit for match %', OLD.id + USING ERRCODE = '23514'; + END IF; + + IF OLD.entropy_precommit_verified_at IS NULL + AND NEW.entropy_precommit_verified_at IS NOT NULL + AND ( + OLD.status <> 'pending' + OR OLD.economy NOT IN ('credits_prestige', 'crypto_race') + OR NEW.status IS DISTINCT FROM OLD.status + OR NEW.economy IS DISTINCT FROM OLD.economy + OR NEW.seed_hash IS DISTINCT FROM OLD.seed_hash + OR NEW.dungeon IS DISTINCT FROM OLD.dungeon + OR NEW.start_block_height IS DISTINCT FROM OLD.start_block_height + ) + THEN + RAISE EXCEPTION 'paid freeze identity cannot change while recording precommit proof for match %', OLD.id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_matches_immutable_entropy_precommit ON matches; +CREATE TRIGGER trg_matches_immutable_entropy_precommit + BEFORE UPDATE ON matches + FOR EACH ROW + EXECUTE FUNCTION reject_match_entropy_precommit_mutation(); diff --git a/src/money/atomic.js b/src/money/atomic.js index f231aaa..316a535 100644 --- a/src/money/atomic.js +++ b/src/money/atomic.js @@ -19,8 +19,8 @@ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); function toBig(v) { if (typeof v === 'bigint') return v; if (typeof v === 'number') { - if (!Number.isFinite(v) || !Number.isInteger(v)) { - throw new Error(`Invalid atomic amount (not an integer number): ${v}`); + if (!Number.isFinite(v) || !Number.isSafeInteger(v)) { + throw new Error(`Invalid atomic amount (not an exact safe integer number): ${v}`); } return BigInt(v); } diff --git a/src/multiplayer/MatchRoom.js b/src/multiplayer/MatchRoom.js index 00f2732..c64d7c3 100644 --- a/src/multiplayer/MatchRoom.js +++ b/src/multiplayer/MatchRoom.js @@ -11,7 +11,7 @@ * • Player-on-player collision is enabled (solidOccupants = true). * • One shared treasure exists; first player to reach it picks it up. * • If the treasure carrier dies, the treasure drops at their corpse. - * • First player to reach the exit wins; timer expiry ranks by proximity. + * • The selected ruleset owns resolution and ranking; timer/block expiry remains deterministic. */ const { v4: uuidv4 } = require('uuid'); @@ -89,6 +89,9 @@ class MatchRoom extends Room { // Movement queue for current tick: occupantId -> { dx, dy } this.moveQueue = new Map(); + // Carries fractional monster speed between ticks (for example 0.5 = one move every + // other tick). This is match state, so it remains deterministic from the committed seed. + this.monsterMoveAccumulator = 0; // Timer / deadline bookkeeping (from the ruleset; defaults reproduce the classic values). this.minDurationMs = this.ruleset.timing.minDurationMs; // floor before next-block expiry can fire (20s) @@ -195,6 +198,9 @@ class MatchRoom extends Room { this.occupants.set(id, occ); this.playerStates.set(id, { userId: e.userId || null, + // Stable across socket reconnect/re-keying. Same-tick action priority is derived + // from this slot + the committed seed, never packet arrival order or socket id. + entrantSlot: i, alive: true, finished: false, escaped: false, @@ -285,9 +291,19 @@ class MatchRoom extends Room { const nx = occ.x + dx; const ny = occ.y + dy; const facing = Room.facingFor(dx, dy); - plans.push({ id, from: { x: occ.x, y: occ.y }, to: { x: nx, y: ny }, facing }); + plans.push({ + id, + from: { x: occ.x, y: occ.y }, + to: { x: nx, y: ny }, + facing, + priority: this._tickActionPriority(id) + }); } + // Network arrival order must never decide a paid match. Resolve the tick in a committed, + // reproducible order which changes per tick but remains stable across reconnects. + plans.sort((a, b) => a.priority.localeCompare(b.priority)); + // If A moves to B's tile and B moves to A's tile, both bounce. const swapPairs = new Set(); for (let i = 0; i < plans.length; i++) { @@ -312,14 +328,25 @@ class MatchRoom extends Room { // Apply each valid move. for (const p of plans) { + if (this.status !== 'active') break; const occ = this.occupants.get(p.id); + const actorState = this.playerStates.get(p.id); + // An earlier same-tick strike may have eliminated this actor after intents were + // captured. A dead/escaped actor never gets a post-mortem move or attack. + if (!occ || !actorState || !actorState.alive || actorState.finished) { + events.push({ + type: 'move_failed', id: p.id, reason: 'eliminated_before_action', + from: p.from, to: p.to, tick: this.tickCount + }); + continue; + } let reason = null; if (swapPairs.has(p.id)) { reason = 'collision_swap'; } else if (!this.isWalkable(p.to.x, p.to.y)) { reason = 'blocked'; - } else if (this.solidOccupants && this.isOccupied(p.to.x, p.to.y, p.id)) { + } else if (this.solidOccupants && this._isBlockingOccupantAt(p.to.x, p.to.y, p.id)) { // PvP: stepping onto a living rival strikes them down; the attacker holds position. if (this.pvpCombat) { const victimId = this._livingOccupantIdAt(p.to.x, p.to.y, p.id); @@ -344,8 +371,7 @@ class MatchRoom extends Room { } occ.moveTo(p.to.x, p.to.y, p.facing); - const state = this.playerStates.get(p.id); - state.moves++; + actorState.moves++; events.push({ type: 'move', id: p.id, from: p.from, to: p.to, facing: p.facing, tick: this.tickCount }); // If the player stepped onto the monster, they die immediately. if (this.monster && p.to.x === this.monster.x && p.to.y === this.monster.y) { @@ -357,6 +383,14 @@ class MatchRoom extends Room { return events; } + _tickActionPriority(id, tick = this.tickCount) { + const slot = this.playerStates.get(id)?.entrantSlot; + const stableSlot = Number.isInteger(slot) ? slot : -1; + return crypto.createHash('sha256') + .update(`${this.seed}:${tick}:${stableSlot}`) + .digest('hex'); + } + _moveMonster() { if (!this.monster || this.activePlayerCount === 0) return []; @@ -392,11 +426,14 @@ class MatchRoom extends Room { } } - const movesPerTick = this.difficultyConfig.monster.movesPerPlayerMove || 1.0; - let movesRemaining = movesPerTick; + const configuredSpeed = Number(this.difficultyConfig?.monster?.movesPerPlayerMove); + const movesPerTick = Number.isFinite(configuredSpeed) && configuredSpeed >= 0 + ? configuredSpeed + : 1.0; + this.monsterMoveAccumulator += movesPerTick; - while (movesRemaining >= 1) { - movesRemaining--; + while (this.monsterMoveAccumulator >= 1) { + this.monsterMoveAccumulator -= 1; const moved = this.monster.moveTowardPlayer( target, this.dungeon, @@ -435,6 +472,21 @@ class MatchRoom extends Room { return null; } + // Terminal players stay in occupants so spectators and final results can render them, but a + // corpse or escaped hero must not become permanent dungeon geometry. This is especially + // important at the single exit tile: Co-op and Score Attack require multiple players to pass + // through it on different ticks. Live, in-play players remain solid (or attackable in PvP). + _isBlockingOccupantAt(x, y, excludeId) { + for (const occ of this.occupants.values()) { + if (occ.id === excludeId || occ.x !== x || occ.y !== y) continue; + const state = this.playerStates.get(occ.id); + // Only an explicitly terminal state becomes pass-through. An unexpected occupant + // without match state stays solid instead of failing open into an overlap. + if (!state || (state.alive && !state.finished)) return true; + } + return false; + } + _checkResolution() { const events = []; const exit = this.dungeon.exit; @@ -571,10 +623,12 @@ class MatchRoom extends Room { r.state.score = this._calculateScore(r.state, i + 1, r.dist); } - // Winner consistency across ALL end paths: the payout target (winnerId) and the - // leaderboard's placement #1 always name the SAME player. finalize() is the single source - // of truth — it runs after expire() in every path, reconciling any provisional winner. - if (ranked.length > 0) this.winnerId = ranked[0].id; + // Competitive winner consistency across ALL end paths: the payout target (winnerId) and + // placement #1 name the same player. Co-op is deliberately collective and therefore has + // no individual winnerId; placements remain useful progress/order metadata. + const collective = this.ruleset.mode === 'coop' + || this.ruleset.winCondition.type === 'all-escape'; + this.winnerId = collective ? null : (ranked[0]?.id || null); return this.playerStates; } @@ -624,6 +678,7 @@ class MatchRoom extends Room { return { matchId: this.id, economy: this.economy, + ruleset: this._rulesetSummary(), tick: this.tickCount, status: this.status, visibleTiles: visible, @@ -664,6 +719,7 @@ class MatchRoom extends Room { ...super.snapshot(), economy: this.economy, variant: this.variant, + ruleset: this._rulesetSummary(), tick: this.tickCount, status: this.status, treasure: this.treasure, @@ -682,9 +738,50 @@ class MatchRoom extends Room { walkable: this.walkable, dungeon: this.dungeon, seedHash: this.seedHash, + fairness: this.fairnessProof(this.status === 'finished'), gameState: this.toGameState(viewerId) }; } + + fairnessProof(reveal = false) { + const derivation = this.seedDerivation || { version: 'server-random-v1' }; + const revealed = reveal && this.status === 'finished'; + const futureBlockFreeze = derivation.version === 'future-chain-block-v2'; + return { + scope: futureBlockFreeze + ? 'future-block seed after durable entrant freeze' + : (derivation.version === 'chain-block-v1' + ? 'chain-derived entrant-locked seed' + : 'server commitment only'), + version: derivation.version, + seedHash: this.seedHash, + seed: revealed ? this.seed : null, + blockHeight: derivation.blockHeight ?? this.startBlockHeight ?? null, + blockHash: derivation.blockHash || null, + freezeBlockHeight: derivation.freezeBlockHeight ?? null, + targetBlockHeight: derivation.targetBlockHeight ?? null, + entropyDelayBlocks: derivation.entropyDelayBlocks ?? null, + entropyConfirmations: derivation.entropyConfirmations ?? null, + minimumConfirmedTipHeight: derivation.minimumConfirmedTipHeight ?? null, + precommitTipHeight: derivation.precommitTipHeight ?? null, + precommitVerifiedAt: derivation.precommitVerifiedAt ?? null, + freezeCommitment: derivation.freezeCommitment || null, + economy: derivation.economy || this.economy, + rulesetId: derivation.rulesetId || this.ruleset.id, + queueEntryIds: revealed ? (derivation.queueEntryIds || null) : null + }; + } + + _rulesetSummary() { + return { + id: this.ruleset.id, + label: this.ruleset.label, + mode: this.ruleset.mode, + description: this.ruleset.metadata?.description || '', + winCondition: this.ruleset.winCondition.type, + pvpCombat: this.ruleset.entities.pvpCombat + }; + } } module.exports = MatchRoom; diff --git a/src/multiplayer/MatchState.js b/src/multiplayer/MatchState.js index 6995c11..e258b1a 100644 --- a/src/multiplayer/MatchState.js +++ b/src/multiplayer/MatchState.js @@ -88,11 +88,14 @@ class MatchState { status: room.status, economy: room.economy, variant: room.variant, + ruleset_id: room.ruleset?.id || 'race', difficulty_preset: room.difficultyPreset, max_players: room.maxPlayers, seed_hash: room.seedHash, seed: room.status === 'finished' ? room.seed : null, - dungeon: room.dungeon, + dungeon: room.seedDerivation + ? { ...room.dungeon, match_fairness: room.seedDerivation } + : room.dungeon, start_block_height: room.startBlockHeight, started_at: room.startedAt ? new Date(room.startedAt) : null, ended_at: room.endedAt ? new Date(room.endedAt) : null, diff --git a/src/network/addressManager.js b/src/network/addressManager.js index 2b668e5..253f972 100644 --- a/src/network/addressManager.js +++ b/src/network/addressManager.js @@ -5,7 +5,12 @@ */ const { AppError, normalizeError } = require('../utils/errors'); -const ADDRESS_REGEX = /((?:4|8)[1-9A-HJ-NP-Za-km-z]{90,110}|(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})/; +// Candidate extraction accepts every Monero nettype; isValidAddress() then restricts XMR to the +// configured network (mainnet 4/8, stagenet 5/7, testnet 9/B). Wallet RPC performs the final +// checksum + nettype validation before persistence. +const ADDRESS_REGEX = /((?:4|8|5|7|9|B)[1-9A-HJ-NP-Za-km-z]{90,110}|(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})/; +const XMR_ADDRESS_REGEX = /^(?:4|8|5|7|9|B)[1-9A-HJ-NP-Za-km-z]{90,110}$/; +const WOW_ADDRESS_REGEX = /^(?:(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})$/; class AddressManager { constructor({ gameModeManager, broadcastManager, io, debugManager, onConfirmed = null }) { @@ -28,9 +33,39 @@ class AddressManager { isValidAddress(address) { const value = typeof address === 'string' ? address.trim() : ''; if (!value) return false; - if (!ADDRESS_REGEX.test(value)) return false; // Basic length guard for sanity (allowing integrated addresses) - return value.length >= 80 && value.length <= 120; + if (value.length < 80 || value.length > 120) return false; + + const currency = String(this.gameModeManager?.cryptoType || '').toUpperCase(); + if (currency === 'XMR') { + if (!XMR_ADDRESS_REGEX.test(value)) return false; + const network = String(this.gameModeManager?.network || 'mainnet').toLowerCase(); + const prefixes = network === 'stagenet' ? new Set(['5', '7']) + : (network === 'testnet' ? new Set(['9', 'B']) : new Set(['4', '8'])); + return prefixes.has(value[0]); + } + if (currency === 'WOW') return WOW_ADDRESS_REGEX.test(value); + return ADDRESS_REGEX.test(value); + } + + async _validateWithWallet(address) { + if (!this.gameModeManager || typeof this.gameModeManager.validatePayoutAddress !== 'function') { + return true; // legacy/injected managers retain syntax-only behavior + } + let result; + try { + result = await this.gameModeManager.validatePayoutAddress(address); + } catch (error) { + throw new AppError('Payout address validation failed', { + safeMessage: 'Could not verify that address with the configured wallet network. Try again.' + }); + } + if (!result || result.valid !== true) { + throw new AppError('Payout address is invalid for the configured wallet network', { + safeMessage: 'That address is not valid for this server\'s configured network.' + }); + } + return true; } async handleDetection(socketId, address) { @@ -70,7 +105,8 @@ class AddressManager { this.pending.set(socketId, trimmed); if (autoConfirm) { - await this.confirm(socketId, true); + const saved = await this.confirm(socketId, true); + if (!saved) return false; } return trimmed; @@ -80,7 +116,7 @@ class AddressManager { const pending = this.pending.get(socketId); if (!pending) { this.broadcastManager?.sendStatusUpdate(socketId, 'warning', 'No address pending. Paste an address first.'); - return; + return false; } if (!accept) { this.pending.delete(socketId); @@ -89,17 +125,22 @@ class AddressManager { if (this.onConfirmed) { try { this.onConfirmed(socketId, false, pending); } catch(_) {} } - return; + return true; } if (this.gameModeManager) { try { - await this.gameModeManager.setUserPayoutAddress(socketId, pending); + await this._validateWithWallet(pending); + const saved = await this.gameModeManager.setUserPayoutAddress(socketId, pending); + if (saved !== true) throw new Error('Payout address was not persisted'); // this.broadcastManager?.sendStatusUpdate(socketId, 'success', 'Payout address saved.'); // Removed duplicate this.io.to(socketId).emit('address_confirmed', { address: pending, message: 'Payout address saved.' }); } catch (e) { const normalized = normalizeError?.(e, 'Failed to save address') || e; this.broadcastManager?.sendStatusUpdate(socketId, 'error', normalized.safeMessage || 'Failed to save address. Try again.'); - return; + this.io.to(socketId).emit('address_update_error', { + message: normalized.safeMessage || 'Failed to save address. Try again.' + }); + return false; } finally { this.pending.delete(socketId); } @@ -111,13 +152,17 @@ class AddressManager { if (this.onConfirmed) { try { this.onConfirmed(socketId, true, pending); } catch(_) {} } + return true; } requiresPayoutAddress() { if (!this.gameModeManager) return false; // free mode => no requirement const mode = this.gameModeManager.gameMode; + if (typeof this.gameModeManager.requiresPayoutAddressForMode === 'function') { + return this.gameModeManager.requiresPayoutAddressForMode(mode); + } return (mode === 'PAID_SINGLE') || (mode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled); } } -module.exports = AddressManager; \ No newline at end of file +module.exports = AddressManager; diff --git a/src/network/connectionHandler.js b/src/network/connectionHandler.js index edec891..7816b79 100644 --- a/src/network/connectionHandler.js +++ b/src/network/connectionHandler.js @@ -234,11 +234,10 @@ class ConnectionHandler { _getResumeToken(socket) { try { - // Prefer the handshake `auth` payload (not logged by proxies); fall back to the - // query string for backward compatibility with older clients during rollout. - return socket.handshake.auth?.resumeToken - || socket.handshake.query?.resumeToken - || null; + // Session credentials belong only in Socket.IO's handshake auth payload. Query + // strings are routinely retained by reverse-proxy/access logs and must never resume + // an account, even when an older client still sends one there. + return socket.handshake.auth?.resumeToken || null; } catch (_) { return null; } @@ -257,6 +256,10 @@ class ConnectionHandler { }); } catch (e) { console.error('Session initialization failed:', e.message); + // A compare-and-swap loser presented a credential another concurrent connection + // already consumed. Do not downgrade it into an anonymous live socket: the outer + // connection handler will emit a generic error and disconnect it. + if (e.code === 'SESSION_TOKEN_REPLAY') throw e; // For database connection issues, allow connection but without session features if (e.message.includes('Database not connected')) { console.log('⚠️ Database not ready, allowing connection without session features'); diff --git a/src/network/matchEconomyPolicy.js b/src/network/matchEconomyPolicy.js new file mode 100644 index 0000000..8b6850d --- /dev/null +++ b/src/network/matchEconomyPolicy.js @@ -0,0 +1,124 @@ +/** + * Match economy admission policy. + * + * Crypto matches are materially different from free/prestige matches: consuming a ticket + * promises one on-chain winner payout. Keep every admission caller on the same fail-closed + * contract so the queue, scheduler, and payout snapshot cannot disagree. + */ + +const { resolveMatchRuleset } = require('../game/rulesets'); + +const CRYPTO_WIN_CONDITIONS = new Set(['first-to-exit', 'last-alive', 'high-score']); +const PG_BIGINT_MAX = 9223372036854775807n; + +function isExplicitTrue(value) { + return String(value ?? '').trim().toLowerCase() === 'true'; +} + +function parsePositiveAtomic(value) { + const raw = String(value ?? '').trim().replace(/_/g, ''); + if (!/^\d+$/.test(raw)) return null; + try { + const parsed = BigInt(raw); + return parsed > 0n && parsed <= PG_BIGINT_MAX ? parsed : null; + } catch (_) { + return null; + } +} + +function parseHouseFeeBasisPoints(value) { + const raw = String(value ?? '').trim(); + if (!/^\d+(?:\.\d{1,2})?$/.test(raw)) return null; + const percent = Number(raw); + if (!Number.isFinite(percent) || percent < 0 || percent >= 100) return null; + return Math.round(percent * 100); +} + +function playerContract(ruleset, requestedMaxPlayers) { + const ruleMin = Number.isInteger(ruleset?.players?.min) ? ruleset.players.min : 2; + const ruleMax = Number.isInteger(ruleset?.players?.max) ? ruleset.players.max : 4; + const hasConfiguredMax = requestedMaxPlayers !== null + && requestedMaxPlayers !== undefined + && String(requestedMaxPlayers).trim() !== ''; + const rawConfiguredMax = String(requestedMaxPlayers ?? '').trim(); + const configured = /^\d+$/.test(rawConfiguredMax) ? Number(rawConfiguredMax) : NaN; + const configuredIsValidInteger = Number.isSafeInteger(configured); + const requestedMax = configuredIsValidInteger ? configured : ruleMax; + // Match persistence and transport require at least two entrants even when a ruleset (such + // as score attack) is also valid as a standalone one-player challenge. + const minPlayers = Math.max(2, ruleMin); + const maxPlayers = Math.max(2, Math.min(32, ruleMax, requestedMax)); + return { + minPlayers, + maxPlayers, + valid: (!hasConfiguredMax || configuredIsValidInteger) + && minPlayers <= maxPlayers + && requestedMax >= minPlayers + && requestedMax <= 32 + }; +} + +function cryptoRulesetSupported(ruleset) { + return !!ruleset + && ruleset.mode !== 'solo' + && ruleset.mode !== 'coop' + && CRYPTO_WIN_CONDITIONS.has(ruleset.winCondition?.type) + && Number(ruleset.players?.max) >= 2; +} + +function matchPayoutAdmissionPolicy({ + env = process.env, + gameModeManager = null, + ruleset = null, + requestedMaxPlayers = null +} = {}) { + const resolvedRuleset = ruleset || resolveMatchRuleset(env.MATCH_RULESET_ID || 'race'); + const players = playerContract( + resolvedRuleset, + requestedMaxPlayers ?? env.MATCH_MAX_PLAYERS ?? resolvedRuleset.players.max + ); + const payoutCap = parsePositiveAtomic(env.MATCH_PAYOUT_MAX); + const entryFee = parsePositiveAtomic(env.MATCH_ENTRY_FEE_ATOMIC); + const houseFeeBasisPoints = parseHouseFeeBasisPoints(env.MATCH_HOUSE_FEE_PERCENT); + const masterPayoutsEnabled = gameModeManager + ? gameModeManager.payoutsEnabled === true + : isExplicitTrue(env.PAYOUTS_ENABLED); + const paidProductAvailable = gameModeManager + ? !!(gameModeManager.directModeEnabled || gameModeManager.creditsModeEnabled) + : isExplicitTrue(env.PAYMENTS_ENABLED); + + const checks = [ + [isExplicitTrue(env.MATCH_ENABLED), 'match_disabled'], + [isExplicitTrue(env.MATCH_CRYPTO_RACE_ENABLED), 'crypto_match_disabled'], + [isExplicitTrue(env.MATCH_PAYOUTS_ENABLED), 'match_payouts_disabled'], + [masterPayoutsEnabled, 'payout_master_disabled'], + [paidProductAvailable, 'paid_products_disabled'], + [cryptoRulesetSupported(resolvedRuleset), 'unsupported_crypto_ruleset'], + [players.valid, 'invalid_player_contract'], + [payoutCap !== null, 'invalid_payout_cap'], + [entryFee !== null, 'invalid_entry_fee'], + [houseFeeBasisPoints !== null, 'invalid_house_fee'] + ]; + const failed = checks.find(([ok]) => !ok); + + return Object.freeze({ + enabled: !failed, + reason: failed ? failed[1] : null, + ruleset: resolvedRuleset, + minPlayers: players.minPlayers, + maxPlayers: players.maxPlayers, + payoutCap, + entryFee, + houseFeeBasisPoints + }); +} + +module.exports = { + PG_BIGINT_MAX, + cryptoRulesetSupported, + isExplicitTrue, + matchPayoutAdmissionPolicy, + parseHouseFeeBasisPoints, + parsePositiveAtomic, + playerContract +}; diff --git a/src/network/matchFairness.js b/src/network/matchFairness.js new file mode 100644 index 0000000..32c5e31 --- /dev/null +++ b/src/network/matchFairness.js @@ -0,0 +1,118 @@ +'use strict'; + +const crypto = require('crypto'); + +const LEGACY_PAID_FREEZE_VERSION = 'future-block-freeze-v1'; +const PAID_FREEZE_VERSION = 'future-block-freeze-v2'; +const PAID_SEED_VERSION = 'future-chain-block-v2'; +const PAID_ECONOMIES = new Set(['credits_prestige', 'crypto_race']); + +function invariant(condition, message) { + if (!condition) throw new Error(message); +} + +function canonicalQueueEntryIds(queueEntryIds) { + const ids = (queueEntryIds || []).map(id => String(id)); + invariant(ids.length >= 2 && ids.every(id => /^\d+$/.test(id)), + 'At least two durable numeric queue entry ids are required'); + invariant(new Set(ids).size === ids.length, 'Queue entry ids must be unique'); + return ids.sort((left, right) => { + if (left.length !== right.length) return left.length - right.length; + return left.localeCompare(right); + }); +} + +function blockHeight(value, label) { + const height = Number(value); + invariant(Number.isSafeInteger(height) && height >= 0, `${label} must be a safe block height`); + return height; +} + +function canonicalBlockHash(value) { + const normalized = String(value || '').trim().toLowerCase(); + invariant(/^[0-9a-f]{64}$/.test(normalized), 'A canonical 64-hex block hash is required'); + return normalized; +} + +function buildPaidEntrantFreeze({ + version = PAID_FREEZE_VERSION, + freezeBlockHeight, + targetBlockHeight, + entropyDelayBlocks = null, + economy, + rulesetId, + queueEntryIds +}) { + invariant(version === PAID_FREEZE_VERSION, 'Paid entrant freeze version is unsupported'); + const frozenAt = blockHeight(freezeBlockHeight, 'Freeze block height'); + const target = blockHeight(targetBlockHeight, 'Target block height'); + const delay = target - frozenAt; + invariant(Number.isSafeInteger(delay) && delay >= 1, + 'Paid seed block must be after the entrant freeze'); + if (entropyDelayBlocks != null) { + invariant(blockHeight(entropyDelayBlocks, 'Paid entropy delay') === delay, + 'Paid entropy delay does not match the committed block heights'); + } + invariant(PAID_ECONOMIES.has(String(economy)), 'Paid entrant freeze requires a paid economy'); + invariant(/^[a-z0-9][a-z0-9-]{0,63}$/.test(String(rulesetId || '')), + 'Paid entrant freeze requires a canonical ruleset id'); + const ids = canonicalQueueEntryIds(queueEntryIds); + const canonical = [ + 'wowngeon-paid-entrant-freeze', PAID_FREEZE_VERSION, String(frozenAt), String(target), + String(delay), + String(economy), String(rulesetId), ids.join(',') + ].join('|'); + const freezeCommitment = crypto.createHash('sha256').update(canonical).digest('hex'); + return Object.freeze({ + version: PAID_FREEZE_VERSION, + freezeBlockHeight: frozenAt, + targetBlockHeight: target, + entropyDelayBlocks: delay, + economy: String(economy), + rulesetId: String(rulesetId), + queueEntryIds: Object.freeze(ids), + freezeCommitment + }); +} + +function deriveFutureBlockMatchSeed({ blockHash, blockHeight: height, freeze }) { + const verifiedFreeze = buildPaidEntrantFreeze(freeze || {}); + const entropyHeight = blockHeight(height, 'Entropy block height'); + invariant(entropyHeight === verifiedFreeze.targetBlockHeight, + 'Paid seed must use the exact future block committed by the entrant freeze'); + if (freeze?.freezeCommitment != null) { + invariant(String(freeze.freezeCommitment) === verifiedFreeze.freezeCommitment, + 'Paid entrant freeze commitment does not match its durable inputs'); + } + const hash = canonicalBlockHash(blockHash); + const canonical = [ + 'wowngeon-match', PAID_SEED_VERSION, hash, String(entropyHeight), + verifiedFreeze.freezeCommitment, verifiedFreeze.economy, verifiedFreeze.rulesetId, + verifiedFreeze.queueEntryIds.join(',') + ].join('|'); + return Object.freeze({ + seed: crypto.createHash('sha256').update(canonical).digest('hex'), + derivation: Object.freeze({ + version: PAID_SEED_VERSION, + blockHash: hash, + blockHeight: entropyHeight, + freezeBlockHeight: verifiedFreeze.freezeBlockHeight, + targetBlockHeight: verifiedFreeze.targetBlockHeight, + entropyDelayBlocks: verifiedFreeze.entropyDelayBlocks, + freezeCommitment: verifiedFreeze.freezeCommitment, + economy: verifiedFreeze.economy, + rulesetId: verifiedFreeze.rulesetId, + queueEntryIds: verifiedFreeze.queueEntryIds + }) + }); +} + +module.exports = { + LEGACY_PAID_FREEZE_VERSION, + PAID_FREEZE_VERSION, + PAID_SEED_VERSION, + buildPaidEntrantFreeze, + canonicalBlockHash, + canonicalQueueEntryIds, + deriveFutureBlockMatchSeed +}; diff --git a/src/network/matchLeaderboard.js b/src/network/matchLeaderboard.js index 0c56045..0443958 100644 --- a/src/network/matchLeaderboard.js +++ b/src/network/matchLeaderboard.js @@ -4,12 +4,12 @@ * At the end of every match, this module decides which leaderboard each entrant's score * belongs to: * • free races → Pleb board (game_mode = 'FREE') - * • credits_prestige races → Prestige board (prestige_leaderboard view) + * • credits_prestige races → Prestige board (authoritative match tables) * • crypto_race races → Hall of Champions (games-like paid board) * * For free and crypto_race we insert a synthetic `games` row if the instance wants those - * scores surfaced on the existing boards. For prestige we rely on the `prestige_leaderboard` - * view driven directly from `match_entrants`. + * scores surfaced on the existing boards. The prestige route queries `matches` and + * `match_entrants` directly. */ class MatchLeaderboard { @@ -30,6 +30,12 @@ class MatchLeaderboard { async postMatch(room) { if (!this.db || room.status !== 'finished') return; + // The existing Pleb/Prestige/Champions boards rank individuals. Co-op has a collective + // success condition and therefore must not manufacture an individual winner from its + // placement order. Its authoritative result remains in matches/match_entrants until a + // dedicated team leaderboard exists. + if (this._isCoop(room)) return; + const economy = room.economy; if (economy === 'free') { @@ -41,6 +47,21 @@ class MatchLeaderboard { } } + _isCoop(room) { + return room?.ruleset?.mode === 'coop' + || room?.ruleset?.winCondition?.type === 'all-escape'; + } + + _entrantResult(room, socketId, state) { + // MatchRoom.finalize() makes these agree, but require both facts defensively: a stale or + // partially-built in-memory room must never award an individual leaderboard win. + const won = socketId === room.winnerId && Number(state.placement) === 1; + return { + status: won ? 'won' : 'lost', + outcome: won ? 'match_winner' : 'match_loss' + }; + } + async _postFreeToPleb(room) { // Insert synthetic solo-style records so free races show up on the Pleb board. // Columns are the REAL games columns (treasure_found, moves_made); dungeon_seed is the @@ -48,6 +69,7 @@ class MatchLeaderboard { await this.db.withTransaction(async (client) => { for (const [socketId, state] of room.playerStates.entries()) { if (!state.userId || state.score <= 0) continue; + const result = this._entrantResult(room, socketId, state); await client.query(` INSERT INTO games (user_id, socket_id, game_mode, status, outcome, treasure_found, moves_made, duration_seconds, score, dungeon_seed, created_at, completed_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) @@ -55,8 +77,8 @@ class MatchLeaderboard { state.userId, socketId, 'FREE', - state.escaped ? 'won' : 'lost', - state.escaped ? 'escaped' : (state.killedBy || 'timeout'), + result.status, + result.outcome, state.hasTreasure, state.moves, Math.max(1, Math.round(((room.endedAt || Date.now()) - (room.startedAt || room.createdAt)) / 1000)), @@ -74,8 +96,8 @@ class MatchLeaderboard { } async _postPrestige(room) { - // Scores are already persisted to match_entrants. The prestige_leaderboard view reads - // them directly, so we just broadcast an update so open leaderboards refresh. + // Scores are already persisted to match_entrants. The runtime prestige query reads them + // directly, so we just broadcast an update so open leaderboards refresh. this._broadcastUpdate('prestige'); } @@ -85,6 +107,7 @@ class MatchLeaderboard { await this.db.withTransaction(async (client) => { for (const [socketId, state] of room.playerStates.entries()) { if (!state.userId || state.score <= 0) continue; + const result = this._entrantResult(room, socketId, state); await client.query(` INSERT INTO games (user_id, socket_id, game_mode, status, outcome, treasure_found, moves_made, duration_seconds, score, dungeon_seed, created_at, completed_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) @@ -92,8 +115,8 @@ class MatchLeaderboard { state.userId, socketId, 'PAID_CREDITS', - state.escaped ? 'won' : 'lost', - state.escaped ? 'escaped' : (state.killedBy || 'timeout'), + result.status, + result.outcome, state.hasTreasure, state.moves, Math.max(1, Math.round(((room.endedAt || Date.now()) - (room.startedAt || room.createdAt)) / 1000)), diff --git a/src/network/matchManager.js b/src/network/matchManager.js index 19c60d5..0147750 100644 --- a/src/network/matchManager.js +++ b/src/network/matchManager.js @@ -14,12 +14,14 @@ const MatchState = require('../multiplayer/MatchState'); const MatchPayoutService = require('./matchPayoutService'); const MatchLeaderboard = require('./matchLeaderboard'); +const { createFinancialRecoveryError } = require('../utils/financialRecoveryError'); const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development'; const RECONNECT_GRACE_MS = 30000; // 30s to reconnect during an active match before AFK-kill const DEFAULT_COUNTDOWN_MS = 3000; const FINALIZE_CLEANUP_MS = 30000; // keep mappings this long after finish for late reconnects +const FINALIZE_RETRY_MS = 5000; // retry a nondurable finish without publishing a winner class MatchManager { constructor({ io, db, debugManager, identityService = null, gameModeManager = null, chatProvider = null } = {}) { @@ -37,6 +39,9 @@ class MatchManager { this.userToMatch = new Map(); // userId -> matchId this.disconnectTimeouts = new Map(); // `${matchId}:${socketId}` -> timeout id this._pendingCleanups = new Set(); // grace-cleanup timers awaiting fire + this.financialRecoveryReady = false; + this._liabilityReconcileTimer = null; + this._liabilityReconcileRunning = false; this.matchLeaderboard = new MatchLeaderboard({ db: this.db, io: this.io, debugManager: this.debugManager }); this.matchPayoutService = new MatchPayoutService({ @@ -60,7 +65,107 @@ class MatchManager { * state is empty on boot; abandoned in-flight matches are recovered and refunded by * MatchQueue.initialize() (the money authority), so there is nothing to rebuild here. */ - initialize() { /* no-op: boot recovery lives in MatchQueue.initialize() */ } + async initialize() { + // Queue recovery resolves abandoned in-flight escrow first. Finished matches already + // have a durable winner + accepted liability snapshot, so reconstruct any payout row + // that a crash/transient DB error prevented us from inserting after completion. + // MATCH_ENABLED controls new rooms only. Reconciliation must run even when operators + // disable the entire match surface, because an already-finished accepted liability is + // still owed and may have crashed before its payout row was inserted. + if (!this.db || !this.matchPayoutService) { + throw createFinancialRecoveryError('finished_match_liabilities', { + scanFailed: true, + scanned: 0, + resolved: 0, + unresolved: [] + }); + } + await this._reconcileFinishedLiabilities({ failClosed: true }); + + // A payout insert can fail after a match finish has already committed. Re-scan in + // bounded batches so a transient runtime failure is retried without duplicating payout + // rows (the database's match_id uniqueness remains the exactly-once anchor). + const configuredMs = Number(process.env.MATCH_LIABILITY_RECONCILE_MS); + const intervalMs = Math.max(5000, Math.min( + 300000, + Number.isFinite(configuredMs) && configuredMs > 0 ? configuredMs : 30000 + )); + this._liabilityReconcileTimer = setInterval(() => { + this._reconcileFinishedLiabilities({ failClosed: false }).catch(error => { + this._log('[MatchManager] liability reconciliation timer error', error.message); + }); + }, intervalMs); + this._liabilityReconcileTimer.unref?.(); + } + + async _reconcileFinishedLiabilities({ failClosed = false, batchSize = 100, maxBatches = 100 } = {}) { + if (this._liabilityReconcileRunning) { + if (failClosed) { + throw createFinancialRecoveryError('finished_match_liabilities', { + scanned: 0, + resolved: 0, + unresolved: [{ type: 'reconciliation', id: 'already_running' }] + }); + } + return { ok: false, skipped: true, scanned: 0, created: 0, failed: 0, unresolved: [] }; + } + + const limit = Math.max(1, Math.min(1000, parseInt(batchSize, 10) || 100)); + const batchLimit = Math.max(1, Math.min(1000, parseInt(maxBatches, 10) || 100)); + const total = { ok: true, scanned: 0, created: 0, failed: 0, unresolved: [] }; + this._liabilityReconcileRunning = true; + try { + for (let batch = 0; batch < batchLimit; batch += 1) { + let result; + try { + result = await this.matchPayoutService.reconcileFinishedLiabilities({ limit }); + } catch (error) { + throw createFinancialRecoveryError('finished_match_liabilities', { + scanFailed: true, + scanned: total.scanned, + resolved: total.created, + unresolved: total.unresolved + }, error); + } + total.scanned += Number(result.scanned) || 0; + total.created += Number(result.created) || 0; + total.failed += Number(result.failed) || 0; + if (Array.isArray(result.unresolved)) total.unresolved.push(...result.unresolved); + + if (total.failed > 0 || result.ok === false) { + if (total.unresolved.length === 0) { + total.unresolved.push({ type: 'match_liability', id: 'unknown' }); + } + throw createFinancialRecoveryError('finished_match_liabilities', { + scanned: total.scanned, + resolved: total.created, + unresolved: total.unresolved + }); + } + if ((Number(result.scanned) || 0) < limit) { + this.financialRecoveryReady = true; + if (total.created > 0) { + this._log(`[MatchManager] liability reconciliation: ${total.created} created, 0 failed`); + } + return total; + } + } + + throw createFinancialRecoveryError('finished_match_liabilities', { + scanned: total.scanned, + resolved: total.created, + unresolved: [{ type: 'reconciliation_backlog', id: `>${limit * batchLimit}` }] + }); + } catch (error) { + this.financialRecoveryReady = false; + total.ok = false; + if (failClosed) throw error; + this._log('[MatchManager] liability reconciliation incomplete', error.message); + return total; + } finally { + this._liabilityReconcileRunning = false; + } + } /** * Attach a MatchRoom that MatchScheduler just created and (for crypto) already collected @@ -79,6 +184,10 @@ class MatchManager { } if (this.matchLeaderboard && this.db) this.matchLeaderboard.db = this.db; + room.minDurationMs = Math.max(0, Number(minDurationMs) || 0); + room.hardCeilingMs = Math.max(1, Number(hardCeilingMs) || 1); + room._minDurationMet = room.minDurationMs === 0; + this.rooms.set(room.id, room); for (const e of (entrants || [])) { this.socketToMatch.set(e.socketId, room.id); @@ -98,7 +207,9 @@ class MatchManager { socket.emit('match_joined', { matchId: room.id, economy: room.economy, + ruleset: room._rulesetSummary?.() || null, seedHash: room.seedHash, + fairness: room.fairnessProof?.(false) || null, players: Array.from(room.occupants.values()).map(o => o.getState()), countdownMs }); @@ -124,18 +235,32 @@ class MatchManager { [room.id] ).catch(err => this._log('[MatchManager] mark-active error', err.message)); } - this._broadcast(room.id, 'match_start', { matchId: room.id, tickMs, seedHash: r.seedHash }); - }, countdownMs); + this._broadcast(room.id, 'match_start', { + matchId: room.id, + tickMs, + seedHash: r.seedHash, + fairness: r.fairnessProof?.(false) || null, + ruleset: r._rulesetSummary?.() || null + }); - // Minimum-duration watchdog: prevents a next-block expiry before the floor. - const floorTimeout = setTimeout(() => { const r = this.rooms.get(room.id); if (r) r._minDurationMet = true; }, minDurationMs); + // The floor is active-play time, not queue/countdown time. The block handler also + // checks startedAt directly, avoiding a timer-order race when a block arrives exactly + // as the floor elapses. + if (!r._minDurationMet) { + const floorTimeout = setTimeout(() => { + const live = this.rooms.get(room.id); + if (live) live._minDurationMet = true; + }, r.minDurationMs); + if (Array.isArray(r._watchdogs)) r._watchdogs.push(floorTimeout); + } + }, countdownMs); // Hard-ceiling watchdog: absolute max match length. Single owner (the scheduler no // longer sets its own ceiling timer), cleared by _finalize so it never fires on a // finished/removed room. - const ceilingTimeout = setTimeout(() => this.expire(room.id, 'hard_ceiling'), hardCeilingMs); + const ceilingTimeout = setTimeout(() => this.expire(room.id, 'hard_ceiling'), room.hardCeilingMs); - room._watchdogs = [startTimer, floorTimeout, ceilingTimeout]; + room._watchdogs = [startTimer, ceilingTimeout]; } async _hydrateEntrants(room, entrants) { @@ -189,6 +314,35 @@ class MatchManager { }); } + /** + * End active block-bounded rooms on the first advancing header after their active-play floor. + * Rooms created by the same header are protected by the strict height comparison. + * @returns {number} number of rooms expired + */ + expireBlockDeadlines(blockHeight) { + const height = Number(blockHeight); + if (!Number.isSafeInteger(height) || height < 0) return 0; + + let expired = 0; + for (const room of Array.from(this.rooms.values())) { + if (!room || room.status !== 'active' || room._finalized) continue; + if (room.ruleset?.timing?.blockDeadline !== true) continue; + const startHeight = Number(room.startBlockHeight); + if (!Number.isSafeInteger(startHeight) || height <= startHeight) continue; + + const floorMs = Math.max(0, Number(room.minDurationMs) || 0); + const activeForMs = Number.isFinite(room.startedAt) ? Date.now() - room.startedAt : -1; + const floorMet = room._minDurationMet === true || activeForMs >= floorMs; + if (!floorMet) continue; + + room._minDurationMet = true; + room.endBlockHeight = height; + this.expire(room.id, 'block_deadline'); + expired += 1; + } + return expired; + } + /** * Called when a match ends naturally (engine detected finished). */ @@ -227,7 +381,19 @@ class MatchManager { async _finalize(room) { if (!room || room._finalized) return; - room._finalized = true; + // Engine callbacks, watchdogs, and disconnect handling can converge here together. Share + // one attempt so persistence/payout/leaderboard work never runs concurrently. + if (room._finalizePromise) return room._finalizePromise; + const attempt = this._finalizeAttempt(room); + room._finalizePromise = attempt; + try { + return await attempt; + } finally { + if (room._finalizePromise === attempt) room._finalizePromise = null; + } + } + + async _finalizeAttempt(room) { // Clear lifecycle watchdogs (countdown, floor, hard ceiling) so none fire post-finish. if (room._watchdogs) { room._watchdogs.forEach(id => clearTimeout(id)); room._watchdogs = null; } @@ -237,43 +403,64 @@ class MatchManager { const engine = this.engines.get(room.id); if (engine) { try { engine.stop(); } catch (_) {} } - // Each step is isolated in its OWN try/catch so a failure in one NEVER prevents the - // winner payout or the match_end broadcast. The winner PAYOUT runs FIRST. - // 1. Winner payout (crypto only). - if (room.economy === 'crypto_race') { - try { await this.matchPayoutService.payoutWinner(room); } - catch (err) { this._log('[MatchManager] payout error', err.message); } - } - // 2. Persist match + entrant results. + // Persist the winner first. Once that transaction commits, the accepted liability is + // fully reconstructable from SQL even if payout insertion fails or the process crashes. + let finishPersisted = !this.db; if (this.db) { - try { await this._persistFinish(room); } + try { + await this._persistFinish(room); + finishPersisted = true; + } catch (err) { this._log('[MatchManager] persist error', err.message); } } - // 3. Leaderboard integration. - try { await this.matchLeaderboard.postMatch(room); } - catch (err) { this._log('[MatchManager] leaderboard error', err.message); } - // 4. Broadcast the result (always attempted). + if (!finishPersisted) { + // This is deliberately not `match_end`: clients must not publish a winner which the + // authoritative database cannot prove. Keep the room mapped and retry the idempotent + // finish transaction; boot recovery remains the final fallback if the process exits. + try { + this._broadcast(room.id, 'match_settlement_pending', { + matchId: room.id, + code: 'MATCH_FINISH_NOT_DURABLE', + retrying: true + }); + } catch (err) { this._log('[MatchManager] pending broadcast error', err.message); } + this._scheduleFinalizeRetry(room); + return; + } + + room._finalized = true; + if (room._finalizeRetryTimer) { + clearTimeout(room._finalizeRetryTimer); + this._pendingCleanups.delete(room._finalizeRetryTimer); + room._finalizeRetryTimer = null; + } + // Insert from the immutable admission snapshot, not current MATCH_PAYOUTS_ENABLED. A + // failure is retried by initialize() reconciliation because the finished winner is now + // durable. Never create a payout when the finish itself did not persist. + if (room.economy === 'crypto_race' && finishPersisted) { + try { await this.matchPayoutService.payoutWinner(room); } + catch (err) { + // The match result and accepted liability are already durable. Close every paid + // admission surface immediately, then attempt the idempotent SQL reconstruction; + // the bounded timer remains the fallback if this immediate pass also fails. + this.financialRecoveryReady = false; + this._log('[MatchManager] payout error', err.message); + await this._reconcileFinishedLiabilities({ failClosed: false }); + } + } + // Leaderboard rows are derived from the durable match result. Never create synthetic + // `games` rows (or announce a prestige refresh) when the finish transaction failed: that + // would publish a result which the authoritative matches tables do not contain. + if (finishPersisted) { + try { await this.matchLeaderboard.postMatch(room); } + catch (err) { this._log('[MatchManager] leaderboard error', err.message); } + } + // Only a durable result is announced as final. try { - this._broadcast(room.id, 'match_end', { - matchId: room.id, - reason: room.endReason, - winnerId: room.winnerId, - players: Array.from(room.playerStates.entries()).map(([id, state]) => { - const occ = room.occupants.get(id); - return { - id, - name: occ?.name || null, - placement: state.placement, - escaped: state.escaped, - hasTreasure: state.hasTreasure, - score: state.score, - killedBy: state.killedBy - }; - }) - }); + this._broadcast(room.id, 'match_end', this._terminalPayload(room)); } catch (err) { this._log('[MatchManager] broadcast error', err.message); } - // 5. Drop all mappings after a grace period so late reconnects still resolve the result. + // Drop all mappings after a grace period so late reconnects still resolve the result. const cleanupTimer = setTimeout(() => { this._pendingCleanups.delete(cleanupTimer); this._cleanup(room.id); @@ -281,9 +468,52 @@ class MatchManager { this._pendingCleanups.add(cleanupTimer); } + _scheduleFinalizeRetry(room) { + if (!room || room._finalized || room._finalizeRetryTimer) return; + const retryTimer = setTimeout(() => { + this._pendingCleanups.delete(retryTimer); + if (room._finalizeRetryTimer === retryTimer) room._finalizeRetryTimer = null; + if (!this.rooms.has(room.id) || room._finalized) return; + this._finalize(room).catch(err => this._log('[MatchManager] finalize retry error', err.message)); + }, FINALIZE_RETRY_MS); + room._finalizeRetryTimer = retryTimer; + this._pendingCleanups.add(retryTimer); + } + + _terminalPayload(room) { + return { + matchId: room.id, + reason: room.endReason, + winnerId: room.winnerId, + ruleset: room._rulesetSummary?.() || null, + fairness: room.fairnessProof?.(true) || null, + players: Array.from(room.playerStates.entries()).map(([id, state]) => { + const occ = room.occupants.get(id); + return { + id, + name: occ?.name || null, + placement: state.placement, + escaped: state.escaped, + hasTreasure: state.hasTreasure, + score: state.score, + killedBy: state.killedBy + }; + }) + }; + } + + _emitTerminalToReconnect(socket, room) { + if (!socket || !room?._finalized) return false; + if (!room._terminalDeliveredSocketIds) room._terminalDeliveredSocketIds = new Set(); + if (room._terminalDeliveredSocketIds.has(socket.id)) return false; + socket.emit('match_end', this._terminalPayload(room)); + room._terminalDeliveredSocketIds.add(socket.id); + return true; + } + async _persistFinish(room) { await this.db.withTransaction(async (client) => { - await client.query(` + const finished = await client.query(` UPDATE matches SET status = 'finished', seed = $1, @@ -299,6 +529,9 @@ class MatchManager { room.winnerId ? room.playerStates.get(room.winnerId)?.userId : null, room.id ]); + if (finished.rowCount !== 1) { + throw new Error(`Match finish persistence missed row ${room.id}`); + } for (const [socketId, state] of room.playerStates.entries()) { await client.query(` @@ -526,6 +759,20 @@ class MatchManager { state: room.toGameState(newId) }); + // A finalized room is retained briefly precisely so a reconnecting player can recover its + // result. Deliver the same terminal payload immediately, once per socket. If persistence + // is still retrying, withhold the winner and tell this socket settlement is pending. + if (room.status === 'finished') { + if (room._finalized) this._emitTerminalToReconnect(socket, room); + else { + socket.emit('match_settlement_pending', { + matchId: room.id, + code: 'MATCH_FINISH_NOT_DURABLE', + retrying: true + }); + } + } + return true; } @@ -549,6 +796,11 @@ class MatchManager { } shutdown() { + this.financialRecoveryReady = false; + if (this._liabilityReconcileTimer) { + clearInterval(this._liabilityReconcileTimer); + this._liabilityReconcileTimer = null; + } for (const [, timeout] of this.disconnectTimeouts.entries()) clearTimeout(timeout); this.disconnectTimeouts.clear(); for (const t of this._pendingCleanups) clearTimeout(t); diff --git a/src/network/matchPayoutService.js b/src/network/matchPayoutService.js index 50f89fb..febc1c6 100644 --- a/src/network/matchPayoutService.js +++ b/src/network/matchPayoutService.js @@ -12,25 +12,60 @@ */ const { normalizeError } = require('../utils/errors'); +const { PG_BIGINT_MAX, matchPayoutAdmissionPolicy } = require('./matchEconomyPolicy'); +const { reservePayoutCapacity } = require('../services/payoutAdmissionService'); // Sentinel address for a winner who has not set a payout address yet. The payout row is stored // as 'needs_review' so the pot is a durable, queryable liability the winner can claim once they // provide an address — the house never silently keeps it, and the batcher never sends to this. const NO_ADDRESS_SENTINEL = 'PENDING_NO_ADDRESS'; -const DEFAULT_ENTRY_FEE_ATOMIC = 5000000000n; // 0.05 WOW at 11 decimals; only a last-resort default class MatchPayoutService { - constructor({ db, walletService, gameModeManager, debugManager } = {}) { + constructor({ db, walletService, gameModeManager, debugManager, env = process.env } = {}) { this.db = db; this.walletService = walletService; this.gameModeManager = gameModeManager; this.debugManager = debugManager; + this.env = env; } _log(...args) { if (this.debugManager?.CONSOLE_LOGGING) console.log(...args); } + _admissionPolicy(room) { + return matchPayoutAdmissionPolicy({ + env: this.env, + gameModeManager: this.gameModeManager, + ruleset: room?.ruleset, + requestedMaxPlayers: room?.maxPlayers + }); + } + + _admissionError(reason) { + const error = new Error(`Crypto match payout admission rejected: ${reason}`); + error.code = String(reason || 'match_payout_admission_rejected').toUpperCase(); + return error; + } + + _validateEntrants(room, entrants, policy) { + const list = Array.isArray(entrants) ? entrants : []; + if (list.length < policy.minPlayers || list.length > policy.maxPlayers) { + throw this._admissionError('invalid_player_count'); + } + const userIds = list.map(entrant => entrant?.userId); + if (userIds.some(userId => userId == null) || new Set(userIds.map(String)).size !== userIds.length) { + throw this._admissionError('invalid_entrants'); + } + const queueEntryIds = list.map(entrant => entrant?.queueEntryId); + if (queueEntryIds.some(id => id == null) || new Set(queueEntryIds.map(String)).size !== queueEntryIds.length) { + throw this._admissionError('invalid_queue_entries'); + } + if (room?.playerStates && room.playerStates.size !== list.length) { + throw this._admissionError('entrant_state_mismatch'); + } + } + /** * Called by MatchScheduler BEFORE a crypto_race match is announced/started. Commits each * entrant's held queue-join ticket to this match, records the ACTUAL collected pot, and @@ -40,14 +75,99 @@ class MatchPayoutService { async collectEntryTickets(room, entrants) { if (!this.db || !room || room.economy !== 'crypto_race') return; - const entryFee = this._entryFeeAtomic(); // BigInt atomic units per entry - const houseFeeBp = this._houseFeeBasisPoints(); // integer basis points (0..10000) + const policy = this._admissionPolicy(room); + if (!policy.enabled) throw this._admissionError(policy.reason); + this._validateEntrants(room, entrants, policy); + + const entryFee = policy.entryFee; // required funded value per ticket + const houseFeeBp = policy.houseFeeBasisPoints; // integer basis points (0..9999) const count = BigInt((entrants && entrants.length) || 0); - const pot = entryFee * count; // ACTUAL collected pot (BigInt) - const houseFee = (pot * BigInt(houseFeeBp)) / 10000n; // floor via integer math — no float const housePercent = houseFeeBp / 100; // for the DECIMAL(5,2) audit column - await this.db.withTransaction(async (client) => { + // Fast outer-bound check before opening the commitment transaction. The authoritative + // amount below is summed from locked paid ticket lots, never fabricated from this config. + const configuredPot = entryFee * count; + const configuredFee = (configuredPot * BigInt(houseFeeBp)) / 10000n; + const configuredLiability = configuredPot - configuredFee; + if (configuredPot > PG_BIGINT_MAX) { + throw this._admissionError('pot_overflow'); + } + if (configuredPot - configuredFee <= 0n || configuredPot - configuredFee > policy.payoutCap) { + throw this._admissionError('payout_cap_exceeded'); + } + + const acceptedSnapshot = await this.db.withTransaction(async (client) => { + // Acquire the shared bankroll-admission lock before any per-match row lock. This + // preserves one lock order across solo and match starts and prevents concurrent + // commitments from all passing against the same unlocked wallet snapshot. + await reservePayoutCapacity({ + client, + walletService: this.walletService, + newLiability: configuredLiability, + gameModeManager: this.gameModeManager, + env: this.env + }); + const queueEntryIds = entrants.map(entrant => entrant.queueEntryId); + const backingResult = await client.query(` + SELECT id, user_id, race_entry_lot_id, escrow_amount, escrow_value_atomic + FROM match_queue_entries + WHERE id = ANY($1::bigint[]) + AND match_id = $2 + AND economy = 'crypto_race' + AND status = 'matched' + ORDER BY id ASC + FOR UPDATE + `, [queueEntryIds, room.id]); + if (backingResult.rowCount !== queueEntryIds.length) { + throw this._admissionError('queue_backing_mismatch'); + } + + const backingById = new Map(backingResult.rows.map(row => [String(row.id), row])); + let pot = 0n; + const ticketBacking = []; + for (const entrant of entrants) { + const backing = backingById.get(String(entrant.queueEntryId)); + let value; + try { value = BigInt(backing?.escrow_value_atomic); } + catch (_) { throw this._admissionError('invalid_ticket_backing'); } + if (!backing + || String(backing.user_id) !== String(entrant.userId) + || Number(backing.escrow_amount) !== 1 + || backing.race_entry_lot_id == null + || value !== entryFee) { + throw this._admissionError('invalid_ticket_backing'); + } + pot += value; + ticketBacking.push({ + queueEntryId: String(backing.id), + raceEntryLotId: String(backing.race_entry_lot_id), + valueAtomic: value.toString() + }); + } + const houseFee = (pot * BigInt(houseFeeBp)) / 10000n; + const liabilityAmount = pot - houseFee; + if (liabilityAmount <= 0n || liabilityAmount > policy.payoutCap) { + throw this._admissionError('payout_cap_exceeded'); + } + + const terms = { + version: 2, + model: 'winner-takes-funded-ticket-pool-minus-house-fee', + funding: 'confirmed-payment-ticket-lots', + rulesetId: room.ruleset?.id || 'race', + winCondition: room.ruleset?.winCondition?.type || 'first-to-exit', + playerCount: Number(count), + ticketBacking, + entryFeeAtomic: entryFee.toString(), + potAtomic: pot.toString(), + houseFeeAtomic: houseFee.toString(), + houseFeeBasisPoints: houseFeeBp, + payoutAmountAtomic: liabilityAmount.toString(), + payoutCapAtomic: policy.payoutCap.toString(), + cryptoType: String(room.cryptoType || this.env.CRYPTO_TYPE || 'WOW').toUpperCase(), + network: String(this.env.MONERO_NETWORK || 'mainnet').toLowerCase() + }; + for (const entrant of (entrants || [])) { // Commit the held ticket to this match (delta 0 — it was already deducted at // queue-join; this row records the commitment for the audit trail). @@ -58,33 +178,106 @@ class MatchPayoutService { ), 'match_start', $2) `, [entrant.userId, room.id]); - await client.query(` + const consumed = await client.query(` UPDATE match_entrants SET entry_consumed = TRUE - WHERE match_id = $1 AND user_id = $2 + WHERE match_id = $1 AND user_id = $2 AND entry_consumed = FALSE + RETURNING id `, [room.id, entrant.userId]); + if (consumed.rowCount !== 1) { + throw this._admissionError('entrant_commit_mismatch'); + } + + const queueConsumed = await client.query(` + UPDATE match_queue_entries + SET status = 'consumed', consumed_at = NOW() + WHERE id = $1 AND match_id = $2 AND user_id = $3 + AND economy = 'crypto_race' AND status = 'matched' + RETURNING id + `, [entrant.queueEntryId, room.id, entrant.userId]); + if (queueConsumed.rowCount !== 1) { + throw this._admissionError('queue_commit_mismatch'); + } } - // Lock the true pot / house fee on the match row (BigInt passed as strings so the - // BIGINT columns are exact). - await client.query(` + // Lock the true pot, fee, cap, and accepted liability in the SAME transaction as + // ticket commitment. The migration makes this snapshot immutable after acceptance. + const accepted = await client.query(` UPDATE matches SET entry_fee_atomic = $1, pot_atomic = $2, house_fee_atomic = $3, - house_fee_percent = $4 - WHERE id = $5 - `, [entryFee.toString(), pot.toString(), houseFee.toString(), housePercent, room.id]); + house_fee_percent = $4, + payout_liability_amount_atomic = $5, + payout_liability_cap_atomic = $6, + payout_liability_terms = $7::jsonb, + payout_liability_accepted_at = NOW() + WHERE id = $8 + AND economy = 'crypto_race' + AND status = 'starting' + AND payout_liability_accepted_at IS NULL + RETURNING id, payout_liability_accepted_at + `, [ + entryFee.toString(), + pot.toString(), + houseFee.toString(), + housePercent, + liabilityAmount.toString(), + policy.payoutCap.toString(), + JSON.stringify(terms), + room.id + ]); + if (accepted.rowCount !== 1) { + throw this._admissionError('liability_snapshot_failed'); + } + return { pot, houseFee, liabilityAmount, terms }; }); - // In-memory bookkeeping (informational). The authoritative amounts live on the match - // row and are re-read (as BigInt) at payout time. - room.entryFeeAtomic = Number(entryFee); - room.potAtomic = Number(pot); - room.houseFeeAtomic = Number(houseFee); + // Atomic amounts stay decimal strings in memory so persistence/debug snapshots never + // round values above Number.MAX_SAFE_INTEGER. The authoritative values live in SQL. + room.entryFeeAtomic = entryFee.toString(); + room.potAtomic = acceptedSnapshot.pot.toString(); + room.houseFeeAtomic = acceptedSnapshot.houseFee.toString(); room.houseFeePercent = housePercent; + room.payoutLiabilityAmountAtomic = acceptedSnapshot.liabilityAmount.toString(); + room.payoutLiabilityCapAtomic = policy.payoutCap.toString(); + room.payoutLiabilityTerms = acceptedSnapshot.terms; - this._log(`[MatchPayoutService] locked pot ${pot} (house fee ${houseFee}, ${housePercent}%) for match ${room.id}`); + this._log(`[MatchPayoutService] accepted liability ${acceptedSnapshot.liabilityAmount} from funded pool ${acceptedSnapshot.pot} (house fee ${acceptedSnapshot.houseFee}, ${housePercent}%) for match ${room.id}`); + } + + /** + * Resolve an ambiguous transaction acknowledgement. A database connection can fail while + * COMMIT is being acknowledged; the caller must inspect the durable snapshot before deciding + * to refund/abort, otherwise it could cancel a committed liability without returning tickets. + */ + async getAcceptedLiability(matchId) { + if (!this.db || !matchId) return null; + const result = await this.db.query(` + SELECT entry_fee_atomic, pot_atomic, house_fee_atomic, house_fee_percent, + payout_liability_amount_atomic, payout_liability_cap_atomic, + payout_liability_terms, payout_liability_accepted_at + FROM matches + WHERE id = $1 + AND economy = 'crypto_race' + AND status IN ('starting', 'active') + AND payout_liability_accepted_at IS NOT NULL + LIMIT 1 + `, [matchId]); + const row = result.rows?.[0]; + if (!row) return null; + try { + const amount = BigInt(row.payout_liability_amount_atomic); + const cap = BigInt(row.payout_liability_cap_atomic); + const pot = BigInt(row.pot_atomic); + const fee = BigInt(row.house_fee_atomic); + if (amount <= 0n || cap <= 0n || amount > cap || pot <= 0n || fee < 0n || amount !== pot - fee) { + return null; + } + } catch (_) { + return null; + } + return row; } /** @@ -94,114 +287,132 @@ class MatchPayoutService { */ async payoutWinner(room) { if (!this.db || !room || room.economy !== 'crypto_race' || !room.winnerId) return; + try { + const result = await this.db.withTransaction(client => this._createPayoutForMatch(client, room.id)); + if (result.created && result.status === 'pending') { + // Trigger the existing batch payout processor (5s debounce). + if (this.gameModeManager && typeof this.gameModeManager._scheduleBatchPayout === 'function') { + this.gameModeManager._scheduleBatchPayout(); + } + } + return result; + } catch (err) { + const normalized = normalizeError(err, 'Failed to queue match payout'); + this._log('[MatchPayoutService] payout error:', normalized.message); + throw normalized; + } + } - const winnerState = room.playerStates.get(room.winnerId); - if (!winnerState || winnerState.userId == null) return; - - // Authoritative pot / fee from the match row (locked at collectEntryTickets). - const matchRow = await this.db.query(` - SELECT pot_atomic, house_fee_atomic + /** + * Create the payout promised by an accepted snapshot. No current admission flag is read: + * MATCH_PAYOUTS_ENABLED can stop new matches, but cannot erase an existing liability. + * The match's finished winner and immutable amount are authoritative, not in-memory values. + */ + async _createPayoutForMatch(client, matchId) { + const matchResult = await client.query(` + SELECT id, economy, status, winner_user_id, + payout_liability_amount_atomic, payout_liability_cap_atomic, + payout_liability_accepted_at FROM matches WHERE id = $1 - `, [room.id]); - if (!matchRow.rows || matchRow.rows.length === 0) return; + FOR UPDATE + `, [matchId]); + const match = matchResult.rows?.[0]; + if (!match || match.economy !== 'crypto_race' || match.status !== 'finished' || match.winner_user_id == null) { + return { created: false, reason: 'match_not_settleable' }; + } + // OFF -> ON must never create a retroactive liability: only the durable admission + // snapshot, written while tickets were committed, authorizes a payout. + if (!match.payout_liability_accepted_at) { + return { created: false, reason: 'liability_not_accepted' }; + } - let pot, houseFee; + let amount, cap; try { - pot = BigInt(matchRow.rows[0].pot_atomic ?? 0); - houseFee = BigInt(matchRow.rows[0].house_fee_atomic ?? 0); + amount = BigInt(match.payout_liability_amount_atomic); + cap = BigInt(match.payout_liability_cap_atomic); } catch (_) { - return; + throw this._admissionError('invalid_liability_snapshot'); } - if (houseFee < 0n) houseFee = 0n; - if (houseFee > pot) houseFee = pot; // never pay a negative amount - const winnerAmount = pot - houseFee; // capped at true collected pot − fee - - if (winnerAmount <= 0n) { - this._log(`[MatchPayoutService] no winner payout for match ${room.id} (pot=${pot} fee=${houseFee})`); - return; + if (amount <= 0n || cap <= 0n || amount > cap) { + throw this._admissionError('invalid_liability_snapshot'); } - // Resolve payout address from the user record (may be absent). - const userResult = await this.db.query(` - SELECT id, payout_address - FROM users - WHERE id = $1 - LIMIT 1 - `, [winnerState.userId]); - const user = userResult.rows[0]; - if (!user) return; + const existing = await client.query(` + SELECT id, status FROM payouts WHERE match_id = $1 LIMIT 1 + `, [match.id]); + if (existing.rows?.length) { + return { created: false, reason: 'payout_exists', payoutId: existing.rows[0].id }; + } + + const userResult = await client.query(` + SELECT id, payout_address FROM users WHERE id = $1 LIMIT 1 + `, [match.winner_user_id]); + const user = userResult.rows?.[0]; + if (!user) throw this._admissionError('winner_not_found'); const hasAddress = typeof user.payout_address === 'string' && user.payout_address.trim().length > 0; const address = hasAddress ? user.payout_address.trim() : NO_ADDRESS_SENTINEL; const status = hasAddress ? 'pending' : 'needs_review'; const reason = hasAddress ? 'match_winner' : 'match_winner_no_address'; + const inserted = await client.query(` + INSERT INTO payouts (user_id, match_id, payout_address, amount, multiplier, reason, status, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT DO NOTHING + RETURNING id + `, [user.id, match.id, address, amount.toString(), 0, reason, status]); + if (inserted.rowCount !== 1) { + return { created: false, reason: 'payout_exists' }; + } - try { - await this.db.withTransaction(async (client) => { - // Guard against a double payout for this match (includes the deferred - // no-address row so we never insert two). - const existing = await client.query(` - SELECT id FROM payouts - WHERE match_id = $1 AND status IN ('pending', 'processing', 'completed', 'needs_review') - FOR UPDATE - `, [room.id]); - if (existing.rows.length > 0) { - throw Object.assign(new Error('Payout already exists for this match'), { code: 'PAYOUT_EXISTS' }); + this._log(`[MatchPayoutService] recorded ${status} liability ${amount} for match ${match.id}`); + return { created: true, status, amount: amount.toString(), payoutId: inserted.rows?.[0]?.id }; + } + + /** Reconcile durable finished liabilities after a crash or transient payout-insert failure. */ + async reconcileFinishedLiabilities({ limit = 100 } = {}) { + if (!this.db) return { ok: true, scanned: 0, created: 0, failed: 0, unresolved: [] }; + const candidates = await this.db.query(` + SELECT m.id + FROM matches m + WHERE m.economy = 'crypto_race' + AND m.status = 'finished' + AND m.winner_user_id IS NOT NULL + AND m.payout_liability_accepted_at IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.match_id = m.id) + ORDER BY m.ended_at ASC NULLS LAST, m.id ASC + LIMIT $1 + `, [Math.max(1, Math.min(1000, parseInt(limit, 10) || 100))]); + + let created = 0; + let failed = 0; + const unresolved = []; + let pendingCreated = false; + for (const candidate of candidates.rows || []) { + try { + const result = await this.db.withTransaction(client => this._createPayoutForMatch(client, candidate.id)); + if (result.created) { + created += 1; + if (result.status === 'pending') pendingCreated = true; } - - await client.query(` - INSERT INTO payouts (user_id, match_id, payout_address, amount, multiplier, reason, status, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) - `, [user.id, room.id, address, winnerAmount.toString(), 0, reason, status]); - }); - - if (hasAddress) { - // Trigger the existing batch payout processor (5s debounce). - if (this.gameModeManager && typeof this.gameModeManager._scheduleBatchPayout === 'function') { - this.gameModeManager._scheduleBatchPayout(); - } - this._log(`[MatchPayoutService] queued winner payout ${winnerAmount} for match ${room.id}`); - } else { - this._log(`[MatchPayoutService] winner ${user.id} has no payout address; recorded ${winnerAmount} as a claimable 'needs_review' payout for match ${room.id}`); + } catch (err) { + failed += 1; + unresolved.push({ type: 'match_liability', id: candidate.id }); + this._log(`[MatchPayoutService] reconciliation failed for ${candidate.id}:`, err.message); } - } catch (err) { - const normalized = normalizeError(err, 'Failed to queue match payout'); - this._log('[MatchPayoutService] payout error:', normalized.message); } + if (pendingCreated && this.gameModeManager && typeof this.gameModeManager._scheduleBatchPayout === 'function') { + this.gameModeManager._scheduleBatchPayout(); + } + return { + ok: failed === 0, + scanned: candidates.rows?.length || 0, + created, + failed, + unresolved + }; } - /** - * Entry fee (atomic units, BigInt). Does NOT assume entryFee == singleGamePrice: an explicit - * MATCH_ENTRY_FEE_ATOMIC (or a gameModeManager.matchEntryFeeAtomic) wins; singleGamePrice is - * only a fallback, then a hard default. - */ - _entryFeeAtomic() { - const raw = (process.env.MATCH_ENTRY_FEE_ATOMIC != null && process.env.MATCH_ENTRY_FEE_ATOMIC !== '') - ? process.env.MATCH_ENTRY_FEE_ATOMIC - : (this.gameModeManager?.matchEntryFeeAtomic - ?? this.gameModeManager?.singleGamePrice - ?? null); - if (raw == null) return DEFAULT_ENTRY_FEE_ATOMIC; - try { - // Atomic units are integers; strip any fractional part defensively before BigInt. - const s = String(raw).trim().split('.')[0]; - const b = BigInt(s); - return b > 0n ? b : DEFAULT_ENTRY_FEE_ATOMIC; - } catch (_) { - return DEFAULT_ENTRY_FEE_ATOMIC; - } - } - - /** - * House fee as integer basis points (percent × 100), so a fractional percent like 5.5% - * survives with no float precision loss in the pot math. - */ - _houseFeeBasisPoints() { - const pct = parseFloat(process.env.MATCH_HOUSE_FEE_PERCENT); - const p = Number.isFinite(pct) && pct >= 0 && pct <= 100 ? pct : 5; - return Math.round(p * 100); - } } module.exports = MatchPayoutService; diff --git a/src/network/matchQueue.js b/src/network/matchQueue.js index 2c6f6e1..9bc914f 100644 --- a/src/network/matchQueue.js +++ b/src/network/matchQueue.js @@ -16,6 +16,10 @@ */ const { normalizeError } = require('../utils/errors'); +const { createFinancialRecoveryError } = require('../utils/financialRecoveryError'); +const { matchPayoutAdmissionPolicy } = require('./matchEconomyPolicy'); +const crypto = require('crypto'); +const { PAID_FREEZE_VERSION, buildPaidEntrantFreeze } = require('./matchFairness'); const ECONOMIES = Object.freeze(['free', 'credits_prestige', 'crypto_race']); const MATCH_ECONOMY_SET = new Set(ECONOMIES); @@ -23,15 +27,21 @@ const MATCH_ECONOMY_SET = new Set(ECONOMIES); const RACE_LEDGER_REASONS = new Set(['queue_leave', 'match_cancel', 'refund']); class MatchQueue { - constructor({ db, gameModeManager = null, debugManager = null } = {}) { + constructor({ db, gameModeManager = null, debugManager = null, isFinancialRecoveryReady = null } = {}) { this.db = db; this.gameModeManager = gameModeManager; this.debugManager = debugManager; + this.isFinancialRecoveryReady = typeof isFinancialRecoveryReady === 'function' + ? isFinancialRecoveryReady + : () => true; this.enabled = process.env.MATCH_ENABLED === 'true'; + this.initialized = false; // In-memory hot state: economy -> array of queue entries (for fast scheduling). // Rebuilt from DB on initialize(). this._queues = { free: [], credits_prestige: [], crypto_race: [] }; + this._availabilityTimer = null; + this._reconcileRunning = false; } _log(...args) { @@ -39,23 +49,54 @@ class MatchQueue { } async initialize() { - if (!this.enabled || !this.db) return; + if (!this.db) return; + this.initialized = false; // MP-H3: refund + cancel abandoned entries and recover in-flight matches BEFORE // rebuilding memory, so escrow is never silently lost across a restart/crash. - await this._recoverStaleQueueEntries(); await this._recoverAbandonedMatches(); + await this._recoverStaleQueueEntries(); + await this.cancelUnverifiedPaidFreezes('match_cancel', { failOnError: true }); // Rebuild in-memory state from the surviving 'queued' rows. - const result = await this.db.query(` - SELECT id, user_id, economy, socket_id, session_token, created_at - FROM match_queue_entries - WHERE status = 'queued' - ORDER BY created_at ASC - `); + let result; + try { + result = await this.db.query(` + SELECT id, user_id, economy, socket_id, session_token, created_at + FROM match_queue_entries + WHERE status = 'queued' + ORDER BY created_at ASC, id ASC + `); + } catch (error) { + throw createFinancialRecoveryError('match_queue_rebuild', { + scanFailed: true, + scanned: 0, + resolved: 0, + unresolved: [] + }, error); + } for (const row of result.rows || []) { if (!this._queues[row.economy]) continue; + // Configuration may have changed while the process was down. Never revive an + // unavailable paid queue (especially crypto_race while payouts are disabled): cancel + // it transactionally and return the held credit/ticket instead. + if (!this.enabled || !this._isEconomyAvailable(row.economy)) { + const refunded = await this._leaveOne(row.user_id, row.economy); + // A transient refund failure leaves the durable row queued. Retain a volatile + // retry handle so the reconciliation timer below keeps trying without requiring + // another restart; `not_queued` means another actor already resolved it. + if (!refunded.success && refunded.reason !== 'not_queued') { + this._queues[row.economy].push({ + queueEntryId: row.id, + userId: row.user_id, + socketId: row.socket_id, + sessionToken: row.session_token, + createdAt: new Date(row.created_at).getTime() + }); + } + continue; + } this._queues[row.economy].push({ queueEntryId: row.id, userId: row.user_id, @@ -65,44 +106,119 @@ class MatchQueue { }); } + // Pending paid entrant freezes are deliberately not stale queue rows: they wait for the + // exact later entropy block and survive a crash. If admission is now disabled, however, + // claim and refund each whole freeze before the scheduler becomes ready. + await this.refundUnavailableQueues('match_cancel', { failOnError: true }); + + // Do not depend on a new block arriving after an operator flips a kill switch. Poll the + // admission policy and transactionally return any now-disabled paid queue escrow. + const reconcileMs = Math.max(1000, parseInt(process.env.MATCH_AVAILABILITY_RECONCILE_MS, 10) || 5000); + this._availabilityTimer = setInterval(async () => { + if (this._reconcileRunning) return; + this._reconcileRunning = true; + try { + // Also retry unlinked matched holds left by a failed persist/refund attempt. + // The exact-row anchor below refuses a row once a match has linked it. + await this._recoverStaleQueueEntries({ + matchedMinAgeSec: Math.max( + 5, + parseInt(process.env.MATCH_STALE_RECONCILE_AGE_SEC, 10) || 30 + ) + }); + await this.refundUnavailableQueues('match_cancel', { failOnError: true }); + this.initialized = true; + } catch (err) { + // This status feeds readiness and every paid-admission predicate. Keep the + // periodic retry alive, but do not accept more value while durable escrow is + // known to be unresolved. + this.initialized = false; + this._log('[MatchQueue] availability reconciliation error', err.message); + } finally { + this._reconcileRunning = false; + } + }, reconcileMs); + this._availabilityTimer.unref?.(); + + // Blocks can arrive while database migrations/recovery are still running. Do not let + // the scheduler drain a partially rebuilt queue until every recovery step has committed. + this.initialized = true; + this._log(`[MatchQueue] Initialized: ${this.length('free')} free, ${this.length('credits_prestige')} prestige, ${this.length('crypto_race')} crypto`); } /** - * Refund + cancel queue rows abandoned by a crash: 'matched' rows that never became a - * running match (older than 5 min), and 'queued' rows older than 24h. Each refund is + * Refund + cancel queue rows abandoned by a crash: in single-instance mode every unlinked + * 'matched' row is from the dead process; multi-instance deployments retain a five-minute + * grace for a sibling to link it. Queued rows age out after 24h. Each refund is * written to the ledger; the row is only cancelled in the same transaction as its refund. */ - async _recoverStaleQueueEntries() { + async _recoverStaleQueueEntries({ matchedMinAgeSec = null } = {}) { + const singleInstance = String(process.env.MATCH_SINGLE_INSTANCE || 'true').toLowerCase() !== 'false'; + const matchedAge = matchedMinAgeSec == null + ? (singleInstance ? 0 : 300) + : Math.max(0, Math.min(3600, parseInt(matchedMinAgeSec, 10) || 0)); let stale = []; try { const res = await this.db.query(` - SELECT user_id, economy FROM match_queue_entries - WHERE (status = 'matched' AND created_at < NOW() - INTERVAL '5 minutes') + SELECT id, user_id, economy, escrow_amount, race_entry_lot_id FROM match_queue_entries + WHERE (status = 'matched' AND match_id IS NULL + AND COALESCE(matched_at, created_at) <= NOW() - ($1::int * INTERVAL '1 second')) OR (status = 'queued' AND created_at < NOW() - INTERVAL '24 hours') - `); + `, [matchedAge]); stale = res.rows || []; } catch (err) { this._log('[MatchQueue] stale scan error', err.message); - return; + throw createFinancialRecoveryError('stale_match_queue_entries', { + scanFailed: true, + scanned: 0, + resolved: 0, + unresolved: [] + }, err); } + const summary = { ok: true, scanned: stale.length, resolved: 0, unresolved: [] }; for (const row of stale) { + let recovered = false; try { await this.db.withTransaction(async (client) => { const upd = await client.query(` UPDATE match_queue_entries SET status = 'cancelled', match_id = NULL - WHERE user_id = $1 AND economy = $2 AND status IN ('queued', 'matched') - RETURNING id - `, [row.user_id, row.economy]); + WHERE id = $1 AND user_id = $2 AND economy = $3 + AND ( + status = 'queued' + OR (status = 'matched' AND match_id IS NULL) + ) + RETURNING id, escrow_amount, race_entry_lot_id + `, [row.id, row.user_id, row.economy]); if (upd.rowCount > 0) { - await this._applyRefund(client, row.user_id, row.economy, 'match_cancel'); + await this._applyRefund( + client, + row.user_id, + row.economy, + 'match_cancel', + upd.rows[0].escrow_amount, + upd.rows[0].race_entry_lot_id + ); + recovered = true; } }); + if (recovered && this._queues[row.economy]) { + this._queues[row.economy] = this._queues[row.economy] + .filter(entry => String(entry.queueEntryId) !== String(row.id)); + } + // A zero-row update means another actor already resolved the row under lock. + summary.resolved += 1; } catch (err) { this._log('[MatchQueue] stale recovery error', err.message); + summary.unresolved.push({ type: 'queue_entry', id: row.id }); } } + if (summary.unresolved.length > 0) { + summary.ok = false; + throw createFinancialRecoveryError('stale_match_queue_entries', summary); + } + return summary; } /** @@ -131,10 +247,15 @@ class MatchQueue { ); rows = res.rows || []; } catch (err) { - // matches table may not exist in a minimal test DB — non-fatal. this._log('[MatchQueue] abandoned match scan error', err.message); - return; + throw createFinancialRecoveryError('abandoned_matches', { + scanFailed: true, + scanned: 0, + resolved: 0, + unresolved: [] + }, err); } + const summary = { ok: true, scanned: rows.length, resolved: 0, unresolved: [] }; for (const m of rows) { try { await this.db.withTransaction(async (client) => { @@ -157,32 +278,124 @@ class MatchQueue { `, [m.id]); if (cancel.rowCount === 0) return; // already handled by another process - const ent = await client.query(`SELECT user_id FROM match_entrants WHERE match_id = $1`, [m.id]); + const ent = await client.query(` + SELECT id, user_id, queue_entry_id + FROM match_entrants + WHERE match_id = $1 + `, [m.id]); for (const e of ent.rows || []) { if (m.economy === 'crypto_race' || m.economy === 'credits_prestige') { - await this._applyRefund(client, e.user_id, m.economy, 'match_cancel'); + // The queue row is the primary exactly-once refund anchor. New rows + // are linked by match_id; the unlinked branch safely recovers legacy + // rows created before migration 033. + let anchor; + if (e.queue_entry_id != null) { + anchor = await client.query(` + UPDATE match_queue_entries + SET status = 'cancelled' + WHERE id = $1 AND match_id = $2 AND user_id = $3 + AND status IN ('matched', 'consumed') + RETURNING id, escrow_amount, race_entry_lot_id + `, [e.queue_entry_id, m.id, e.user_id]); + } else { + anchor = await client.query(` + UPDATE match_queue_entries + SET status = 'cancelled' + WHERE match_id = $1 AND user_id = $2 + AND status IN ('matched', 'consumed') + RETURNING id, escrow_amount, race_entry_lot_id + `, [m.id, e.user_id]); + } + if (anchor.rowCount === 0) { + anchor = await client.query(` + UPDATE match_queue_entries + SET status = 'cancelled' + WHERE id = ( + SELECT id FROM match_queue_entries + WHERE match_id IS NULL AND user_id = $1 AND economy = $2 + AND status = 'matched' + ORDER BY matched_at ASC NULLS LAST, id ASC + LIMIT 1 FOR UPDATE + ) + RETURNING id, escrow_amount, race_entry_lot_id + `, [e.user_id, m.economy]); + } + if (anchor.rowCount > 1) { + throw new Error(`Multiple queue escrow anchors for match ${m.id}, user ${e.user_id}`); + } + if (anchor.rowCount > 0) { + await this._applyRefund( + client, + e.user_id, + m.economy, + 'match_cancel', + anchor.rows[0].escrow_amount, + anchor.rows[0].race_entry_lot_id + ); + await client.query(` + UPDATE match_entrants + SET entry_refunded_at = COALESCE(entry_refunded_at, NOW()) + WHERE id = $1 + `, [e.id]); + } else { + // Last-resort legacy anchor when the old process never linked or + // retained a queue row. entry_refunded_at makes the fallback + // idempotent across repeated boots. + const claimedEntrant = await client.query(` + UPDATE match_entrants SET entry_refunded_at = NOW() + WHERE id = $1 AND entry_refunded_at IS NULL + RETURNING id + `, [e.id]); + if (claimedEntrant.rowCount > 0) { + await this._applyRefund(client, e.user_id, m.economy, 'match_cancel'); + } + } } } - await client.query(` - UPDATE match_queue_entries SET status = 'cancelled' - WHERE match_id = $1 AND status IN ('queued', 'matched') - `, [m.id]); }); this._log(`[MatchQueue] recovered abandoned match ${m.id} (${m.economy})`); + summary.resolved += 1; } catch (err) { this._log('[MatchQueue] abandoned match recovery error', err.message); + summary.unresolved.push({ type: 'match', id: m.id }); } } + if (summary.unresolved.length > 0) { + summary.ok = false; + throw createFinancialRecoveryError('abandoned_matches', summary); + } + return summary; } _validateEconomy(economy) { return MATCH_ECONOMY_SET.has(economy) ? economy : null; } + _isEconomyAvailable(economy) { + if (!this.enabled || !this._validateEconomy(economy)) return false; + if (economy === 'crypto_race') { + // Never let an injected/legacy queue bypass the crypto admission contract. This is + // evaluated on every join/drain so a hot kill-switch takes effect immediately. + const policy = matchPayoutAdmissionPolicy({ + env: process.env, + gameModeManager: this.gameModeManager + }); + if (!policy.enabled) return false; + } + if (this.gameModeManager && typeof this.gameModeManager._getMatchEconomies === 'function') { + return this.gameModeManager._getMatchEconomies()[economy] === true; + } + return true; // free/prestige injected users retain the historical contract + } + isEnabled() { return this.enabled; } + isEconomyAvailable(economy) { + return this._isEconomyAvailable(economy); + } + length(economy) { return this._queues[economy]?.length || 0; } @@ -192,6 +405,419 @@ class MatchQueue { return this._queues[economy].slice(); } + /** + * Atomically freeze one FIFO paid entrant set before its entropy block exists. The pending + * matches row is a durable freeze envelope, not a playable match: seed_hash temporarily holds + * the deterministic entrant-freeze commitment and dungeon contains only the freeze inputs. + * A later scheduler block replaces both with the future-block-derived room in one transaction. + */ + async freezePaidMatch({ + economy, + maxPlayers, + minPlayers = 2, + freezeBlockHeight, + targetBlockHeight, + rulesetId, + variant = 'race', + difficultyPreset = 'race', + freezeId = crypto.randomUUID() + } = {}) { + economy = this._validateEconomy(economy); + const minimum = Math.max(2, Math.min(32, parseInt(minPlayers, 10) || 2)); + const maximum = Math.max(minimum, Math.min(32, parseInt(maxPlayers, 10) || minimum)); + if (economy === 'free' + || !economy + || !this.isFinancialRecoveryReady() + || !this._isEconomyAvailable(economy) + || this._queues[economy].length < minimum) return null; + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(freezeId))) { + throw new Error('Paid entrant freeze requires a canonical UUID'); + } + if (!['race', 'pvp'].includes(variant)) throw new Error('Paid entrant freeze variant is invalid'); + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(String(difficultyPreset))) { + throw new Error('Paid entrant freeze difficulty is invalid'); + } + + const candidates = this._queues[economy].slice(0, Math.min(maximum, this._queues[economy].length)); + const userIds = candidates.map(entry => String(entry.userId)); + const socketIds = candidates.map(entry => String(entry.socketId || '')); + if (new Set(userIds).size !== candidates.length + || socketIds.some(id => id.length === 0) + || new Set(socketIds).size !== candidates.length) { + this._log('[MatchQueue] paid entrant freeze refused duplicate entrant identity'); + return null; + } + const queueEntryIds = candidates.map(entry => entry.queueEntryId); + const freeze = buildPaidEntrantFreeze({ + freezeBlockHeight, + targetBlockHeight, + economy, + rulesetId, + queueEntryIds + }); + const freezeEnvelope = { match_fairness_freeze: freeze }; + + let acceptedAfterAmbiguousCommit = null; + try { + await this.db.withTransaction(async (client) => { + const pending = await client.query(` + INSERT INTO matches ( + id, status, economy, variant, ruleset_id, difficulty_preset, max_players, + seed_hash, dungeon, start_block_height, entry_fee_atomic, + pot_atomic, house_fee_atomic, house_fee_percent, created_at + ) VALUES ( + $1, 'pending', $2, $3, $4, $5, $6, + $7, $8::jsonb, $9, 0, + 0, 0, 0, NOW() + ) + RETURNING id + `, [ + freezeId, + economy, + variant, + rulesetId, + difficultyPreset, + maximum, + freeze.freezeCommitment, + JSON.stringify(freezeEnvelope), + freeze.targetBlockHeight + ]); + if (pending.rowCount !== 1) throw new Error('Paid entrant freeze row was not created'); + + const linked = await client.query(` + UPDATE match_queue_entries + SET status = 'matched', matched_at = NOW(), match_id = $1 + WHERE id = ANY($2::bigint[]) + AND economy = $3 + AND status = 'queued' + AND match_id IS NULL + RETURNING id, user_id + `, [freezeId, freeze.queueEntryIds, economy]); + const expectedByQueueId = new Map(candidates.map(entry => [ + String(entry.queueEntryId), String(entry.userId) + ])); + const linksMatch = linked.rowCount === candidates.length + && (linked.rows || []).every(row => ( + expectedByQueueId.get(String(row.id)) === String(row.user_id) + )); + if (!linksMatch) { + throw new Error(`Paid entrant freeze mismatch: expected ${candidates.length}, got ${linked.rowCount}`); + } + + for (const entrant of candidates) { + await client.query(` + INSERT INTO match_entrants (match_id, user_id, socket_id, queue_entry_id) + VALUES ($1, $2, $3, $4) + `, [freezeId, entrant.userId, entrant.socketId, entrant.queueEntryId]); + } + }); + } catch (err) { + // COMMIT acknowledgement can fail after PostgreSQL accepted the whole freeze. Never + // leave those now-matched rows in the volatile queued array and try to freeze them a + // second time. Prove the exact pending envelope before deciding this attempt failed. + try { + const persisted = (await this.listFrozenPaidMatches(null)) + .find(group => group.id === String(freezeId)); + const expectedUsers = new Map(candidates.map(entry => [ + String(entry.queueEntryId), String(entry.userId) + ])); + const exactEntries = persisted?.entries?.length === candidates.length + && persisted.entries.every(entry => ( + expectedUsers.get(String(entry.queueEntryId)) === String(entry.userId) + )); + const persistedFreeze = persisted?.freeze + ? buildPaidEntrantFreeze(persisted.freeze) + : null; + if (persisted + && persisted.economy === economy + && persisted.rulesetId === String(rulesetId) + && persisted.freezeCommitment === freeze.freezeCommitment + && persistedFreeze?.freezeCommitment === freeze.freezeCommitment + && exactEntries) { + acceptedAfterAmbiguousCommit = persisted; + } + } catch (_) { /* retain volatile queue; no durable success was provable */ } + if (!acceptedAfterAmbiguousCommit) { + this._log('[MatchQueue] paid entrant freeze error', err.message); + return null; + } + this._log(`[MatchQueue] paid entrant freeze ${freezeId} committed despite acknowledgement failure`); + } + + const idSet = new Set(freeze.queueEntryIds); + this._queues[economy] = this._queues[economy] + .filter(entry => !idSet.has(String(entry.queueEntryId))); + this._log(`[MatchQueue] froze ${candidates.length} ${economy} entrants for future block ${freeze.targetBlockHeight}`); + return acceptedAfterAmbiguousCommit || Object.freeze({ + id: String(freezeId), + economy, + variant, + rulesetId: String(rulesetId), + difficultyPreset: String(difficultyPreset), + maxPlayers: maximum, + targetBlockHeight: freeze.targetBlockHeight, + freezeCommitment: freeze.freezeCommitment, + precommitTipHeight: null, + precommitVerifiedAt: null, + freeze, + entries: candidates + }); + } + + /** + * After the freeze transaction commits, durably witness a fresh daemon tip that is still + * strictly below the committed entropy target. A single conditional UPDATE makes the proof + * atomic; the follow-up read safely adopts a success whose COMMIT acknowledgement was lost. + */ + async verifyFrozenPrecommit(matchId, freezeCommitment, observedTipHeight) { + if (!this.db || !matchId) return { verified: false, reason: 'database_unavailable' }; + const tip = Number(observedTipHeight); + if (!Number.isSafeInteger(tip) || tip < 0) { + throw new Error('Paid freeze precommit requires a safe daemon tip height'); + } + const commitment = String(freezeCommitment || '').trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(commitment)) { + throw new Error('Paid freeze precommit requires an exact commitment'); + } + + let written = null; + let acknowledgementError = null; + try { + await this.db.withTransaction(async (client) => { + const result = await client.query(` + UPDATE matches + SET entropy_precommit_tip_height = $3, + entropy_precommit_verified_at = NOW() + WHERE id = $1 + AND status = 'pending' + AND economy IN ('credits_prestige', 'crypto_race') + AND seed_hash = $2 + AND start_block_height > $3 + AND entropy_precommit_tip_height IS NULL + AND entropy_precommit_verified_at IS NULL + RETURNING id, start_block_height, entropy_precommit_tip_height, + entropy_precommit_verified_at + `, [matchId, commitment, tip]); + written = result.rows?.[0] || null; + }); + } catch (err) { + acknowledgementError = err; + } + + if (!written) { + let persisted; + try { + const result = await this.db.query(` + SELECT id, status, seed_hash, start_block_height, + entropy_precommit_tip_height, entropy_precommit_verified_at + FROM matches + WHERE id = $1 + AND economy IN ('credits_prestige', 'crypto_race') + LIMIT 1 + `, [matchId]); + persisted = result.rows?.[0] || null; + } catch (readErr) { + throw acknowledgementError || readErr; + } + const adopted = persisted?.status === 'pending' + && String(persisted.seed_hash || '').toLowerCase() === commitment + && Number(persisted.entropy_precommit_tip_height) === tip + && persisted.entropy_precommit_verified_at != null + && Number(persisted.start_block_height) > tip; + if (!adopted) { + if (acknowledgementError) throw acknowledgementError; + return { verified: false, reason: 'freeze_not_claimable' }; + } + written = persisted; + } + + return Object.freeze({ + verified: true, + matchId: String(written.id), + targetBlockHeight: Number(written.start_block_height), + observedTipHeight: Number(written.entropy_precommit_tip_height), + verifiedAt: written.entropy_precommit_verified_at + }); + } + + /** Load paid freezes that are due (or all freezes when throughBlockHeight is null). */ + async listFrozenPaidMatches(throughBlockHeight = null) { + if (!this.db) return []; + const through = throughBlockHeight == null ? null : Number(throughBlockHeight); + if (through != null && (!Number.isSafeInteger(through) || through < 0)) { + throw new Error('Frozen-match scan requires a safe block height'); + } + const result = await this.db.query(` + SELECT m.id, m.economy, m.variant, m.ruleset_id, m.difficulty_preset, + m.max_players, m.seed_hash, m.start_block_height, m.dungeon, + m.entropy_precommit_tip_height, m.entropy_precommit_verified_at, + q.id AS queue_entry_id, q.user_id, q.socket_id, q.session_token, q.created_at + FROM matches m + LEFT JOIN match_queue_entries q + ON q.match_id = m.id AND q.status = 'matched' + WHERE m.status = 'pending' + AND m.economy IN ('credits_prestige', 'crypto_race') + AND ($1::bigint IS NULL OR m.start_block_height <= $1) + ORDER BY m.start_block_height ASC, m.created_at ASC, m.id ASC, + q.created_at ASC NULLS LAST, q.id ASC NULLS LAST + `, [through]); + const groups = new Map(); + for (const row of result.rows || []) { + const id = String(row.id); + if (!groups.has(id)) { + let dungeon = row.dungeon; + if (typeof dungeon === 'string') { + try { dungeon = JSON.parse(dungeon); } catch (_) { dungeon = null; } + } + groups.set(id, { + id, + economy: row.economy, + variant: row.variant, + rulesetId: row.ruleset_id, + difficultyPreset: row.difficulty_preset, + maxPlayers: Number(row.max_players), + targetBlockHeight: Number(row.start_block_height), + freezeCommitment: String(row.seed_hash || ''), + precommitTipHeight: row.entropy_precommit_tip_height == null + ? null + : Number(row.entropy_precommit_tip_height), + precommitVerifiedAt: row.entropy_precommit_verified_at || null, + freeze: dungeon?.match_fairness_freeze || null, + entries: [] + }); + } + if (row.queue_entry_id != null) { + groups.get(id).entries.push({ + queueEntryId: row.queue_entry_id, + userId: row.user_id, + socketId: row.socket_id, + sessionToken: row.session_token, + createdAt: new Date(row.created_at).getTime() + }); + } + } + return [...groups.values()]; + } + + /** + * Boot-time fail-closed cleanup. A v1, malformed, or unverified pending freeze has no durable + * proof that its target did not exist at commit time, so its exact escrow anchors are refunded. + */ + async cancelUnverifiedPaidFreezes(reason = 'match_cancel', { failOnError = true } = {}) { + let freezes; + try { + freezes = await this.listFrozenPaidMatches(null); + } catch (err) { + throw createFinancialRecoveryError('unverified_paid_match_freezes', { + scanFailed: true, + scanned: 0, + resolved: 0, + unresolved: [] + }, err); + } + + const unsafe = (freezes || []).filter((freeze) => { + let canonical = null; + try { + if (freeze?.freeze?.version === PAID_FREEZE_VERSION) { + canonical = buildPaidEntrantFreeze(freeze.freeze); + } + } catch (_) { /* malformed freezes are unsafe and must be cancelled */ } + return !canonical + || canonical.freezeCommitment !== String(freeze.freezeCommitment || '') + || freeze.precommitTipHeight == null + || !Number.isSafeInteger(Number(freeze.precommitTipHeight)) + || Number(freeze.precommitTipHeight) < 0 + || Number(freeze.precommitTipHeight) < canonical.freezeBlockHeight + || Number(freeze.precommitTipHeight) >= canonical.targetBlockHeight + || freeze.precommitVerifiedAt == null; + }); + const summary = { + ok: true, + scanned: unsafe.length, + resolved: 0, + failed: 0, + unresolved: [] + }; + for (const freeze of unsafe) { + const result = await this.cancelFrozenMatch(freeze.id, reason); + if (result?.resolved) summary.resolved += 1; + else summary.unresolved.push({ type: 'pending_match', id: freeze.id }); + } + summary.failed = summary.unresolved.length; + summary.ok = summary.failed === 0; + if (failOnError && !summary.ok) { + throw createFinancialRecoveryError('unverified_paid_match_freezes', summary); + } + return summary; + } + + /** + * Claim a pending freeze for cancellation and refund every linked escrow anchor in the same + * transaction. Activation and cancellation serialize on matches.status, so neither can + * refund an entrant after a match has become starting. + */ + async cancelFrozenMatch(matchId, reason = 'match_cancel') { + if (!this.db || !matchId) return { claimed: false, resolved: false, refunded: 0 }; + let refunded = 0; + let claimed = false; + try { + await this.db.withTransaction(async (client) => { + const cancel = await client.query(` + UPDATE matches + SET status = 'cancelled', ended_at = NOW() + WHERE id = $1 + AND status = 'pending' + AND economy IN ('credits_prestige', 'crypto_race') + RETURNING id, economy, dungeon + `, [matchId]); + if (cancel.rowCount === 0) return; + claimed = true; + const match = cancel.rows[0]; + let dungeon = match.dungeon; + if (typeof dungeon === 'string') dungeon = JSON.parse(dungeon); + const frozenIds = (dungeon?.match_fairness_freeze?.queueEntryIds || []).map(String); + if (frozenIds.length < 2 || new Set(frozenIds).size !== frozenIds.length) { + throw new Error(`Paid freeze ${matchId} has no valid entrant commitment`); + } + const anchors = await client.query(` + UPDATE match_queue_entries + SET status = 'cancelled' + WHERE match_id = $1 + AND economy = $2 + AND status = 'matched' + RETURNING id, user_id, escrow_amount, race_entry_lot_id + `, [matchId, match.economy]); + const actualIds = (anchors.rows || []).map(row => String(row.id)).sort(); + const expectedIds = [...frozenIds].sort(); + if (anchors.rowCount !== expectedIds.length + || actualIds.some((id, index) => id !== expectedIds[index])) { + throw new Error(`Paid freeze ${matchId} escrow anchors do not match its entrant commitment`); + } + for (const anchor of anchors.rows) { + await this._applyRefund( + client, + anchor.user_id, + match.economy, + reason, + anchor.escrow_amount, + anchor.race_entry_lot_id + ); + await client.query(` + UPDATE match_entrants + SET entry_refunded_at = COALESCE(entry_refunded_at, NOW()) + WHERE match_id = $1 AND queue_entry_id = $2 + `, [matchId, anchor.id]); + refunded += 1; + } + }); + } catch (err) { + this._log('[MatchQueue] frozen-match cancellation error', err.message); + return { claimed: false, resolved: false, refunded: 0 }; + } + return { claimed, resolved: true, refunded }; + } + /** * socketHandlers-facing API: enqueue a resolved session into a match queue. Identity comes * from the (server-resolved) session; the economy is explicit. @@ -218,6 +844,12 @@ class MatchQueue { if (!entry || entry.userId == null) return { success: false, reason: 'invalid_user' }; const economy = this._validateEconomy(entry.economy); if (!economy) return { success: false, reason: 'invalid_economy' }; + if (economy !== 'free' && (!this.initialized || !this.isFinancialRecoveryReady())) { + return { success: false, reason: 'financial_recovery_pending' }; + } + if (!this._isEconomyAvailable(economy)) { + return { success: false, reason: 'economy_disabled' }; + } // session_token is NOT NULL in the schema; coerce a missing token to a stable-ish // string (reconnect matches on userId anyway). @@ -242,36 +874,66 @@ class MatchQueue { } } - async _insertQueueEntry(client, entry, economy) { - const result = await client.query(` + async _claimQueueEntry(client, entry, economy) { + // Claim the durable queue row before touching credits/tickets. The partial unique + // index on (user_id, economy) serializes concurrent joins; only the transaction that + // actually INSERTs the row is allowed to debit escrow. An upsert cannot provide that + // guarantee because its UPDATE branch looks successful to every duplicate request. + const inserted = await client.query(` INSERT INTO match_queue_entries (user_id, economy, socket_id, session_token, status) VALUES ($1, $2, $3, $4, 'queued') ON CONFLICT (user_id, economy) WHERE status = 'queued' - DO UPDATE SET socket_id = EXCLUDED.socket_id, - session_token = EXCLUDED.session_token, - created_at = NOW() + DO NOTHING RETURNING id, created_at `, [entry.userId, economy, entry.socketId, entry.sessionToken]); - return result.rows[0]; + if (inserted.rows.length > 0) { + return { row: inserted.rows[0], inserted: true }; + } + + // Duplicate/reconnect: refresh only transport identity, preserving queue age/order. + const existing = await client.query(` + UPDATE match_queue_entries + SET socket_id = $3, session_token = $4 + WHERE user_id = $1 AND economy = $2 AND status = 'queued' + RETURNING id, created_at + `, [entry.userId, economy, entry.socketId, entry.sessionToken]); + if (existing.rows.length === 0) { + throw Object.assign(new Error('Queue claim was lost'), { code: 'QUEUE_CLAIM_LOST' }); + } + return { row: existing.rows[0], inserted: false }; } async _joinFree(entry) { + let newlyQueued = false; await this.db.withTransaction(async (client) => { - const dbRow = await this._insertQueueEntry(client, entry, 'free'); + const claim = await this._claimQueueEntry(client, entry, 'free'); + const dbRow = claim.row; + newlyQueued = claim.inserted; entry.queueEntryId = dbRow.id; entry.createdAt = new Date(dbRow.created_at).getTime(); }); this._addToMemory('free', entry); const position = this._queues.free.length; this._log(`[MatchQueue] free join user=${entry.userId} pos=${position}`); - return { success: true, position }; + return { success: true, position, alreadyQueued: !newlyQueued }; } async _joinCreditsPrestige(entry) { const cost = this._creditsCost(); + let newlyQueued = false; await this.db.withTransaction(async (client) => { - // 1. Deduct credits atomically. + // 1. Claim the one durable queue row. Duplicate joins merely refresh the + // socket/session and must not hold a second escrow amount. + const claim = await this._claimQueueEntry(client, entry, 'credits_prestige'); + const dbRow = claim.row; + newlyQueued = claim.inserted; + entry.queueEntryId = dbRow.id; + entry.createdAt = new Date(dbRow.created_at).getTime(); + entry.cost = cost; + if (!newlyQueued) return; + + // 2. Deduct credits atomically. const creditResult = await client.query(` UPDATE users SET credits = credits - $1 @@ -285,28 +947,50 @@ class MatchQueue { const balanceAfter = creditResult.rows[0].credits; - // 2. Log credit transaction (escrow). + // 3. Log credit transaction (escrow). await client.query(` INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type, metadata) VALUES ($1, $2, 'match_queue_join', $3, 'match', $4) - `, [entry.userId, -cost, balanceAfter, JSON.stringify({ economy: 'credits_prestige' })]); + `, [entry.userId, -cost, balanceAfter, JSON.stringify({ economy: 'credits_prestige', queueEntryId: dbRow.id })]); + + const escrow = await client.query(` + UPDATE match_queue_entries SET escrow_amount = $1 + WHERE id = $2 AND status = 'queued' + RETURNING id + `, [cost, dbRow.id]); + if (escrow.rowCount !== 1) { + throw Object.assign(new Error('Credit escrow row was lost'), { code: 'QUEUE_ESCROW_LOST' }); + } - // 3. Insert queue entry. - const dbRow = await this._insertQueueEntry(client, entry, 'credits_prestige'); - entry.queueEntryId = dbRow.id; - entry.createdAt = new Date(dbRow.created_at).getTime(); - entry.cost = cost; }); this._addToMemory('credits_prestige', entry); const position = this._queues.credits_prestige.length; this._log(`[MatchQueue] credits_prestige join user=${entry.userId} pos=${position}`); - return { success: true, position }; + return { success: true, position, alreadyQueued: !newlyQueued }; } async _joinCryptoRace(entry) { + const policy = matchPayoutAdmissionPolicy({ + env: process.env, + gameModeManager: this.gameModeManager + }); + if (!policy.enabled) { + throw Object.assign(new Error(`Crypto match admission disabled: ${policy.reason}`), { + code: String(policy.reason || 'CRYPTO_MATCH_DISABLED').toUpperCase() + }); + } + let newlyQueued = false; await this.db.withTransaction(async (client) => { - // 1. Consume one race entry ticket atomically (escrow). + // 1. Claim before debiting so retries/concurrent socket events are idempotent. + const claim = await this._claimQueueEntry(client, entry, 'crypto_race'); + const dbRow = claim.row; + newlyQueued = claim.inserted; + entry.queueEntryId = dbRow.id; + entry.createdAt = new Date(dbRow.created_at).getTime(); + if (!newlyQueued) return; + + // 2. Consume one race entry ticket atomically (escrow). const ticketResult = await client.query(` UPDATE users SET race_entries = race_entries - 1 @@ -320,22 +1004,66 @@ class MatchQueue { const balanceAfter = ticketResult.rows[0].race_entries; - // 2. Log ticket transaction. + // Allocate one durably funded ticket lot. Aggregate/admin-granted legacy tickets + // without a paid lot cannot back an on-chain winner liability. + const lotResult = await client.query(` + SELECT id, unit_value_atomic + FROM race_entry_lots + WHERE user_id = $1 + AND remaining_entries > 0 + AND refunded_at IS NULL + AND unit_value_atomic = $2 + ORDER BY created_at ASC, id ASC + LIMIT 1 + FOR UPDATE + `, [entry.userId, policy.entryFee.toString()]); + const lot = lotResult.rows?.[0]; + if (!lot) { + throw Object.assign(new Error('No funded race entry ticket'), { code: 'NO_FUNDED_RACE_TICKET' }); + } + const allocated = await client.query(` + UPDATE race_entry_lots + SET remaining_entries = remaining_entries - 1 + WHERE id = $1 AND user_id = $2 + AND remaining_entries > 0 + AND refunded_at IS NULL + RETURNING id + `, [lot.id, entry.userId]); + if (allocated.rowCount !== 1) { + throw Object.assign(new Error('Race entry lot allocation was lost'), { code: 'TICKET_LOT_LOST' }); + } + + // 3. Log ticket transaction. await client.query(` INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, metadata) VALUES ($1, -1, $2, 'queue_join', $3) - `, [entry.userId, balanceAfter, JSON.stringify({ economy: 'crypto_race' })]); + `, [entry.userId, balanceAfter, JSON.stringify({ + economy: 'crypto_race', + queueEntryId: dbRow.id, + raceEntryLotId: lot.id, + backingValueAtomic: String(lot.unit_value_atomic) + })]); + + const escrow = await client.query(` + UPDATE match_queue_entries + SET escrow_amount = 1, + escrow_value_atomic = $2, + race_entry_lot_id = $3 + WHERE id = $1 AND status = 'queued' + RETURNING id + `, [dbRow.id, String(lot.unit_value_atomic), lot.id]); + if (escrow.rowCount !== 1) { + throw Object.assign(new Error('Ticket escrow row was lost'), { code: 'QUEUE_ESCROW_LOST' }); + } + entry.backingValueAtomic = String(lot.unit_value_atomic); + entry.raceEntryLotId = lot.id; - // 3. Insert queue entry. - const dbRow = await this._insertQueueEntry(client, entry, 'crypto_race'); - entry.queueEntryId = dbRow.id; - entry.createdAt = new Date(dbRow.created_at).getTime(); }); this._addToMemory('crypto_race', entry); const position = this._queues.crypto_race.length; this._log(`[MatchQueue] crypto_race join user=${entry.userId} pos=${position}`); - return { success: true, position }; + return { success: true, position, alreadyQueued: !newlyQueued }; } _addToMemory(economy, entry) { @@ -343,7 +1071,14 @@ class MatchQueue { const idx = q.findIndex(e => e.userId === entry.userId); if (idx !== -1) q[idx] = entry; else q.push(entry); - q.sort((a, b) => a.createdAt - b.createdAt); + q.sort((a, b) => { + const age = a.createdAt - b.createdAt; + if (age !== 0) return age; + const left = String(a.queueEntryId || ''); + const right = String(b.queueEntryId || ''); + if (left.length !== right.length) return left.length - right.length; + return left.localeCompare(right); + }); } _creditsCost() { @@ -356,9 +1091,12 @@ class MatchQueue { * (MP-H4): the credit refund reads the exact deducted amount from the ledger, so it is * correct even after a restart cleared the in-memory entry. Always writes a ledger row. */ - async _applyRefund(client, userId, economy, ledgerReason = 'queue_leave') { + async _applyRefund(client, userId, economy, ledgerReason = 'queue_leave', escrowAmount = null, raceEntryLotId = null) { if (economy === 'credits_prestige') { - const amount = await this._creditsJoinAmount(client, userId); + let amount = Math.abs(parseInt(escrowAmount, 10)); + if (!Number.isSafeInteger(amount) || amount <= 0) { + amount = await this._creditsJoinAmount(client, userId); + } if (amount <= 0) return 0; const r = await client.query(` UPDATE users SET credits = credits + $1 WHERE id = $2 RETURNING credits @@ -371,16 +1109,43 @@ class MatchQueue { return amount; } if (economy === 'crypto_race') { + let amount = Math.abs(parseInt(escrowAmount, 10)); + if (!Number.isSafeInteger(amount) || amount <= 0) amount = 1; // migration-022 legacy row const reason = RACE_LEDGER_REASONS.has(ledgerReason) ? ledgerReason : 'queue_leave'; + // Match queue allocation and product reversal both lock the user before the lot. + // Keep refunds on the same order to avoid a user<->lot deadlock; a later lot failure + // rolls this balance update back with the surrounding transaction. const r = await client.query(` - UPDATE users SET race_entries = race_entries + 1 WHERE id = $1 RETURNING race_entries - `, [userId]); + UPDATE users SET race_entries = race_entries + $1 WHERE id = $2 RETURNING race_entries + `, [amount, userId]); + if (r.rowCount !== 1) { + throw new Error(`Unable to restore race ticket balance for user ${userId}`); + } + if (raceEntryLotId != null) { + const restored = await client.query(` + UPDATE race_entry_lots + SET remaining_entries = remaining_entries + $1 + WHERE id = $2 AND user_id = $3 + AND refunded_at IS NULL + AND remaining_entries + $1 <= original_entries + RETURNING id + `, [amount, raceEntryLotId, userId]); + if (restored.rowCount !== 1) { + throw new Error(`Unable to restore race ticket lot ${raceEntryLotId}`); + } + } const bal = r.rows[0]?.race_entries ?? 0; await client.query(` INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, metadata) - VALUES ($1, 1, $2, $3, $4) - `, [userId, bal, reason, JSON.stringify({ economy, refunded: true, source: ledgerReason })]); - return 1; + VALUES ($1, $2, $3, $4, $5) + `, [userId, amount, bal, reason, JSON.stringify({ + economy, + refunded: true, + source: ledgerReason, + amount, + raceEntryLotId + })]); + return amount; } return 0; // free: nothing to refund } @@ -445,7 +1210,7 @@ class MatchQueue { const result = await client.query(` DELETE FROM match_queue_entries WHERE user_id = $1 AND economy = $2 AND status = 'queued' - RETURNING id + RETURNING id, escrow_amount, race_entry_lot_id `, [userId, economy]); if (result.rowCount === 0) { @@ -453,7 +1218,14 @@ class MatchQueue { } // MP-H4: refund is DB-derived inside _applyRefund, not read from volatile memory. - await this._applyRefund(client, userId, economy, 'queue_leave'); + await this._applyRefund( + client, + userId, + economy, + 'queue_leave', + result.rows[0].escrow_amount, + result.rows[0].race_entry_lot_id + ); }); const memIdx = this._queues[economy].findIndex(e => e.userId === userId); @@ -479,31 +1251,119 @@ class MatchQueue { * the row was actually still queued/matched (so it can never double-refund). */ async refundEntries(entries, economy, reason = 'match_cancel') { - if (!this.db || !Array.isArray(entries) || entries.length === 0) return; + if (!this.db || !Array.isArray(entries) || entries.length === 0) { + return { attempted: 0, resolved: 0, failed: 0 }; + } economy = this._validateEconomy(economy); - if (!economy) return; + if (!economy) return { attempted: entries.length, resolved: 0, failed: entries.length }; + let attempted = 0; + let resolvedCount = 0; for (const e of entries) { const userId = e?.userId; if (userId == null) continue; + attempted += 1; + let resolved = false; try { await this.db.withTransaction(async (client) => { const upd = await client.query(` UPDATE match_queue_entries SET status = 'cancelled', match_id = NULL - WHERE user_id = $1 AND economy = $2 AND status IN ('queued', 'matched') - RETURNING id - `, [userId, economy]); + WHERE user_id = $1 AND economy = $2 + AND ($3::bigint IS NULL OR id = $3) + AND status IN ('queued', 'matched') + RETURNING id, escrow_amount, race_entry_lot_id + `, [userId, economy, e.queueEntryId || null]); + if (upd.rowCount > 1) { + throw new Error(`Multiple queue escrow anchors for user ${userId}, economy ${economy}`); + } if (upd.rowCount > 0) { - await this._applyRefund(client, userId, economy, reason); + await this._applyRefund( + client, + userId, + economy, + reason, + upd.rows[0].escrow_amount, + upd.rows[0].race_entry_lot_id + ); + await client.query(` + UPDATE match_entrants + SET entry_refunded_at = COALESCE(entry_refunded_at, NOW()) + WHERE queue_entry_id = $1 + `, [upd.rows[0].id]); } }); + // A zero-row update means another actor already resolved this queue row; in + // either case it is now safe to remove the volatile copy. + resolved = true; } catch (err) { this._log('[MatchQueue] refundEntries error', err.message); } - const idx = this._queues[economy].findIndex(q => q.userId === userId); - if (idx >= 0) this._queues[economy].splice(idx, 1); + if (resolved) { + resolvedCount += 1; + const idx = this._queues[economy].findIndex(q => q.userId === userId); + if (idx >= 0) this._queues[economy].splice(idx, 1); + } } + return { attempted, resolved: resolvedCount, failed: attempted - resolvedCount }; + } + + /** + * Refund queued escrow immediately when a paid economy is hot-disabled. Failed refunds stay + * in memory and are retried on the next scheduler block; they are never silently forgotten. + */ + async refundUnavailableQueues(reason = 'match_cancel', { failOnError = false } = {}) { + let attempted = 0; + let resolved = 0; + const unresolved = []; + for (const economy of ['credits_prestige', 'crypto_race']) { + if (this._isEconomyAvailable(economy)) continue; + const entries = this._queues[economy].slice(); + attempted += entries.length; + const entryResult = await this.refundEntries(entries, economy, reason); + resolved += entryResult.resolved; + if (entryResult.failed > 0) { + const surviving = new Set(this._queues[economy].map(entry => String(entry.queueEntryId))); + for (const entry of entries) { + if (surviving.has(String(entry.queueEntryId))) { + unresolved.push({ type: 'queue_entry', id: entry.queueEntryId }); + } + } + } + let freezes = []; + try { + freezes = (await this.listFrozenPaidMatches(null)) + .filter(freeze => freeze.economy === economy); + } catch (err) { + this._log('[MatchQueue] unavailable frozen-match scan error', err.message); + if (failOnError) { + throw createFinancialRecoveryError('unavailable_paid_match_escrow', { + scanFailed: true, + scanned: attempted, + resolved, + unresolved + }, err); + } + } + for (const freeze of freezes) { + attempted += freeze.entries.length; + const result = await this.cancelFrozenMatch(freeze.id, reason); + if (result.resolved) resolved += freeze.entries.length; + else unresolved.push({ type: 'pending_match', id: freeze.id }); + } + } + const summary = { + ok: unresolved.length === 0, + scanned: attempted, + attempted, + resolved, + failed: unresolved.length, + unresolved + }; + if (failOnError && unresolved.length > 0) { + throw createFinancialRecoveryError('unavailable_paid_match_escrow', summary); + } + return summary; } /** @@ -512,27 +1372,45 @@ class MatchQueue { * failed drain leaves the queue intact for the next block. * @returns {Promise<{entries:Array}|null>} */ - async drain(economy, maxPlayers) { + async drain(economy, maxPlayers, minPlayers = 2) { economy = this._validateEconomy(economy); - if (!economy || this._queues[economy].length < 2) return null; + const minimum = Math.max(2, Math.min(32, parseInt(minPlayers, 10) || 2)); + const maximum = Math.max(minimum, Math.min(32, parseInt(maxPlayers, 10) || minimum)); + if (!economy + || (economy !== 'free' && !this.isFinancialRecoveryReady()) + || !this._isEconomyAvailable(economy) + || this._queues[economy].length < minimum) return null; - const count = Math.min(maxPlayers, this._queues[economy].length); + const count = Math.min(maximum, this._queues[economy].length); const candidates = this._queues[economy].slice(0, count); - const userIds = candidates.map(e => e.userId); + const queueEntryIds = candidates.map(e => e.queueEntryId); + if (queueEntryIds.some(id => id == null) + || new Set(queueEntryIds.map(String)).size !== queueEntryIds.length) { + this._log('[MatchQueue] drain refused entries without unique durable queue ids'); + return null; + } try { await this.db.withTransaction(async (client) => { const result = await client.query(` UPDATE match_queue_entries SET status = 'matched', matched_at = NOW() - WHERE user_id = ANY($1::int[]) AND economy = $2 AND status = 'queued' + WHERE id = ANY($1::bigint[]) AND economy = $2 AND status = 'queued' RETURNING id, user_id - `, [userIds, economy]); + `, [queueEntryIds, economy]); - if (result.rowCount !== userIds.length) { + const expectedById = new Map(candidates.map(entry => [ + String(entry.queueEntryId), + String(entry.userId) + ])); + const rowsMatch = result.rowCount === queueEntryIds.length + && (result.rows || []).every(row => ( + expectedById.get(String(row.id)) === String(row.user_id) + )); + if (!rowsMatch) { // Memory/DB out of sync — abort without mutating memory; retry next block. throw Object.assign( - new Error(`Queue drain mismatch: expected ${userIds.length}, got ${result.rowCount}`), + new Error(`Queue drain mismatch: expected ${queueEntryIds.length}, got ${result.rowCount}`), { code: 'DRAIN_MISMATCH' } ); } @@ -543,8 +1421,9 @@ class MatchQueue { } // DB commit succeeded — now remove them from the in-memory queue. - const idSet = new Set(userIds); - this._queues[economy] = this._queues[economy].filter(e => !idSet.has(e.userId)); + const idSet = new Set(queueEntryIds.map(String)); + this._queues[economy] = this._queues[economy] + .filter(e => !idSet.has(String(e.queueEntryId))); this._log(`[MatchQueue] drained ${candidates.length} players for ${economy}`); return { entries: candidates }; } @@ -590,13 +1469,27 @@ class MatchQueue { * written for each). */ async shutdown() { - if (!this.enabled || !this.db) return; + this.initialized = false; + if (this._availabilityTimer) { + clearInterval(this._availabilityTimer); + this._availabilityTimer = null; + } + if (!this.db) return; for (const economy of ECONOMIES) { const entries = this._queues[economy].slice(); for (const e of entries) { await this._leaveOne(e.userId, economy).catch(() => {}); } } + let freezes = []; + try { + freezes = await this.listFrozenPaidMatches(null); + } catch (err) { + this._log('[MatchQueue] shutdown frozen-match scan error', err.message); + } + for (const freeze of freezes) { + await this.cancelFrozenMatch(freeze.id, 'match_cancel'); + } } } diff --git a/src/network/matchScheduler.js b/src/network/matchScheduler.js index 86486e3..89f7712 100644 --- a/src/network/matchScheduler.js +++ b/src/network/matchScheduler.js @@ -1,9 +1,10 @@ /** * MatchScheduler — block-cadence match maker. * - * Subscribes to the same block event as the solo queue handler. On every new block, it drains - * each enabled economy's queue into a MatchRoom (if ≥2 players are queued). Single players - * carry over to the next block and can leave at any time. + * Subscribes to the same block event as the solo queue handler. Free matches may drain and start + * immediately. Paid matches first persist an exact FIFO entrant freeze, then wait for their + * configured future header and confirmation depth before deriving a seed and starting. Single + * players carry over and can leave at any time. * * MatchScheduler creates MatchRooms but does not own Socket.IO broadcasting; it delegates the * started room to MatchManager for transport, the real countdown, persistence, and finish @@ -14,12 +15,83 @@ const MatchRoom = require('../multiplayer/MatchRoom'); const MatchEngine = require('../multiplayer/MatchEngine'); const MatchState = require('../multiplayer/MatchState'); +const crypto = require('crypto'); +const { resolveMatchRuleset } = require('../game/rulesets'); +const { matchPayoutAdmissionPolicy, playerContract } = require('./matchEconomyPolicy'); +const { + PAID_FREEZE_VERSION, + PAID_SEED_VERSION, + buildPaidEntrantFreeze, + deriveFutureBlockMatchSeed +} = require('./matchFairness'); const DEFAULT_MAX_PLAYERS = 4; const DEFAULT_TICK_MS = 250; const DEFAULT_MIN_DURATION_MS = 20000; const DEFAULT_HARD_CEILING_MS = 240000; const DEFAULT_COUNTDOWN_MS = 3000; +const DEFAULT_PAID_ENTROPY_DELAY_BLOCKS = 2; +const DEFAULT_PAID_ENTROPY_CONFIRMATIONS = 2; +const BLOCK_SEED_VERSION = 'chain-block-v1'; + +function extractBlockHash(block) { + const value = typeof block === 'string' + ? block + : (block?.block_header?.hash || block?.blockHeader?.hash || block?.hash || ''); + const normalized = String(value).trim().toLowerCase(); + return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null; +} + +function extractBlockHeight(block) { + if (!block || typeof block === 'string') return null; + const value = block?.block_header?.height ?? block?.blockHeader?.height ?? block?.height; + const height = Number(value); + return Number.isSafeInteger(height) && height >= 0 ? height : null; +} + +function exactCanonicalHeader(block, expectedHeight) { + const hash = extractBlockHash(block); + const height = extractBlockHeight(block); + return hash && height === Number(expectedHeight) ? { hash, height } : null; +} + +function boundedBlockSetting(value, fallback, label) { + if (value == null || String(value).trim() === '') return fallback; + const raw = String(value).trim(); + if (!/^\d+$/.test(raw)) throw new Error(`${label} must be a canonical integer`); + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 100) { + throw new Error(`${label} must be an integer from 1 through 100`); + } + return parsed; +} + +function deriveBlockMatchSeed({ blockHash, blockHeight, economy, rulesetId, queueEntryIds }) { + const normalizedHash = extractBlockHash(blockHash); + if (!normalizedHash) throw new Error('A canonical 64-hex block hash is required'); + const ids = (queueEntryIds || []).map(id => String(id)).sort((left, right) => { + if (left.length !== right.length) return left.length - right.length; + return left.localeCompare(right); + }); + if (ids.length < 2 || ids.some(id => !/^\d+$/.test(id))) { + throw new Error('At least two durable numeric queue entry ids are required'); + } + const canonical = [ + 'wowngeon-match', BLOCK_SEED_VERSION, normalizedHash, String(blockHeight), + String(economy), String(rulesetId), ids.join(',') + ].join('|'); + return { + seed: crypto.createHash('sha256').update(canonical).digest('hex'), + derivation: { + version: BLOCK_SEED_VERSION, + blockHash: normalizedHash, + blockHeight: Number(blockHeight), + economy: String(economy), + rulesetId: String(rulesetId), + queueEntryIds: ids + } + }; +} class MatchScheduler { constructor({ @@ -30,56 +102,497 @@ class MatchScheduler { tickMs = null, minDurationMs = null, hardCeilingMs = null, - countdownMs = null + countdownMs = null, + rulesetId = null, + blockEntropyProvider = null, + blockCountProvider = null, + paidEntropyDelayBlocks = null, + paidEntropyConfirmations = null } = {}) { this.matchQueue = matchQueue; this.matchManager = matchManager; this.debugManager = debugManager; this.enabled = process.env.MATCH_ENABLED === 'true'; + this.ruleset = resolveMatchRuleset(rulesetId || process.env.MATCH_RULESET_ID || 'race'); - this.maxPlayers = Math.max(2, Math.min(32, maxPlayers || parseInt(process.env.MATCH_MAX_PLAYERS, 10) || DEFAULT_MAX_PLAYERS)); - this.tickMs = tickMs || parseInt(process.env.MATCH_TICK_MS, 10) || DEFAULT_TICK_MS; - this.minDurationMs = minDurationMs || parseInt(process.env.MATCH_MIN_DURATION_MS, 10) || DEFAULT_MIN_DURATION_MS; - this.hardCeilingMs = hardCeilingMs || parseInt(process.env.MATCH_HARD_CEILING_MS, 10) || DEFAULT_HARD_CEILING_MS; + const requestedMax = maxPlayers ?? process.env.MATCH_MAX_PLAYERS ?? DEFAULT_MAX_PLAYERS; + const players = playerContract(this.ruleset, requestedMax); + this.minPlayers = players.minPlayers; + this.maxPlayers = players.maxPlayers; + this.tickMs = tickMs || parseInt(process.env.MATCH_TICK_MS, 10) || this.ruleset.timing.tickMs || DEFAULT_TICK_MS; + this.minDurationMs = minDurationMs || parseInt(process.env.MATCH_MIN_DURATION_MS, 10) || this.ruleset.timing.minDurationMs || DEFAULT_MIN_DURATION_MS; + this.hardCeilingMs = hardCeilingMs || parseInt(process.env.MATCH_HARD_CEILING_MS, 10) || this.ruleset.timing.hardCeilingMs || DEFAULT_HARD_CEILING_MS; this.countdownMs = countdownMs || parseInt(process.env.MATCH_COUNTDOWN_MS, 10) || DEFAULT_COUNTDOWN_MS; + this.paidEntropyDelayBlocks = boundedBlockSetting( + paidEntropyDelayBlocks ?? process.env.MATCH_PAID_ENTROPY_DELAY_BLOCKS, + DEFAULT_PAID_ENTROPY_DELAY_BLOCKS, + 'MATCH_PAID_ENTROPY_DELAY_BLOCKS' + ); + this.paidEntropyConfirmations = boundedBlockSetting( + paidEntropyConfirmations ?? process.env.MATCH_PAID_ENTROPY_CONFIRMATIONS, + DEFAULT_PAID_ENTROPY_CONFIRMATIONS, + 'MATCH_PAID_ENTROPY_CONFIRMATIONS' + ); + this.blockEntropyProvider = blockEntropyProvider || (async headerHeight => { + const rpc = this.debugManager?.rpcService; + // Callers pass an actual zero-based canonical header height. DebugManager's external + // event is a block count and is normalized exactly once in _processBlock(). + return typeof rpc?.getBlockByHeight === 'function' + ? rpc.getBlockByHeight(Number(headerHeight)) + : null; + }); + this.blockCountProvider = blockCountProvider || (async () => { + const rpc = this.debugManager?.rpcService; + if (typeof rpc?.getBlockCountStrict === 'function') { + return rpc.getBlockCountStrict(); + } + if (typeof rpc?.makeRPCCall === 'function') { + const result = await rpc.makeRPCCall('getblockcount'); + return result?.count; + } + throw new Error('Strict daemon block count is unavailable'); + }); // Retained for interface stability; the scheduler no longer owns any long-lived timers // (the hard-ceiling watchdog is owned solely by MatchManager, cleared on finalize). this._timeoutIds = []; + this._shuttingDown = false; + this._lastProcessedBlockCount = null; + // SocketHandlers intentionally does not await match scheduling on the solo block path. + // Serialize blocks here so a later block can never race ahead of the durable freeze that + // it is supposed to supply entropy for. + this._blockTail = Promise.resolve(); } _log(...args) { if (this.debugManager?.CONSOLE_LOGGING) console.log(...args); } + async _strictDaemonBlockCount() { + const response = await this.blockCountProvider(); + const count = Number(response?.count ?? response); + if (!Number.isSafeInteger(count) || count < 1) { + throw new Error('Strict daemon block count is invalid'); + } + return count; + } + + async _strictDaemonTipHeight() { + return (await this._strictDaemonBlockCount()) - 1; + } + + _minimumConfirmedTip(targetBlockHeight) { + return Number(targetBlockHeight) + this.paidEntropyConfirmations - 1; + } + + async _readStableConfirmedHeader(targetBlockHeight) { + const target = Number(targetBlockHeight); + const minimumTip = this._minimumConfirmedTip(target); + const firstTip = await this._strictDaemonTipHeight(); + if (firstTip < minimumTip) return { ready: false, reason: 'confirmations_pending' }; + + const first = exactCanonicalHeader(await this.blockEntropyProvider(target), target); + if (!first) throw new Error(`Daemon did not return exact canonical header ${target}`); + + const secondTip = await this._strictDaemonTipHeight(); + if (secondTip < minimumTip) return { ready: false, reason: 'confirmations_pending' }; + const second = exactCanonicalHeader(await this.blockEntropyProvider(target), target); + if (!second) throw new Error(`Daemon did not re-confirm exact canonical header ${target}`); + if (first.hash !== second.hash) { + const error = new Error(`Canonical header ${target} changed during paid activation`); + error.code = 'PAID_ENTROPY_REORG'; + throw error; + } + return { ready: true, hash: second.hash, height: target }; + } + + async _recheckActivatedHeader(seedProof) { + const target = Number(seedProof?.derivation?.targetBlockHeight + ?? seedProof?.derivation?.blockHeight); + const expectedHash = String(seedProof?.derivation?.blockHash || '').toLowerCase(); + try { + const tip = await this._strictDaemonTipHeight(); + if (tip < this._minimumConfirmedTip(target)) return false; + const header = exactCanonicalHeader(await this.blockEntropyProvider(target), target); + return Boolean(header && header.hash === expectedHash); + } catch (err) { + this._log(`[MatchScheduler] post-activation header ${target} unavailable`, err.message); + return false; + } + } + + async _cancelFrozen(frozen, reason = 'match_cancel') { + if (!frozen?.id || typeof this.matchQueue?.cancelFrozenMatch !== 'function') { + const error = new Error('Exact paid-freeze cancellation API is unavailable'); + error.code = 'PAID_FREEZE_CANCEL_UNRESOLVED'; + throw error; + } + const result = await this.matchQueue.cancelFrozenMatch(frozen.id, reason); + if (!result?.resolved) { + const error = new Error(`Paid freeze ${frozen.id} cancellation remains unresolved`); + error.code = 'PAID_FREEZE_CANCEL_UNRESOLVED'; + throw error; + } + return true; + } + /** * Hook this into the existing DebugManager.onNewBlockCallback(). - * @param {number} blockHeight + * @param {number} blockCount Monero-family getblockcount count (tip header height + 1) */ - async onBlock(blockHeight) { - if (!this.enabled || !this.matchQueue || !this.matchManager) return; + onBlock(blockCount) { + if (!this.enabled || !this.matchQueue || !this.matchManager || this._shuttingDown) { + return Promise.resolve(); + } + const scheduled = this._blockTail.then(() => this._processBlock(blockCount)); + this._blockTail = scheduled.catch(() => {}); + return scheduled; + } - for (const economy of ['free', 'credits_prestige', 'crypto_race']) { + async _processBlock(blockCount) { + if (this._shuttingDown) return; + const count = Number(blockCount); + if (!Number.isSafeInteger(count) || count < 1) { + throw new Error('Match scheduler requires a positive safe block count'); + } + // getblockcount is the number of blocks, while get_block(height) is zero-based. Every + // persisted/disclosed fairness height below is the actual header height whose hash is + // requested—not the count emitted by DebugManager. + const observedHeaderHeight = count - 1; + + // A hot economy/payout disable must release both queued and already-frozen escrow. + if (typeof this.matchQueue.refundUnavailableQueues === 'function') { + await this.matchQueue.refundUnavailableQueues('match_cancel'); + } + if (this._shuttingDown) return; + + const advancingBlock = this._lastProcessedBlockCount == null + || count > this._lastProcessedBlockCount; + if (advancingBlock) this._lastProcessedBlockCount = count; + + // Existing active rooms see this header before any new room is attached. The manager + // requires a strict height advance beyond each room's start header and its active-play + // duration floor, so duplicate polls and same-header starts cannot end a match. + if (advancingBlock && typeof this.matchManager.expireBlockDeadlines === 'function') { try { - await this._drainEconomy(economy, blockHeight); + await this.matchManager.expireBlockDeadlines(observedHeaderHeight); } catch (err) { - // One economy failing must never block the others (or single-player blocks). - this._log(`[MatchScheduler] drain ${economy} error`, err.message); + this._log('[MatchScheduler] block-deadline processing error', err.message); + } + } + if (this._shuttingDown) return; + + // Phase 1: commit every currently eligible paid entrant set without reading ANY block + // hash. Each exact target is H+the configured delay, then a fresh strict daemon count is + // durably recorded after commit. Both paid economies complete this guard before phase 2. + let paidPreflightBlocked = false; + if (advancingBlock) { + for (const economy of ['credits_prestige', 'crypto_race']) { + try { + await this._freezePaidEconomy(economy, observedHeaderHeight); + } catch (err) { + paidPreflightBlocked = true; + this._log(`[MatchScheduler] freeze ${economy} error`, err.message); + } + } + } + if (this._shuttingDown) return; + + // Phase 2a validates/cancels every due paid freeze without reading a hash. This prevents a + // verified freeze in one economy from revealing entropy before an unverified freeze in the + // other economy has been refunded. Phase 2b alone performs exact target-header reads. + if (typeof this.matchQueue.listFrozenPaidMatches === 'function') { + let freezes = []; + try { + freezes = await this.matchQueue.listFrozenPaidMatches(observedHeaderHeight); + } catch (err) { + this._log('[MatchScheduler] frozen-match scan error', err.message); + } + const prepared = []; + for (const freeze of freezes) { + if (this._shuttingDown) return; + try { + const candidate = await this._prepareFrozenMatch(freeze, observedHeaderHeight); + if (candidate) prepared.push(candidate); + } catch (err) { + paidPreflightBlocked = true; + this._log(`[MatchScheduler] frozen match ${freeze?.id || 'unknown'} preflight retained`, err.message); + } + } + for (const candidate of paidPreflightBlocked ? [] : prepared) { + if (this._shuttingDown) return; + try { + await this._resolvePreparedFrozenMatch(candidate); + } catch (err) { + this._log(`[MatchScheduler] frozen match ${candidate?.frozen?.id || 'unknown'} retained`, err.message); + if (err?.code === 'PAID_FREEZE_CANCEL_UNRESOLVED') break; + } + } + } + + if (this._shuttingDown) return; + // Free matches preserve immediate scheduling. Drain first so even their current hash is + // not learned before this block's paid FIFO sets have been frozen. + if (advancingBlock) { + try { + await this._drainEconomy('free', observedHeaderHeight); + } catch (err) { + this._log('[MatchScheduler] drain free error', err.message); } } } - async _drainEconomy(economy, blockHeight) { - const drain = await this.matchQueue.drain(economy, this.maxPlayers); - if (!drain) return; + async _freezePaidEconomy(economy, blockHeight) { + if (this.matchQueue?.initialized === false || this._shuttingDown) return null; + if (typeof this.matchQueue?.isFinancialRecoveryReady === 'function' + && this.matchQueue.isFinancialRecoveryReady() !== true) return null; + if (typeof this.matchQueue.freezePaidMatch !== 'function') { + this._log(`[MatchScheduler] retaining ${economy}: durable freeze API unavailable`); + return null; + } + if (economy === 'crypto_race') { + const policy = matchPayoutAdmissionPolicy({ + env: process.env, + gameModeManager: this.matchManager?.gameModeManager, + ruleset: this.ruleset, + requestedMaxPlayers: this.maxPlayers + }); + if (!policy.enabled) return null; + } + const frozen = await this.matchQueue.freezePaidMatch({ + economy, + maxPlayers: this.maxPlayers, + minPlayers: this.minPlayers, + freezeBlockHeight: blockHeight, + targetBlockHeight: blockHeight + this.paidEntropyDelayBlocks, + rulesetId: this.ruleset.id, + variant: this.ruleset.entities.pvpCombat ? 'pvp' : 'race', + difficultyPreset: this.ruleset.world.difficultyPreset || 'race' + }); + if (!frozen) return null; - const { entries } = drain; - if (!entries || entries.length < 2) { - // Defensive: drain() already marked these 'matched'; refund them so a race that did - // not start never consumes credits/tickets. - if (entries && entries.length) { - await this._refund(entries, economy, 'match_cancel'); + // The count must be fetched only after freezePaidMatch's transaction is durably accepted. + // A delayed COMMIT that crosses the target can therefore never be disguised as a valid + // precommit; refund it without consulting that target's hash. + let tip; + try { + tip = await this._strictDaemonTipHeight(); + } catch (err) { + this._log(`[MatchScheduler] paid freeze ${frozen.id} precommit count unavailable`, err.message); + await this._cancelFrozen(frozen); + return null; // a crash before this cleanup is caught by startup recovery + } + if (tip < Number(frozen.freeze?.freezeBlockHeight) + || tip >= Number(frozen.targetBlockHeight)) { + await this._cancelFrozen(frozen); + return null; + } + if (typeof this.matchQueue.verifyFrozenPrecommit !== 'function') { + await this._cancelFrozen(frozen); + return null; + } + let proof; + try { + proof = await this.matchQueue.verifyFrozenPrecommit( + frozen.id, + frozen.freezeCommitment, + tip + ); + } catch (err) { + this._log(`[MatchScheduler] paid freeze ${frozen.id} precommit write unavailable`, err.message); + await this._cancelFrozen(frozen); + return null; + } + if (!proof?.verified) { + await this._cancelFrozen(frozen); + return null; + } + return Object.freeze({ + ...frozen, + precommitTipHeight: proof.observedTipHeight, + precommitVerifiedAt: proof.verifiedAt + }); + } + + async _prepareFrozenMatch(frozen, observedBlockHeight) { + if (typeof this.matchQueue?.isFinancialRecoveryReady === 'function' + && this.matchQueue.isFinancialRecoveryReady() !== true) return null; + if (!frozen || !frozen.freeze) { + await this._cancelFrozen(frozen); + return null; + } + if (frozen.freeze.version !== PAID_FREEZE_VERSION) { + await this._cancelFrozen(frozen); + return null; + } + if (typeof this.matchQueue.isEconomyAvailable === 'function' + && !this.matchQueue.isEconomyAvailable(frozen.economy)) { + await this._cancelFrozen(frozen); + return null; + } + if (frozen.economy === 'crypto_race') { + const policy = matchPayoutAdmissionPolicy({ + env: process.env, + gameModeManager: this.matchManager?.gameModeManager, + ruleset: this.ruleset, + requestedMaxPlayers: this.maxPlayers + }); + if (!policy.enabled) { + await this._cancelFrozen(frozen); + return null; } + } + let verifiedFreeze; + try { + verifiedFreeze = buildPaidEntrantFreeze(frozen.freeze); + } catch (err) { + await this._cancelFrozen(frozen); + return null; + } + if (String(frozen.freezeCommitment || '') !== verifiedFreeze.freezeCommitment) { + await this._cancelFrozen(frozen); + return null; + } + if (verifiedFreeze.targetBlockHeight > observedBlockHeight) return null; + if (verifiedFreeze.economy !== frozen.economy) { + await this._cancelFrozen(frozen); + return null; + } + if (verifiedFreeze.rulesetId !== this.ruleset.id) { + // A legitimate operator ruleset change must not reinterpret an old paid freeze. + // Its exact queue anchors are safely refundable because activation has not claimed it. + await this._cancelFrozen(frozen); + return null; + } + const precommitTip = Number(frozen.precommitTipHeight); + const verifiedAt = new Date(frozen.precommitVerifiedAt).getTime(); + const minimumDelay = String(process.env.NODE_ENV || '').toLowerCase() === 'production' ? 2 : 1; + if (frozen.precommitTipHeight == null + || !Number.isSafeInteger(precommitTip) + || precommitTip < verifiedFreeze.freezeBlockHeight + || precommitTip >= verifiedFreeze.targetBlockHeight + || !Number.isFinite(verifiedAt) + || verifiedFreeze.entropyDelayBlocks < minimumDelay) { + await this._cancelFrozen(frozen); + return null; + } + let exactEntrants; + try { + exactEntrants = buildPaidEntrantFreeze({ + ...verifiedFreeze, + queueEntryIds: (frozen.entries || []).map(entry => entry.queueEntryId) + }); + } catch (_) { + await this._cancelFrozen(frozen); + return null; + } + const frozenEntries = frozen.entries || []; + if (exactEntrants.freezeCommitment !== verifiedFreeze.freezeCommitment + || new Set(frozenEntries.map(entry => String(entry.userId))).size !== frozenEntries.length + || frozenEntries.some(entry => !String(entry.socketId || '')) + || new Set(frozenEntries.map(entry => String(entry.socketId))).size !== frozenEntries.length) { + await this._cancelFrozen(frozen); + return null; + } + + const tip = await this._strictDaemonTipHeight(); + if (tip < this._minimumConfirmedTip(verifiedFreeze.targetBlockHeight)) { + return null; + } + return { frozen, verifiedFreeze }; + } + + async _resolvePreparedFrozenMatch({ frozen, verifiedFreeze }) { + let canonical; + try { + canonical = await this._readStableConfirmedHeader(verifiedFreeze.targetBlockHeight); + } catch (err) { + if (err?.code === 'PAID_ENTROPY_REORG') await this._cancelFrozen(frozen); + else this._log(`[MatchScheduler] future block ${verifiedFreeze.targetBlockHeight} unavailable`, err.message); + return; + } + if (!canonical?.ready) return; + if (this._shuttingDown) return; + const derived = deriveFutureBlockMatchSeed({ + blockHash: canonical.hash, + blockHeight: verifiedFreeze.targetBlockHeight, + freeze: verifiedFreeze + }); + // Confirmation depth is activation-safety metadata, not additional seed material. Persist + // it with the proof so an auditor can evaluate the wait contract after configuration has + // changed, together with the durable post-commit daemon-tip witness. + const seedProof = Object.freeze({ + seed: derived.seed, + derivation: Object.freeze({ + ...derived.derivation, + entropyConfirmations: this.paidEntropyConfirmations, + minimumConfirmedTipHeight: this._minimumConfirmedTip( + verifiedFreeze.targetBlockHeight + ), + precommitTipHeight: Number(frozen.precommitTipHeight), + precommitVerifiedAt: new Date(frozen.precommitVerifiedAt).toISOString() + }) + }); + return this._startEntries({ + entries: frozen.entries, + economy: frozen.economy, + blockHeight: verifiedFreeze.targetBlockHeight, + seedProof, + frozen + }); + } + + async _resolveFrozenMatch(frozen, observedBlockHeight) { + const prepared = await this._prepareFrozenMatch(frozen, observedBlockHeight); + if (prepared) return this._resolvePreparedFrozenMatch(prepared); + return undefined; + } + + async _drainEconomy(economy, blockHeight, blockHash = null) { + // Debug/block polling starts before database recovery completes. The concrete MatchQueue + // exposes an explicit readiness bit; injected test/legacy queues without it keep working. + if (this.matchQueue?.initialized === false) return; + // Paid economies can only enter through the durable future-block freeze path above. + if (economy !== 'free') return; + + const drain = await this.matchQueue.drain(economy, this.maxPlayers, this.minPlayers); + if (!drain) return; + const { entries } = drain; + if (!extractBlockHash(blockHash)) { + try { + blockHash = extractBlockHash(await this.blockEntropyProvider(blockHeight)); + } catch (err) { + this._log(`[MatchScheduler] free block entropy unavailable at ${blockHeight}`, err.message); + } + } + const seedProof = extractBlockHash(blockHash) ? deriveBlockMatchSeed({ + blockHash, + blockHeight, + economy, + rulesetId: this.ruleset.id, + queueEntryIds: entries.map(entry => entry.queueEntryId) + }) : null; + return this._startEntries({ entries, economy, blockHeight, seedProof, frozen: null }); + } + + async _startEntries({ entries, economy, blockHeight, seedProof = null, frozen = null }) { + if (this._shuttingDown) return; + if (!entries || entries.length < this.minPlayers || entries.length > this.maxPlayers) { + // Defensive: drain() already marked these 'matched'; refund them when the actual + // entrant count violates the selected ruleset's player contract. + if (entries && entries.length) { + if (frozen && typeof this.matchQueue.cancelFrozenMatch === 'function') { + await this.matchQueue.cancelFrozenMatch(frozen.id, 'match_cancel'); + } else { + await this._refund(entries, economy, 'match_cancel'); + } + } + return; + } + + if (economy !== 'free' && seedProof?.derivation?.version !== PAID_SEED_VERSION) { + // No environment exception and no server-random fallback for value-bearing matches. + this._log(`[MatchScheduler] retaining frozen ${economy} entrants: future-block proof missing`); return; } @@ -97,25 +610,58 @@ class MatchScheduler { } const room = new MatchRoom({ + id: frozen?.id, + ruleset: this.ruleset, economy, - variant: 'race', - difficultyPreset: 'race', maxPlayers: this.maxPlayers, entrants, + seed: seedProof?.seed, startBlockHeight: blockHeight, cryptoType: process.env.CRYPTO_TYPE || 'WOW' }); + room.seedDerivation = seedProof?.derivation || { + version: 'server-random-v1', + blockHeight: Number(blockHeight), + economy: String(economy), + rulesetId: this.ruleset.id, + queueEntryIds: entries.map(entry => String(entry.queueEntryId)).sort() + }; room.minDurationMs = this.minDurationMs; room.hardCeilingMs = this.hardCeilingMs; - // Persist match + entrants BEFORE any money movement or notification so a crash mid-race - // is recoverable from the DB on next boot. - try { - await this._persistMatch(room, entries, economy); - } catch (err) { - this._log('[MatchScheduler] persist failed; refunding entrants', err.message); - await this._refund(entries, economy, 'match_cancel'); - return; + // Paid rooms claim an already-durable pending freeze. Free rooms keep the historical + // create-and-link path. Never refund an ambiguous paid activation: prove it committed or + // leave the row for crash recovery. + if (frozen) { + try { + await this._activateFrozenMatch(room, entries, frozen); + } catch (err) { + const accepted = await this._frozenActivationAccepted(room, frozen).catch(() => false); + if (!accepted) { + this._log(`[MatchScheduler] frozen activation ${room.id} unproven; retaining for recovery`); + return; + } + } + // The activation transaction changes the row to `starting` but leaves every queue + // escrow anchor untouched. Re-read the exact target now, before ticket collection, + // engine creation, or client notification. A reorg/unavailable daemon therefore + // takes the normal exact-anchor abort/refund path with no gameplay side effects. + if (!await this._recheckActivatedHeader(seedProof)) { + this._log(`[MatchScheduler] canonical target changed after activation of ${room.id}; aborting + refunding`); + await this._abortMatch(room, entries, economy); + return; + } + if (typeof this.matchQueue.noteFrozenStarted === 'function') { + this.matchQueue.noteFrozenStarted(frozen.id); + } + } else { + try { + await this._persistMatch(room, entries, economy); + } catch (err) { + this._log('[MatchScheduler] persist failed; refunding entrants', err.message); + await this._refund(entries, economy, 'match_cancel'); + return; + } } // MP-C4: collect the crypto pot / commit entry tickets BEFORE notifying players or @@ -129,9 +675,26 @@ class MatchScheduler { } await payoutService.collectEntryTickets(room, entries); } catch (err) { - this._log('[MatchScheduler] pot collection failed; aborting + refunding', err.message); - await this._abortMatch(room, entries, economy); - return; + // COMMIT acknowledgements can fail after PostgreSQL durably accepted the whole + // ticket/liability transaction. Inspect that immutable snapshot before aborting; + // cancelling a committed row while leaving consumed tickets would strand value. + const accepted = (payoutService && typeof payoutService.getAcceptedLiability === 'function') + ? await payoutService.getAcceptedLiability(room.id).catch(() => null) + : null; + if (accepted) { + room.entryFeeAtomic = String(accepted.entry_fee_atomic); + room.potAtomic = String(accepted.pot_atomic); + room.houseFeeAtomic = String(accepted.house_fee_atomic); + room.houseFeePercent = Number(accepted.house_fee_percent); + room.payoutLiabilityAmountAtomic = String(accepted.payout_liability_amount_atomic); + room.payoutLiabilityCapAtomic = String(accepted.payout_liability_cap_atomic); + room.payoutLiabilityTerms = accepted.payout_liability_terms; + this._log(`[MatchScheduler] collection acknowledgement failed but liability ${room.id} is durable; continuing`); + } else { + this._log('[MatchScheduler] pot collection failed; aborting + refunding', err.message); + await this._abortMatch(room, entries, economy); + return; + } } } @@ -163,17 +726,20 @@ class MatchScheduler { * method is unavailable (older queue implementations). */ async _refund(entries, economy, reason) { - if (!this.matchQueue) return; + if (!this.matchQueue) return { attempted: entries?.length || 0, resolved: 0, failed: entries?.length || 0 }; try { if (typeof this.matchQueue.refundEntries === 'function') { - await this.matchQueue.refundEntries(entries, economy, reason); - return; + return await this.matchQueue.refundEntries(entries, economy, reason); } + let resolved = 0; for (const e of entries) { - await this.matchQueue.leave(e.userId, economy).catch(() => {}); + const result = await this.matchQueue.leave(e.userId, economy).catch(() => null); + if (result?.success) resolved += 1; } + return { attempted: entries.length, resolved, failed: entries.length - resolved }; } catch (err) { this._log('[MatchScheduler] refund error', err.message); + return { attempted: entries?.length || 0, resolved: 0, failed: entries?.length || 0 }; } } @@ -181,22 +747,39 @@ class MatchScheduler { * Abort a match whose pot could not be collected: mark it cancelled and refund everyone. */ async _abortMatch(room, entries, economy) { + // Refund first. Each refund is anchored to its exact queue row and marks the entrant in + // the same transaction. If the process dies before the match status update, boot recovery + // sees those anchors already resolved and cannot double-refund them. + const refundResult = await this._refund(entries, economy, 'match_cancel'); + if (refundResult?.failed > 0) { + this._log(`[MatchScheduler] leaving match ${room.id} recoverable after ${refundResult.failed} refund failure(s)`); + return; + } if (this.matchManager?.db) { try { await this.matchManager.db.query( - `UPDATE matches SET status = 'cancelled', ended_at = NOW() WHERE id = $1`, + `UPDATE matches + SET status = 'cancelled', ended_at = NOW() + WHERE id = $1 + AND status = 'starting' + AND payout_liability_accepted_at IS NULL`, [room.id] ); } catch (err) { this._log('[MatchScheduler] abort mark-cancelled error', err.message); } } - await this._refund(entries, economy, 'match_cancel'); } async _persistMatch(room, entries, economy) { if (!this.matchManager?.db) return; + const queueEntryIds = entries.map(entry => entry?.queueEntryId); + if (queueEntryIds.some(id => id == null) + || new Set(queueEntryIds.map(String)).size !== queueEntryIds.length) { + throw new Error('Cannot persist match without unique durable queue entry ids'); + } + const matchRow = MatchState.toMatchRow(room); // Override row defaults with the actual queued economy and status. matchRow.status = 'starting'; @@ -205,19 +788,20 @@ class MatchScheduler { await this.matchManager.db.withTransaction(async (client) => { await client.query(` INSERT INTO matches ( - id, status, economy, variant, difficulty_preset, max_players, + id, status, economy, variant, ruleset_id, difficulty_preset, max_players, seed_hash, dungeon, start_block_height, entry_fee_atomic, pot_atomic, house_fee_atomic, house_fee_percent, created_at ) VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, $10, - $11, $12, $13, NOW() + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, + $12, $13, $14, NOW() ) `, [ matchRow.id, matchRow.status, matchRow.economy, matchRow.variant, + matchRow.ruleset_id, matchRow.difficulty_preset, matchRow.max_players, matchRow.seed_hash, @@ -229,19 +813,151 @@ class MatchScheduler { matchRow.house_fee_percent ]); + const linked = await client.query(` + UPDATE match_queue_entries + SET match_id = $1 + WHERE id = ANY($2::bigint[]) + AND economy = $3 + AND status = 'matched' + AND match_id IS NULL + RETURNING id, user_id + `, [room.id, queueEntryIds, economy]); + const expectedByQueueId = new Map(entries.map(entry => [ + String(entry.queueEntryId), + String(entry.userId) + ])); + const linksMatch = linked.rowCount === queueEntryIds.length + && (linked.rows || []).every(row => ( + expectedByQueueId.get(String(row.id)) === String(row.user_id) + )); + if (!linksMatch) { + throw new Error(`Queue-link mismatch for match ${room.id}: expected ${queueEntryIds.length}, got ${linked.rowCount}`); + } + for (const entrant of entries) { await client.query(` - INSERT INTO match_entrants (match_id, user_id, socket_id) - VALUES ($1, $2, $3) - `, [room.id, entrant.userId, entrant.socketId]); + INSERT INTO match_entrants (match_id, user_id, socket_id, queue_entry_id) + VALUES ($1, $2, $3, $4) + `, [room.id, entrant.userId, entrant.socketId, entrant.queueEntryId]); } }); } - shutdown() { + async _activateFrozenMatch(room, entries, frozen) { + if (!this.matchManager?.db) return; + const queueEntryIds = entries.map(entry => entry?.queueEntryId); + if (queueEntryIds.some(id => id == null) + || new Set(queueEntryIds.map(String)).size !== queueEntryIds.length) { + throw new Error('Cannot activate a freeze without unique durable queue entry ids'); + } + const matchRow = MatchState.toMatchRow(room); + await this.matchManager.db.withTransaction(async (client) => { + // This status claim serializes against MatchQueue.cancelFrozenMatch(). Whichever + // transaction wins owns every linked escrow anchor; the loser must not touch them. + const activated = await client.query(` + UPDATE matches + SET status = 'starting', + variant = $1, + ruleset_id = $2, + difficulty_preset = $3, + max_players = $4, + seed_hash = $5, + dungeon = $6::jsonb, + start_block_height = $7 + WHERE id = $8 + AND status = 'pending' + AND economy = $9 + AND start_block_height = $7 + AND seed_hash = $10 + AND entropy_precommit_tip_height IS NOT NULL + AND entropy_precommit_verified_at IS NOT NULL + AND entropy_precommit_tip_height < start_block_height + RETURNING id + `, [ + matchRow.variant, + matchRow.ruleset_id, + matchRow.difficulty_preset, + matchRow.max_players, + matchRow.seed_hash, + JSON.stringify(matchRow.dungeon), + matchRow.start_block_height, + room.id, + room.economy, + frozen.freezeCommitment + ]); + if (activated.rowCount !== 1) { + throw new Error(`Pending paid freeze ${room.id} was not claimable`); + } + + const expectedByQueueId = new Map(entries.map(entry => [ + String(entry.queueEntryId), String(entry.userId) + ])); + const linked = await client.query(` + SELECT id, user_id + FROM match_queue_entries + WHERE match_id = $1 + AND economy = $2 + AND status = 'matched' + ORDER BY id ASC + FOR UPDATE + `, [room.id, room.economy]); + const linksMatch = linked.rowCount === queueEntryIds.length + && (linked.rows || []).every(row => ( + expectedByQueueId.get(String(row.id)) === String(row.user_id) + )); + if (!linksMatch) throw new Error(`Frozen queue-link mismatch for match ${room.id}`); + + const entrantRows = await client.query(` + SELECT user_id, socket_id, queue_entry_id + FROM match_entrants + WHERE match_id = $1 + ORDER BY queue_entry_id ASC + FOR UPDATE + `, [room.id]); + const entrantLinksMatch = entrantRows.rowCount === entries.length + && (entrantRows.rows || []).every(row => ( + expectedByQueueId.get(String(row.queue_entry_id)) === String(row.user_id) + )); + if (!entrantLinksMatch) throw new Error(`Frozen entrant mismatch for match ${room.id}`); + }); + } + + async _frozenActivationAccepted(room, frozen) { + if (!this.matchManager?.db) return true; + const result = await this.matchManager.db.query(` + SELECT status, economy, seed_hash, start_block_height, dungeon, + entropy_precommit_tip_height, entropy_precommit_verified_at + FROM matches + WHERE id = $1 + LIMIT 1 + `, [room.id]); + const row = result.rows?.[0]; + let dungeon = row?.dungeon; + if (typeof dungeon === 'string') { + try { dungeon = JSON.parse(dungeon); } catch (_) { return false; } + } + const proof = dungeon?.match_fairness; + return row?.status === 'starting' + && row.economy === room.economy + && String(row.seed_hash) === room.seedHash + && Number(row.start_block_height) === Number(room.startBlockHeight) + && row.entropy_precommit_tip_height != null + && Number(row.entropy_precommit_tip_height) < Number(row.start_block_height) + && row.entropy_precommit_verified_at != null + && proof?.version === PAID_SEED_VERSION + && proof?.freezeCommitment === frozen.freezeCommitment; + } + + async shutdown() { + this._shuttingDown = true; + await this._blockTail.catch(() => {}); for (const id of this._timeoutIds) clearTimeout(id); this._timeoutIds = []; } } module.exports = MatchScheduler; +module.exports.BLOCK_SEED_VERSION = BLOCK_SEED_VERSION; +module.exports.deriveBlockMatchSeed = deriveBlockMatchSeed; +module.exports.deriveFutureBlockMatchSeed = deriveFutureBlockMatchSeed; +module.exports.extractBlockHash = extractBlockHash; diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index 3579b60..f282d53 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -5,6 +5,14 @@ */ const { normalizeError } = require('../utils/errors'); +const money = require('../money/atomic'); +const { + buildCommerceDisclosure, + validatePaidAcknowledgement +} = require('../config/commerceDisclosurePolicy'); + +const PENDING_COMMERCE_ACK_TTL_MS = 5 * 60 * 1000; +const PENDING_COMMERCE_ACK_MAX = 256; class PaymentHandlers { constructor({ io, gameModeManager, walletService, debugManager, queueManager, broadcastManager, sessionManager }) { @@ -24,6 +32,14 @@ class PaymentHandlers { this.paymentMonitors = new Map(); // Track pending payment metadata per socket (may reuse existing) this.socketPaymentMap = new Map(); // socketId -> { address, paymentId, amount, cryptoType, createdAt } + // A single-game fairness offer may arrive before payout-address confirmation. Preserve + // the already-consumed proof across that prompt; never fall back to generating a layout + // from an unpublished server seed when payment later confirms. + this.pendingEntryFairness = new Map(); + // Retain a canonical acknowledgement across the payout-address prompt. Entries are tiny, + // bounded, expire quickly, and are cleared on disconnect; raw client objects never enter + // this map. + this.pendingCommerceAcknowledgement = new Map(); // Track paymentIds already confirmed (avoid duplicate emits / queue actions) this.confirmedPayments = new Set(); // Periodic cleanup (confirmed payment IDs older than retention window) @@ -46,6 +62,7 @@ class PaymentHandlers { this.mempoolNotified.delete(addr); } } + this._sweepPendingCommerceAcknowledgements(now); }, 30 * 60 * 1000); // sweep every 30 min if (this._cleanupInterval && this._cleanupInterval.unref) { // Allow process (including Jest) to exit naturally without waiting for this interval @@ -53,28 +70,193 @@ class PaymentHandlers { } } + _entryModeForPaymentType(paymentType) { + if (paymentType === 'single_game') return 'PAID_SINGLE'; + if (paymentType === 'credits_package') return 'PAID_CREDITS'; + return null; + } + + _requiresBoundPaidFairness() { + return typeof this.gameModeManager?._requiresPaidFairnessV2 === 'function' + && this.gameModeManager._requiresPaidFairnessV2(); + } + + _invoiceCanLeadDirectlyToPayout(paymentType, { preselection = false } = {}) { + const type = String(paymentType || '').toLowerCase(); + if (type === 'single_game' || type === 'pay_direct') { + return !!this.gameModeManager?.isPayoutEnabledForMode?.('PAID_SINGLE'); + } + if (type === 'use_credit' || type === 'paid_credits') { + return !!this.gameModeManager?.isPayoutEnabledForMode?.('PAID_CREDITS'); + } + if (preselection && !type) { + return !!(this.gameModeManager?.isPayoutEnabledForMode?.('PAID_SINGLE') + || this.gameModeManager?.isPayoutEnabledForMode?.('PAID_CREDITS')); + } + // Buying credits/products creates no immediate payout promise. The authoritative reserve + // gate runs later when a credit is actually consumed for a payout-bearing game. + return false; + } + + _sweepPendingCommerceAcknowledgements(now = Date.now()) { + for (const [socketId, entry] of this.pendingCommerceAcknowledgement.entries()) { + if (!entry || entry.expiresAt <= now) this.pendingCommerceAcknowledgement.delete(socketId); + } + } + + _rememberPendingCommerceAcknowledgement(socketId, acknowledgement) { + if (!socketId || !acknowledgement) return; + this._sweepPendingCommerceAcknowledgements(); + this.pendingCommerceAcknowledgement.delete(socketId); + while (this.pendingCommerceAcknowledgement.size >= PENDING_COMMERCE_ACK_MAX) { + const oldest = this.pendingCommerceAcknowledgement.keys().next().value; + if (oldest === undefined) break; + this.pendingCommerceAcknowledgement.delete(oldest); + } + // Clone only the bounded canonical contract. Never retain a caller-owned object or any + // incidental socket payload fields in this short-lived continuation cache. + const canonicalAcknowledgement = Object.freeze({ + policyVersion: String(acknowledgement.policyVersion), + ageEligible: acknowledgement.ageEligible === true, + termsRead: acknowledgement.termsRead === true, + riskAccepted: acknowledgement.riskAccepted === true, + testnetUnderstood: acknowledgement.testnetUnderstood === true + }); + this.pendingCommerceAcknowledgement.set(socketId, { + acknowledgement: canonicalAcknowledgement, + expiresAt: Date.now() + PENDING_COMMERCE_ACK_TTL_MS + }); + } + + _getPendingCommerceAcknowledgement(socketId) { + const entry = this.pendingCommerceAcknowledgement.get(socketId); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + this.pendingCommerceAcknowledgement.delete(socketId); + return null; + } + return entry.acknowledgement; + } + + clearPendingCommerceAcknowledgement(socketId) { + this.pendingCommerceAcknowledgement.delete(socketId); + } + + _requirePaidAcknowledgement(socket, acknowledgement) { + const disclosure = buildCommerceDisclosure(this.gameModeManager, process.env); + // This method is reached only for value-bearing actions. Force the action-level gate even + // when payment intake is paused and a user is spending an existing credit/ticket. + const result = validatePaidAcknowledgement(acknowledgement, disclosure, { required: true }); + if (result.ok) return result.acknowledgement; + this.clearPendingCommerceAcknowledgement(socket.id); + this.pendingEntryFairness.delete(socket.id); + const payload = { + error: result.message, + message: result.message, + code: result.code, + policyVersion: disclosure.policyVersion + }; + this.io.to(socket.id).emit('commerce_ack_required', payload); + this.io.to(socket.id).emit('payment_error', payload); + return null; + } + + async _allowInvoiceFlowForReserve(socket, paymentType, options = {}) { + if (!this._invoiceCanLeadDirectlyToPayout(paymentType, options)) return true; + const alertService = this.gameModeManager?.alertService; + if (!alertService || typeof alertService.checkBalanceForGameStart !== 'function') { + return true; // authoritative transactional start gate remains fail-closed + } + const balanceCheck = await alertService.checkBalanceForGameStart(); + if (!balanceCheck.halted) return true; + this.io.to(socket.id).emit('balance_critical', { + reason: 'low_balance', + message: balanceCheck.reason || 'Sorry, the house balance is too low to initiate new games. Please try again later.' + }); + return false; + } + + /** + * Fail closed before creating an invoice when the resulting entry mode can pay out and the + * operator requires an address. The primary browser flow calls handlePaymentRequest directly, + * so this cannot live only in the legacy createAndShowPaymentRequest path. + */ + async _ensureRequiredPayoutAddress(socket, paymentType) { + const mode = this._entryModeForPaymentType(paymentType); + if (!mode || !this.gameModeManager) return { ok: true, reason: 'not_required' }; + const needsAddress = typeof this.gameModeManager.requiresPayoutAddressForMode === 'function' + ? this.gameModeManager.requiresPayoutAddressForMode(mode) + : ((mode === 'PAID_SINGLE') + || (mode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled)); + if (!needsAddress) return { ok: true, reason: 'not_required' }; + + try { + const userRow = await this.gameModeManager.getOrCreateUser(socket.id); + if (userRow?.payout_address) return { ok: true, reason: 'present' }; + const message = 'Before paying, add a payout address so winnings can be delivered.'; + this.broadcastManager.sendStatusUpdate(socket.id, 'payment', `💳 ${message}`); + this.io.to(socket.id).emit('payment_error', { error: message, code: 'PAYOUT_ADDRESS_REQUIRED' }); + return { ok: false, reason: 'address_required' }; + } catch (error) { + const normalized = normalizeError(error, 'Failed to verify payout address'); + console.error('Address pre-check failed:', normalized.message); + this.io.to(socket.id).emit('payment_error', { + error: 'Could not verify your payout address. Please try again.', + code: 'PAYOUT_ADDRESS_CHECK_FAILED' + }); + return { ok: false, reason: 'address_check_failed' }; + } + } + async handlePaymentRequest(socket, data) { if (!this.gameModeManager) { + this.clearPendingCommerceAcknowledgement(socket.id); this.io.to(socket.id).emit('payment_error', { error: 'Payment system not available' }); return; } - // Check if house balance is critically low before proceeding with payment - if (this.gameModeManager.alertService) { - const balanceCheck = await this.gameModeManager.alertService.checkBalanceForGameStart(); - if (balanceCheck.halted) { - this.io.to(socket.id).emit('balance_critical', { - reason: 'low_balance', - message: balanceCheck.reason || 'Sorry, the house balance is too low to initiate new games. Please try again later.' - }); - return; - } + const paymentType = data?.type || data?.gameMode || 'single_game'; + if (typeof this.gameModeManager.isPaymentIntakeEnabled === 'function' + && !this.gameModeManager.isPaymentIntakeEnabled(paymentType)) { + this.clearPendingCommerceAcknowledgement(socket.id); + this.pendingEntryFairness.delete(socket.id); + this.io.to(socket.id).emit('payment_error', { + error: 'That paid product is not available on this server.', + code: 'PAYMENT_INTAKE_DISABLED' + }); + return; } + const acknowledgement = this._requirePaidAcknowledgement(socket, data?.legalAcknowledgement); + if (!acknowledgement) return; + try { - const { type, packageId, productId } = data; - const paymentType = type || data.gameMode || 'single_game'; - const options = { packageId, productId, reuseExisting: true }; + const { type, packageId, productId } = data || {}; + const paymentType = type || data?.gameMode || 'single_game'; + if (!await this._allowInvoiceFlowForReserve(socket, paymentType)) { + this.clearPendingCommerceAcknowledgement(socket.id); + this.pendingEntryFairness.delete(socket.id); + return; + } + if (paymentType === 'single_game' && data?.fairnessProof) { + this.pendingEntryFairness.set(socket.id, data.fairnessProof); + } + const addressCheck = await this._ensureRequiredPayoutAddress(socket, paymentType); + if (!addressCheck.ok) { + if (addressCheck.reason === 'address_required') { + this._rememberPendingCommerceAcknowledgement(socket.id, acknowledgement); + } else { + this.clearPendingCommerceAcknowledgement(socket.id); + this.pendingEntryFairness.delete(socket.id); + } + return; + } + const options = { + packageId, + productId, + reuseExisting: true, + fairnessProof: data.fairnessProof || this.pendingEntryFairness.get(socket.id) || null + }; const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, options); const cryptoType = this.gameModeManager.cryptoType; @@ -108,8 +290,16 @@ class PaymentHandlers { grants: paymentRequest.grants, paymentType: paymentType, qr: qrDataUrl, - reused: !!paymentRequest.reused + reused: !!paymentRequest.reused, + fairness: paymentRequest.fairnessProof ? { + proofVersion: paymentRequest.fairnessProof.proofVersion, + offerId: paymentRequest.fairnessProof.offerId, + offerIssuedAt: paymentRequest.fairnessProof.offerIssuedAt, + commitment: paymentRequest.fairnessProof.commitment, + clientSeed: paymentRequest.fairnessProof.clientSeed + } : null }); + this.clearPendingCommerceAcknowledgement(socket.id); // CRITICAL: Start payment monitoring! // Stop any existing monitoring for this socket first @@ -120,10 +310,14 @@ class PaymentHandlers { this.queueManager.getUserBySocket(socket.id) : { serverId: socket.id }; // Start monitoring for payment - this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser, paymentType); + this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser, + paymentType, paymentRequest.fairnessProof || options.fairnessProof || null); + if (paymentType === 'single_game') this.pendingEntryFairness.delete(socket.id); if (this.debugManager.CONSOLE_LOGGING) console.log(`💳 Payment request created for ${socket.id}: ${paymentRequest.amount}`); } catch (e) { + this.clearPendingCommerceAcknowledgement(socket.id); + this.pendingEntryFairness.delete(socket.id); const err = normalizeError(e, 'Failed to create payment request'); console.error('Error creating payment request:', err.message); this.io.to(socket.id).emit('payment_error', { error: err.safeMessage }); @@ -131,18 +325,23 @@ class PaymentHandlers { } async createAndShowPaymentRequest(socket, options = {}) { - if (!this.gameModeManager) return; + if (!this.gameModeManager) { + this.clearPendingCommerceAcknowledgement(socket.id); + this.pendingEntryFairness.delete(socket.id); + return; + } - // Check if house balance is critically low before proceeding with payment - if (this.gameModeManager.alertService) { - const balanceCheck = await this.gameModeManager.alertService.checkBalanceForGameStart(); - if (balanceCheck.halted) { - this.io.to(socket.id).emit('balance_critical', { - reason: 'low_balance', - message: balanceCheck.reason || 'Sorry, the house balance is too low to initiate new games. Please try again later.' - }); - return; - } + const suppliedAcknowledgement = options.legalAcknowledgement + || this._getPendingCommerceAcknowledgement(socket.id); + const acknowledgement = this._requirePaidAcknowledgement(socket, suppliedAcknowledgement); + if (!acknowledgement) return; + + if (!await this._allowInvoiceFlowForReserve(socket, options.paymentType, { + preselection: !options.paymentType + })) { + this.clearPendingCommerceAcknowledgement(socket.id); + this.pendingEntryFairness.delete(socket.id); + return; } // If both direct and credits modes are enabled, let the user choose @@ -155,26 +354,11 @@ class PaymentHandlers { reason: 'choose_payment_method', message: 'Choose how you want to play' }); + this.clearPendingCommerceAcknowledgement(socket.id); return; } try { - // Ensure payout address exists for modes that may payout (PAID_SINGLE or PAID_CREDITS with payouts) - const mode = this.gameModeManager.gameMode; - const needsAddress = (mode === 'PAID_SINGLE') || (mode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled); - if (needsAddress) { - try { - const userRow = await this.gameModeManager.getOrCreateUser(socket.id); - if (!userRow.payout_address) { - this.broadcastManager.sendStatusUpdate(socket.id, 'payment', '💳 Before paying, paste your payout address (XMR/WOW) then type confirm.'); - // Do not proceed until address is set - return; - } - } catch (e) { - const err = normalizeError(e, 'Failed to verify payout address'); - console.error('Address pre-check failed:', err.message); - } - } const currentUser = socket.id && this.queueManager.getUserBySocket ? this.queueManager.getUserBySocket(socket.id) : null; // caller (SocketHandlers) will handle user existence; keep method generic const gameMode = this.gameModeManager.gameMode; @@ -192,23 +376,46 @@ class PaymentHandlers { } } + if (paymentType === 'single_game' && options.fairnessProof) { + this.pendingEntryFairness.set(socket.id, options.fairnessProof); + } + + const fairnessProof = paymentType === 'single_game' + ? (options.fairnessProof || this.pendingEntryFairness.get(socket.id) || null) + : null; + + const addressCheck = await this._ensureRequiredPayoutAddress(socket, paymentType); + if (!addressCheck.ok) { + if (addressCheck.reason === 'address_required') { + this._rememberPendingCommerceAcknowledgement(socket.id, acknowledgement); + } else { + this.clearPendingCommerceAcknowledgement(socket.id); + this.pendingEntryFairness.delete(socket.id); + } + return; + } + if (paymentType === 'single_game') { amount = this.gameModeManager.singleGamePrice; description = 'Single game entry'; } else if (paymentType === 'credits_package') { const primaryPackage = this.gameModeManager.getPrimaryCreditPackage(); - amount = Number(primaryPackage?.price ?? this.gameModeManager.creditsPackagePrice); + amount = money.toSafe(money.toBig(primaryPackage?.price ?? this.gameModeManager.creditsPackagePrice)); const credits = primaryPackage?.credits ?? this.gameModeManager.creditsPerGameCost * 10; description = `${credits} credit package`; } else if (paymentType === 'cosmetic_pack') { const product = this.gameModeManager.getCosmeticProduct(options.productId || options.packageId); if (!product) { + this.clearPendingCommerceAcknowledgement(socket.id); + this.pendingEntryFairness.delete(socket.id); this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid product selection.'); return; } - amount = Number(product.price); + amount = money.toSafe(money.toBig(product.price)); description = product.label || product.id; } else { + this.clearPendingCommerceAcknowledgement(socket.id); + this.pendingEntryFairness.delete(socket.id); this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid game mode configuration.'); return; } @@ -223,7 +430,8 @@ class PaymentHandlers { reuseExisting: true, packageId: options.packageId, productId: options.productId, - userId: sessionUserId + userId: sessionUserId, + fairnessProof }); const reused = !!paymentRequest.reused; @@ -264,8 +472,16 @@ class PaymentHandlers { expiresAt: paymentRequest.expiresAt, qr: qrDataUrl, package: paymentRequest.package, - reused + reused, + fairness: paymentRequest.fairnessProof ? { + proofVersion: paymentRequest.fairnessProof.proofVersion, + offerId: paymentRequest.fairnessProof.offerId, + offerIssuedAt: paymentRequest.fairnessProof.offerIssuedAt, + commitment: paymentRequest.fairnessProof.commitment, + clientSeed: paymentRequest.fairnessProof.clientSeed + } : null }); + this.clearPendingCommerceAcknowledgement(socket.id); const statusHeader = reused ? '🔁 Existing payment request still pending. Use the details below to pay.' @@ -284,15 +500,19 @@ class PaymentHandlers { // If we reused an existing request, ensure we refresh monitoring to avoid duplicate watchers this.stopMonitoringForSocket(socket.id); - this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser, paymentType); + this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser, + paymentType, paymentRequest.fairnessProof || fairnessProof); + if (paymentType === 'single_game') this.pendingEntryFairness.delete(socket.id); } catch (e) { + this.clearPendingCommerceAcknowledgement(socket.id); + this.pendingEntryFairness.delete(socket.id); const err = normalizeError(e, 'Failed to create payment request'); console.error('Error creating payment request:', err.message); this.broadcastManager.sendStatusUpdate(socket.id, 'error', err.safeMessage); } } - _monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser, paymentType = 'single_game') { + _monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser, paymentType = 'single_game', fairnessProof = null) { // Select the provider that owns this chain: native wallet-RPC by default, or a BTCPay/ // checkout gateway when this.cryptoType is routed to one. Native watches by subaddress; // a gateway watches by invoice id. The confirmation handler is identical either way. @@ -308,6 +528,7 @@ class PaymentHandlers { amount: paymentRequest.amount, cryptoType, paymentType, + fairnessProof: paymentType === 'single_game' ? fairnessProof : null, package: paymentRequest.package, provider: provider || null, watchRef, @@ -349,10 +570,28 @@ class PaymentHandlers { const mapping = this.socketPaymentMap.get(socket.id); const isGameEntry = !mapping || mapping.paymentType === 'single_game'; + // A production paid entry is only usable with the proof durably bound to its + // invoice. Never put a legacy/corrupt unbound payment into the queue merely + // because its transaction appeared in the mempool. + if (isGameEntry && this._requiresBoundPaidFairness() && !mapping?.fairnessProof) { + console.error(`[PaymentHandlers] Refusing to queue unbound paid entry ${paymentRequest.id}`); + socket.emit('payment_review_required', { + paymentId: paymentRequest.id, + code: 'PAYMENT_FAIRNESS_UNBOUND', + message: 'This payment requires support review before a game can start.' + }); + return; + } + const detectMessage = !isGameEntry ? 'Payment detected in mempool! Awaiting block confirmation to apply your purchase...' : 'Payment detected in mempool! Adding you to the game queue...'; - socket.emit('payment_detected', { paymentId: paymentRequest.id, message: detectMessage, amount: status.amount, confirmations: 0 }); + socket.emit('payment_detected', { + paymentId: paymentRequest.id, + message: detectMessage, + amount: status.observedAmount ?? status.amount, + confirmations: 0 + }); // Only queue for single_game payments. Product purchases just apply grants. if (isGameEntry) { @@ -370,7 +609,8 @@ class PaymentHandlers { userId: userId, paymentId: paymentRequest.id, requiresConfirmation: true, - confirmed: false + confirmed: false, + fairnessProof: mapping?.fairnessProof || null }); } socket.emit('queue_joined', { position: (existingIdx === -1 ? this.queueManager.getQueueLength() : existingIdx + 1), message: 'Payment received! Waiting for next block to start game...', currentBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null, nextBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() + 1 : null }); @@ -381,14 +621,23 @@ class PaymentHandlers { // checkPaymentStatus() sets `confirmed` for ANY incoming tx on the subaddress, // while `complete` means totalReceived >= required. Without this gate a player // could send a single atomic unit and play (or buy credits) for free. - if (!status.complete) { + let exactCoverage = false; + try { + exactCoverage = money.toBig(status.amount || 0) + >= money.toBig(paymentRequest.amount); + } catch (_) { exactCoverage = false; } + if (!status.complete || !exactCoverage) { // Underpaid. Keep monitoring so a later top-up to the same address can // complete it; warn the user once to avoid spamming on every 2s poll. if (!this.underpaidNotified.has(paymentRequest.id)) { this.underpaidNotified.add(paymentRequest.id); const required = status.required || 0; const received = status.amount || 0; - const shortfall = Math.max(0, required - received); + let shortfall = 0; + try { + const exact = money.toBig(required) - money.toBig(received); + shortfall = money.toSafe(exact > 0n ? exact : 0n); + } catch (_) { shortfall = 0; } console.warn(`[PaymentHandlers] Underpaid payment ${paymentRequest.id}: received ${received}, required ${required} (shortfall ${shortfall})`); try { socket.emit('payment_underpaid', { @@ -412,6 +661,23 @@ class PaymentHandlers { return; } + const confirmedMapping = this.socketPaymentMap.get(socket.id); + const confirmedIsGameEntry = !confirmedMapping + || confirmedMapping.paymentType === 'single_game'; + if (confirmedIsGameEntry && this._requiresBoundPaidFairness() + && !confirmedMapping?.fairnessProof) { + console.error(`[PaymentHandlers] Refusing to confirm unbound paid entry ${paymentRequest.id}`); + socket.emit('payment_review_required', { + paymentId: paymentRequest.id, + code: 'PAYMENT_FAIRNESS_UNBOUND', + message: 'Funds were detected, but this entry requires support review.' + }); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', + 'Payment received, but its fairness commitment is missing. No game was started; contact support.'); + this.stopMonitoringForSocket(socket.id); + return; + } + // Mark in memory to prevent duplicate processing within this session this.confirmedPayments.add(paymentRequest.id); this._confirmedTimestamps.set(paymentRequest.id, Date.now()); @@ -425,7 +691,8 @@ class PaymentHandlers { socket.id, paymentRequest.id, mapping.package, - Math.round(status.amount || 0) + money.toBig(status.amount || 0).toString(), + status.receipts || [] ); if (creditsResult.success) { socket.emit('credits_update', { @@ -454,11 +721,23 @@ class PaymentHandlers { socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment already processed.', confirmations: status.confirmations }); } else { console.error('Failed to process credits package:', creditsResult.reason); - socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed but failed to add credits. Contact support.', confirmations: status.confirmations }); + socket.emit('payment_review_required', { + paymentId: paymentRequest.id, + code: 'PAYMENT_GRANT_REJECTED', + message: 'Funds were detected, but the purchase was not applied and requires support review.' + }); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', + 'Payment received, but no entitlement was applied. Contact support for review.'); } } catch (e) { console.error('Error processing credits package confirmation:', e.message); - socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations }); + socket.emit('payment_review_required', { + paymentId: paymentRequest.id, + code: 'PAYMENT_RECEIPT_REJECTED', + message: 'Funds were detected, but the purchase transaction failed and requires support review.' + }); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', + 'Payment received, but no entitlement was applied. Contact support for review.'); } // Defensively remove from queue — credits purchases should never be queued this.queueManager.removePlayer(socket.id); @@ -467,8 +746,16 @@ class PaymentHandlers { // CRITICAL: Atomically update payment status and check if we actually updated it // This prevents double-processing on server restart let wasUpdated = false; + let confirmationError = null; try { - if (this.gameModeManager && this.gameModeManager.db) { + if (typeof this.gameModeManager?.confirmSingleGamePayment === 'function') { + const confirmation = await this.gameModeManager.confirmSingleGamePayment( + paymentRequest.id, + money.toBig(status.amount || 0).toString(), + status.receipts || [] + ); + wasUpdated = confirmation?.updated === true; + } else if (this.gameModeManager && this.gameModeManager.db) { const updateResult = await this.gameModeManager.db.query(` UPDATE payments SET status = 'confirmed', @@ -476,18 +763,28 @@ class PaymentHandlers { received_amount = $2 WHERE id = $1 AND status = 'pending' RETURNING id - `, [paymentRequest.id, Math.round(status.amount || 0)]); + `, [paymentRequest.id, money.toBig(status.amount || 0).toString()]); wasUpdated = updateResult.rows.length > 0; if (this.debugManager.CONSOLE_LOGGING) { console.log(`[PaymentHandlers] Payment ${paymentRequest.id} status update: ${wasUpdated ? 'success' : 'already confirmed'}`); } } } catch (dbErr) { + confirmationError = dbErr; console.error('[PaymentHandlers] Failed to update payment status in DB:', dbErr.message); } // Only proceed with game logic if we actually confirmed this payment if (!wasUpdated) { + if (confirmationError) { + socket.emit('payment_review_required', { + paymentId: paymentRequest.id, + code: 'PAYMENT_RECEIPT_REJECTED', + message: 'Funds were detected, but their receipt evidence requires support review.' + }); + this.stopMonitoringForSocket(socket.id); + return; + } console.log(`[PaymentHandlers] Payment ${paymentRequest.id} already confirmed, skipping duplicate processing`); socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment already processed.', confirmations: status.confirmations }); this.stopMonitoringForSocket(socket.id); @@ -531,7 +828,8 @@ class PaymentHandlers { userId: userId, paymentId: paymentRequest.id, requiresConfirmation: false, // Already confirmed - confirmed: true + confirmed: true, + fairnessProof: mapping?.fairnessProof || null }); } else { // Player was in queue from mempool detection, just mark confirmed @@ -610,6 +908,8 @@ class PaymentHandlers { clearTimeout(to); this._expiryTimeouts.delete(socketId); } + this.pendingCommerceAcknowledgement.clear(); + this.pendingEntryFairness.clear(); } } diff --git a/src/network/prestigeLeaderboard.js b/src/network/prestigeLeaderboard.js new file mode 100644 index 0000000..d3ddf68 --- /dev/null +++ b/src/network/prestigeLeaderboard.js @@ -0,0 +1,49 @@ +'use strict'; + +const PERIOD_MS = Object.freeze({ + week: 7 * 24 * 60 * 60 * 1000, + month: 30 * 24 * 60 * 60 * 1000 +}); + +function normalizeLeaderboardPeriod(value) { + const period = String(value || 'all').toLowerCase(); + return Object.prototype.hasOwnProperty.call(PERIOD_MS, period) ? period : 'all'; +} + +function prestigeCutoff(period, now = new Date()) { + const normalized = normalizeLeaderboardPeriod(period); + if (normalized === 'all') return null; + return new Date(now.getTime() - PERIOD_MS[normalized]); +} + +async function queryPrestigeLeaderboard(db, { period = 'all', limit = 20, now = new Date() } = {}) { + const cutoff = prestigeCutoff(period, now); + return db.query(` + SELECT + u.id, + COALESCE(u.display_name, 'Anon#' || u.id) AS name, + MAX(me.score) AS best_score, + COUNT(*) FILTER ( + WHERE me.placement = 1 + AND m.winner_user_id = me.user_id + ) AS wins, + COUNT(*) AS games_played + FROM match_entrants me + JOIN matches m ON me.match_id = m.id + JOIN users u ON me.user_id = u.id + WHERE m.economy = 'credits_prestige' + AND m.status = 'finished' + AND m.ruleset_id <> 'coop-escape' + AND me.score > 0 + AND ($1::timestamptz IS NULL OR m.ended_at > $1::timestamptz) + GROUP BY u.id, u.display_name + ORDER BY best_score DESC + LIMIT $2 + `, [cutoff, limit]); +} + +module.exports = { + normalizeLeaderboardPeriod, + prestigeCutoff, + queryPrestigeLeaderboard +}; diff --git a/src/network/queueHandler.js b/src/network/queueHandler.js index e9570e2..7448a41 100644 --- a/src/network/queueHandler.js +++ b/src/network/queueHandler.js @@ -26,6 +26,8 @@ class QueueHandler { // the auto_start free path so the "Next block · Free Play" choice doesn't wrongly pop the // payment modal for a free queued game. const wantsFree = opts.free === true && this.gameModeManager?.freePlayEnabled; + let authorizedFairnessProof = opts.fairnessProof || null; + let authorizedPaymentId = null; // Rate limiting for queue attempts const rateLimitResult = await this.rateLimiter.checkLimit(socket.id, 'game:queue'); if (!rateLimitResult.allowed) { @@ -67,7 +69,10 @@ class QueueHandler { this.broadcastManager.sendStatusUpdate(socket.id, 'payment', '⚠️ Paste your payout address first, then type confirm.'); break; case 'make_payment': - await this.paymentHandlers.createAndShowPaymentRequest(socket); + await this.paymentHandlers.createAndShowPaymentRequest(socket, { + fairnessProof: opts.fairnessProof || null, + legalAcknowledgement: opts.legalAcknowledgement + }); break; default: this.broadcastManager.sendStatusUpdate(socket.id, 'error', @@ -75,6 +80,15 @@ class QueueHandler { } return; } + if (paymentCheckResult.effectiveMode === 'PAID_SINGLE') { + authorizedFairnessProof = paymentCheckResult.fairnessProof || null; + authorizedPaymentId = paymentCheckResult.paymentId || null; + if (this.gameModeManager._requiresPaidFairnessV2?.() && !authorizedFairnessProof) { + this.broadcastManager.sendStatusUpdate(socket.id, 'error', + 'This paid entry has no durable fairness binding and requires support review.'); + return; + } + } } // Record the queue attempt @@ -95,9 +109,11 @@ class QueueHandler { serverId: socket.id, clientId: currentUser.clientId, userId: userId, + paymentId: authorizedPaymentId, requiresConfirmation: false, confirmed: true, - free: wantsFree + free: wantsFree, + fairnessProof: authorizedFairnessProof }); const currentBlock = this.debugManager.getCurrentBlockHeight(); @@ -179,7 +195,7 @@ class QueueHandler { * Handle early entry request - start game immediately without waiting for next block * Risk: Player will die if next block is found before they escape */ - async handleEarlyEntry(socket, getUserBySocket) { + async handleEarlyEntry(socket, getUserBySocket, opts = {}) { try { // Rate limiting const rateLimitResult = await this.rateLimiter.checkLimit(socket.id, 'game:queue'); @@ -234,7 +250,9 @@ class QueueHandler { this.broadcastManager.sendStatusUpdate(socket.id, 'payment', '⚠️ Paste your payout address first, then type confirm.'); break; case 'make_payment': - await this.paymentHandlers.createAndShowPaymentRequest(socket); + await this.paymentHandlers.createAndShowPaymentRequest(socket, { + legalAcknowledgement: opts.legalAcknowledgement + }); break; default: this.broadcastManager.sendStatusUpdate(socket.id, 'error', @@ -251,7 +269,9 @@ class QueueHandler { // For early entry, the player's blockRec is set to current block // This means they will die when the NEXT block is found (currentBlock + 1) - const result = await this.queueManager.startEarlyGame(socket.id, currentUser, currentBlock); + const result = await this.queueManager.startEarlyGame(socket.id, currentUser, currentBlock, { + fairnessProof: opts.fairnessProof || null + }); if (result.success) { // Emit early entry success event @@ -293,41 +313,51 @@ class QueueHandler { } try { - // Check payout address requirement for payout-eligible modes - const payoutEligible = (this.gameModeManager.gameMode === 'PAID_SINGLE') || - (this.gameModeManager.gameMode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled); - - if (payoutEligible) { - const user = await this.gameModeManager.getOrCreateUser(socketId); - if (!user.payout_address) { - return { - allowed: false, - action: 'set_address', - reason: 'Payout address required' - }; - } - } - - // Check general payment eligibility + // Resolve eligibility first: on a mixed instance the actual entry may consume a + // credit or a confirmed direct payment, and those modes can have different payout + // policies. Address-gate the mode that processGameStart will really use. const eligibility = await this.gameModeManager.canUserStartGame(socketId); - + if (!eligibility.allowed) { if (this.debugManager.CONSOLE_LOGGING) { console.log(`❌ Payment required for ${socketId}: ${eligibility.reason}`); } - + return { allowed: false, action: eligibility.action === 'purchase_credits' ? 'make_payment' : 'make_payment', reason: eligibility.reason }; } - + + const effectiveMode = eligibility.effectiveMode || this.gameModeManager.gameMode; + const needsAddress = typeof this.gameModeManager.requiresPayoutAddressForMode === 'function' + ? this.gameModeManager.requiresPayoutAddressForMode(effectiveMode) + : ((effectiveMode === 'PAID_SINGLE') + || (effectiveMode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled)); + + if (needsAddress) { + const user = await this.gameModeManager.getOrCreateUser(socketId); + if (!user.payout_address) { + return { + allowed: false, + action: 'set_address', + reason: 'Payout address required' + }; + } + } + if (this.debugManager.CONSOLE_LOGGING) { console.log(`✅ Payment validated for ${socketId}: ${eligibility.reason}`); } - return { allowed: true, reason: eligibility.reason }; + return { + allowed: true, + reason: eligibility.reason, + effectiveMode, + paymentId: eligibility.paymentId || null, + fairnessProof: eligibility.fairnessProof || null + }; } catch (error) { console.error('Error checking payment eligibility:', error); @@ -350,4 +380,4 @@ class QueueHandler { } } -module.exports = QueueHandler; \ No newline at end of file +module.exports = QueueHandler; diff --git a/src/network/queueManager.js b/src/network/queueManager.js index 1ae7f28..423adef 100644 --- a/src/network/queueManager.js +++ b/src/network/queueManager.js @@ -22,9 +22,9 @@ class QueueManager { this._waitingPlayers = []; // internal queue } - addPlayer({ serverId, clientId, userId = null, entryTime = Date.now(), paymentId = null, requiresConfirmation = false, confirmed = true, free = false }) { + addPlayer({ serverId, clientId, userId = null, entryTime = Date.now(), paymentId = null, requiresConfirmation = false, confirmed = true, free = false, fairnessProof = null }) { if (this.getPlayerIndex(serverId) !== -1) return; // already queued - this._waitingPlayers.push({ serverId, clientId, userId, entryTime, paymentId, requiresConfirmation, confirmed, free }); + this._waitingPlayers.push({ serverId, clientId, userId, entryTime, paymentId, requiresConfirmation, confirmed, free, fairnessProof }); if (this.CONSOLE_LOGGING) { console.log(`[QueueManager] Added player ${serverId} (userId=${userId}, confirmed=${confirmed}, requiresConfirmation=${requiresConfirmation}). Queue length: ${this._waitingPlayers.length}`); } @@ -109,7 +109,9 @@ class QueueManager { console.log(`[QueueManager] Player ${serverId} enters on block ${blockHeight} (dies after ${blockHeight + 1})`); } try { - const game = await this.createGameForUser(currentUser, 'standard'); + const game = await this.createGameForUser(currentUser, 'standard', { + fairnessProof: entry.fairnessProof || null + }); const gameState = game.getState(); gameState.blockHeight = blockHeight; // Include provably fair commitment @@ -124,7 +126,7 @@ class QueueManager { if (!startRes.success) { // Abort game + clean up orphaned DB record if (this.activeGames) this.activeGames.delete(serverId); - this._cleanupOrphanedGame(game); + await this._cleanupOrphanedGame(game); this.io.to(serverId).emit('message', 'Error starting game: ' + (startRes.reason || 'Payment processing failed')); continue; } @@ -180,7 +182,9 @@ class QueueManager { if (!currentUser) return false; currentUser.blockRec = blockHeight; // lifetime until next block try { - const game = await this.createGameForUser(currentUser, 'standard'); + const game = await this.createGameForUser(currentUser, 'standard', { + fairnessProof: entry.fairnessProof || null + }); const gameState = game.getState(); gameState.blockHeight = blockHeight; // Include provably fair commitment @@ -195,7 +199,7 @@ class QueueManager { if (!startRes.success) { // Abort game + clean up orphaned DB record if (this.activeGames) this.activeGames.delete(serverId); - this._cleanupOrphanedGame(game); + await this._cleanupOrphanedGame(game); this.io.to(serverId).emit('message', 'Error starting game: ' + (startRes.reason || 'Payment processing failed')); return false; } @@ -224,7 +228,7 @@ class QueueManager { * @param {number} blockHeight - Current block height * @returns {Object} { success: boolean, reason?: string } */ - async startEarlyGame(serverId, currentUser, blockHeight) { + async startEarlyGame(serverId, currentUser, blockHeight, options = {}) { if (!currentUser) { return { success: false, reason: 'User not found' }; } @@ -239,7 +243,10 @@ class QueueManager { currentUser.isEarlyEntry = true; // Mark as early entry for potential special handling try { - const game = await this.createGameForUser(currentUser, 'standard', { earlyEntry: true }); + const game = await this.createGameForUser(currentUser, 'standard', { + earlyEntry: true, + fairnessProof: options.fairnessProof || null + }); const gameState = game.getState(); gameState.blockHeight = blockHeight; gameState.isEarlyEntry = true; @@ -256,7 +263,7 @@ class QueueManager { if (!startRes.success) { // Abort game + clean up orphaned DB record if (this.activeGames) this.activeGames.delete(serverId); - this._cleanupOrphanedGame(game); + await this._cleanupOrphanedGame(game); return { success: false, reason: startRes.reason || 'Payment processing failed' }; } // Emit credits_update if credits were spent @@ -284,10 +291,12 @@ class QueueManager { * Clean up a game record from the DB when processGameStart fails. * Prevents orphaned "active" records that never complete. */ - _cleanupOrphanedGame(game) { + async _cleanupOrphanedGame(game) { if (game && game.dbId && this.gameModeManager && this.gameModeManager.db) { - this.gameModeManager.db.query( - `UPDATE games SET status = 'expired', outcome = 'aborted', completed_at = NOW() WHERE id = $1`, + await this.gameModeManager.db.query( + `UPDATE games + SET status = 'expired', outcome = 'aborted', completed_at = NOW() + WHERE id = $1 AND entry_consumed_at IS NULL AND status = 'active'`, [game.dbId] ).catch(err => console.error('[QueueManager] Failed to clean up orphaned game:', err.message)); } @@ -323,7 +332,7 @@ class QueueManager { userId: entry.userId || null, requiresConfirmation: !!entry.requiresConfirmation, confirmed: !!entry.confirmed, - isValuable: this._isValuableEntry(entry) + isValuable: !!(entry.paymentId || entry.userId) })); } } diff --git a/src/network/sessionManager.js b/src/network/sessionManager.js index cca2fa3..76b94a8 100644 --- a/src/network/sessionManager.js +++ b/src/network/sessionManager.js @@ -4,12 +4,14 @@ */ const crypto = require('crypto'); const { normalizeError } = require('../utils/errors'); +const { createFinancialRecoveryError } = require('../utils/financialRecoveryError'); class SessionManager { - constructor({ db, debugManager, gameModeManager }) { + constructor({ db, debugManager, gameModeManager, io = null }) { this.db = db; this.debugManager = debugManager; this.gameModeManager = gameModeManager; + this.io = io; this.sessions = new Map(); // Memory cache this.cleanupInterval = null; } @@ -31,41 +33,32 @@ class SessionManager { ); if (result.rows.length > 0) { - const user = result.rows[0]; - - // Update last seen and socket_id - await this.db.query( - 'UPDATE users SET socket_id = $1, last_seen = NOW() WHERE id = $2', - [socketId, user.id] // Parameterized - ); - - // Update cache - this.sessions.set(socketId, user); + const selectedUser = result.rows[0]; // Check for unprocessed payments (payment recovery) - const recovered = await this.recoverPendingPayments(user.id, socketId); - if (recovered.creditsRecovered > 0) { - // Refresh user data after recovery - const refreshed = await this.db.query('SELECT * FROM users WHERE id = $1', [user.id]); - if (refreshed.rows.length > 0) { - this.sessions.set(socketId, refreshed.rows[0]); - user.credits = refreshed.rows[0].credits; - } - } + const recovered = await this.recoverPendingPayments(selectedUser.id, socketId); - // SECURITY: Rotate the token on every resume. The session token is a bearer - // credential — rotating it limits the window in which a leaked/observed token is - // usable (a leaked token is invalidated the next time the real owner reconnects). - // The client persists the returned token from the `session_resumed` event. - let issuedToken = resumeToken; - try { - issuedToken = await this.rotateToken(user.id); - user.anon_token = issuedToken; - this.sessions.set(socketId, user); - } catch (rotErr) { - // If rotation fails, keep the existing token rather than locking the user out. - console.error('[SessionManager] Token rotation failed, keeping existing token:', rotErr.message); + // Compare-and-swap the bearer credential and socket ownership in one UPDATE. Two + // concurrent resumes may both read the old token, but exactly one can consume it; + // the losing connection is rejected instead of receiving an already-stale token. + const issuedToken = this.generateSecureToken(); + const rotated = await this.db.query(` + UPDATE users + SET socket_id = $1, last_seen = NOW(), anon_token = $2 + WHERE id = $3 AND anon_token = $4 + RETURNING * + `, [socketId, issuedToken, selectedUser.id, resumeToken]); + if (rotated.rows.length !== 1) { + const replayError = new Error('Session token was already consumed by another resume'); + replayError.code = 'SESSION_TOKEN_REPLAY'; + throw replayError; } + const user = rotated.rows[0]; + + // A bearer rotation revokes every older live socket for this stable user. Evict and + // disconnect them before caching the newly authenticated socket. + this.disconnectUserSessions([user.id], [], { exceptSocketId: socketId }); + this.sessions.set(socketId, user); if (this.debugManager.CONSOLE_LOGGING) console.log(`[SessionManager] Resumed session for user ${user.id}`); return { @@ -99,6 +92,7 @@ class SessionManager { user: newUser }; } catch (error) { + if (error?.code === 'SESSION_TOKEN_REPLAY') throw error; const normalized = normalizeError(error, 'Failed to resume or create session'); console.error('[SessionManager] Error in resumeOrCreate:', normalized.message); throw normalized; @@ -317,6 +311,9 @@ class SessionManager { AND p.payment_type = 'single_game' AND p.confirmed_at > NOW() - INTERVAL '7 days' AND NOT EXISTS (SELECT 1 FROM games g WHERE g.payment_id = p.id) + AND NOT EXISTS ( + SELECT 1 FROM payment_entitlement_grants peg WHERE peg.payment_id = p.id + ) AND NOT EXISTS ( SELECT 1 FROM credit_transactions ct WHERE ct.user_id = p.user_id AND ct.reason = 'single_game_recovered:' || p.id @@ -329,18 +326,55 @@ class SessionManager { const recovered = await this.db.withTransaction(async (client) => { // Lock the payment and re-check under the lock that it is still unconsumed // and not already recovered (prevents double-credit across instances/races). - const lock = await client.query(`SELECT id FROM payments WHERE id = $1 FOR UPDATE`, [payment.id]); + const lock = await client.query(` + SELECT id, user_id, status, payment_type + FROM payments + WHERE id = $1 + FOR UPDATE + `, [payment.id]); if (!lock.rows[0]) return null; + if (String(lock.rows[0].user_id) !== String(userId) + || lock.rows[0].status !== 'confirmed' + || lock.rows[0].payment_type !== 'single_game') return null; const gameCheck = await client.query(`SELECT 1 FROM games WHERE payment_id = $1 LIMIT 1`, [payment.id]); if (gameCheck.rows.length > 0) return null; // a game was started for it after all + const existingGrant = await client.query( + `SELECT payment_id FROM payment_entitlement_grants WHERE payment_id = $1 FOR UPDATE`, + [payment.id] + ); + if (existingGrant.rows.length > 0) return null; + const reason = `single_game_recovered:${payment.id}`; const dup = await client.query( `SELECT 1 FROM credit_transactions WHERE user_id = $1 AND reason = $2 LIMIT 1`, [userId, reason] ); - if (dup.rows.length > 0) return null; // already recovered + if (dup.rows.length > 0) { + // Compatibility with rows written before migration 035. Make the legacy + // ledger reason authoritative by materializing the canonical marker, but do + // not grant anything again. + await client.query(` + INSERT INTO payment_entitlement_grants ( + payment_id, user_id, source, credits_granted, metadata + ) VALUES ($1, $2, 'single_game_recovery', $3, $4::jsonb) + ON CONFLICT (payment_id) DO NOTHING + `, [payment.id, userId, creditsPerGame, JSON.stringify({ legacyReason: reason })]); + return null; + } + + // This unique payment-scoped marker is the grant claim. It is inserted before + // the balance mutation in the same transaction, so concurrent recovery and a + // later direct-game start cannot both consume the invoice. + const grant = await client.query(` + INSERT INTO payment_entitlement_grants ( + payment_id, user_id, source, credits_granted, metadata + ) VALUES ($1, $2, 'single_game_recovery', $3, $4::jsonb) + ON CONFLICT (payment_id) DO NOTHING + RETURNING payment_id + `, [payment.id, userId, creditsPerGame, JSON.stringify({ reason })]); + if (grant.rowCount !== 1) return null; const upd = await client.query( `UPDATE users SET credits = credits + $1 WHERE id = $2 RETURNING credits`, @@ -348,9 +382,10 @@ class SessionManager { ); const newBalance = upd.rows[0]?.credits ?? 0; await client.query(` - INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) - VALUES ($1, $2, $3, $4, 'recovery') - `, [userId, creditsPerGame, reason, newBalance]); + INSERT INTO credit_transactions ( + user_id, amount, reason, balance_after, transaction_type, payment_id + ) VALUES ($1, $2, $3, $4, 'recovery', $5) + `, [userId, creditsPerGame, reason, newBalance, payment.id]); return { creditsToAdd: creditsPerGame }; }); @@ -406,30 +441,47 @@ class SessionManager { /** * C6/G6: Recover games left stuck at status='active' when the server was killed * mid-run. On a fresh boot no game can legitimately still be running, so every such row - * is an orphan: idempotently refund the player what they paid (the linked confirmed - * single_game payment, or the credits spent for a PAID_CREDITS game) and then finalize - * the game's status. Safe to run repeatedly — each game is locked FOR UPDATE and skipped - * unless still 'active', and each refund is deduped by a per-game credit_transactions - * reason key, so re-runs neither double-refund nor double-finalize. index.js calls this - * at startup right after recoverPendingPayments. + * is an orphan: idempotently restore an exact, durably recorded PAID_CREDITS debit and + * then finalize the game's status. A mode label, payment link, or current entry price is + * never treated as debit evidence; direct-payment refunds require the explicit refund + * workflow. Safe to run repeatedly — each game is locked FOR UPDATE and skipped unless + * still 'active', and each credit restoration has a per-game ledger key. index.js calls + * this at startup right after recoverPendingPayments. */ async recoverOrphanedGames() { - const summary = { finalized: 0, refunded: 0, creditsRefunded: 0 }; + const summary = { + ok: true, + scanned: 0, + finalized: 0, + refunded: 0, + creditsRefunded: 0, + unresolved: [] + }; + let orphans; try { - const creditsPerGame = (this.gameModeManager && this.gameModeManager.creditsPerGameCost) || 1; - - const orphans = await this.db.query( + orphans = await this.db.query( `SELECT id FROM games WHERE status = 'active' ORDER BY id ASC`, [] ); + } catch (error) { + summary.ok = false; + throw createFinancialRecoveryError('orphaned_solo_games', { + ...summary, + scanFailed: true, + resolved: 0 + }, error); + } - for (const orphan of orphans.rows) { + const rows = orphans.rows || []; + summary.scanned = rows.length; + for (const orphan of rows) { try { const outcome = await this.db.withTransaction(async (client) => { // Lock the game row and re-check under the lock: another instance (or a prior // run) may already have finalized it. const lock = await client.query( - `SELECT id, user_id, game_mode, payment_id, status + `SELECT id, user_id, game_mode, payment_id, status, + entry_consumed_at, entry_credits_spent FROM games WHERE id = $1 FOR UPDATE`, [orphan.id] ); @@ -438,6 +490,7 @@ class SessionManager { const reason = `orphan_game_refunded:${game.id}`; let refunded = false; + let creditsRefunded = 0; if (game.user_id) { // Dedup: never refund the same orphaned game twice. @@ -447,31 +500,29 @@ class SessionManager { ); if (dup.rows.length === 0) { - // A refund is warranted when the player actually paid: a PAID_CREDITS game - // consumed credits, or a PAID_SINGLE game had a confirmed payment. - let doRefund = false; - if (game.game_mode === 'PAID_CREDITS') { - doRefund = true; - } else if (game.payment_id != null) { - const pay = await client.query( - `SELECT status FROM payments WHERE id = $1`, - [game.payment_id] - ); - doRefund = !!pay.rows[0] && pay.rows[0].status === 'confirmed'; - } + // Refund only durable debit evidence. Merely inserting an active row with a + // PAID_CREDITS label does not prove a credit was taken (start can fail after + // game persistence but before its transaction commits). Never use the current + // configured cost: it may have changed since this entry was accepted. + const spent = Number(game.entry_credits_spent || 0); + const doRefund = game.game_mode === 'PAID_CREDITS' + && game.entry_consumed_at != null + && Number.isSafeInteger(spent) + && spent > 0; if (doRefund) { const upd = await client.query( `UPDATE users SET credits = credits + $1 WHERE id = $2 RETURNING credits`, - [creditsPerGame, game.user_id] + [spent, game.user_id] ); const newBalance = upd.rows[0]?.credits ?? 0; await client.query( `INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) VALUES ($1, $2, $3, $4, 'refund')`, - [game.user_id, creditsPerGame, reason, newBalance] + [game.user_id, spent, reason, newBalance] ); refunded = true; + creditsRefunded = spent; } } } @@ -483,27 +534,32 @@ class SessionManager { [newStatus, game.id] ); - return { refunded }; + return { refunded, creditsRefunded }; }); if (outcome) { summary.finalized++; if (outcome.refunded) { summary.refunded++; - summary.creditsRefunded += creditsPerGame; + summary.creditsRefunded += outcome.creditsRefunded; } } } catch (e) { console.error(`[OrphanRecovery] Failed to recover game ${orphan.id}:`, e.message); + summary.unresolved.push({ type: 'game', id: orphan.id }); } - } + } - if (summary.finalized > 0) { - console.log(`[OrphanRecovery] Finalized ${summary.finalized} orphaned game(s); refunded ${summary.refunded} (${summary.creditsRefunded} credits).`); - } - } catch (error) { - const normalized = normalizeError(error, 'Failed to recover orphaned games'); - console.error('[OrphanRecovery] Error recovering orphaned games:', normalized.message); + if (summary.unresolved.length > 0) { + summary.ok = false; + throw createFinancialRecoveryError('orphaned_solo_games', { + ...summary, + resolved: summary.finalized + }); + } + + if (summary.finalized > 0) { + console.log(`[OrphanRecovery] Finalized ${summary.finalized} orphaned game(s); refunded ${summary.refunded} (${summary.creditsRefunded} credits).`); } return summary; } @@ -512,14 +568,54 @@ class SessionManager { return crypto.randomBytes(32).toString('base64url'); } - async rotateToken(userId) { + /** + * Evict and disconnect cached sockets belonging to one or more stable users. Extra socket ids + * cover pre-cache/legacy rows. Used by token rotation and wallet-account adoption so an old + * live socket cannot keep exercising paid actions from a stale in-memory session. + */ + disconnectUserSessions(userIds, extraSocketIds = [], { exceptSocketId = null } = {}) { + const targetUsers = new Set((Array.isArray(userIds) ? userIds : [userIds]) + .filter(id => id != null) + .map(String)); + const socketIds = new Set((Array.isArray(extraSocketIds) ? extraSocketIds : [extraSocketIds]) + .filter(Boolean)); + + for (const [cachedSocketId, cachedUser] of this.sessions) { + if (targetUsers.has(String(cachedUser?.id))) socketIds.add(cachedSocketId); + } + + for (const staleSocketId of socketIds) { + if (!staleSocketId || staleSocketId === exceptSocketId) continue; + this.sessions.delete(staleSocketId); + try { + const socket = this.io?.sockets?.sockets?.get?.(staleSocketId); + if (socket) socket.disconnect(true); + } catch (_) { + // DB token revocation is authoritative; socket teardown is best-effort defense in depth. + } + } + return Array.from(socketIds).filter(id => id !== exceptSocketId); + } + + async rotateToken(userId, presentedToken) { + if (!presentedToken) { + const missingToken = new Error('A presented token is required for rotation'); + missingToken.code = 'SESSION_TOKEN_REPLAY'; + throw missingToken; + } const newToken = this.generateSecureToken(); - - // SECURE: Parameterized query - await this.db.query( - 'UPDATE users SET anon_token = $1 WHERE id = $2', - [newToken, userId] + + const rotated = await this.db.query( + `UPDATE users SET anon_token = $1 + WHERE id = $2 AND anon_token = $3 + RETURNING id`, + [newToken, userId, presentedToken] ); + if (rotated.rows.length !== 1) { + const replayError = new Error('Session token was already consumed by another rotation'); + replayError.code = 'SESSION_TOKEN_REPLAY'; + throw replayError; + } return newToken; } diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index b835be3..97300cb 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -24,7 +24,32 @@ const TavernManager = require('./tavernManager'); const IdentityService = require('./identityService'); const SuspendedGameManager = require('./suspendedGameManager'); const MemoryManager = require('../utils/memoryManager'); +const FairnessOfferManager = require('../game/fairnessOfferManager'); const { normalizeError } = require('../utils/errors'); +const money = require('../money/atomic'); +const { + buildCommerceDisclosure, + validatePaidAcknowledgement +} = require('../config/commerceDisclosurePolicy'); + +function requirePaidActionAcknowledgement(context, socket, raw, errorEvent = null) { + const disclosure = buildCommerceDisclosure(context.gameModeManager, process.env); + // Every call site represents a value-bearing action. This action-level requirement must not + // disappear when invoice intake is paused: already-owned credits and race tickets still have + // value and may still be consumed. + const result = validatePaidAcknowledgement(raw, disclosure, { required: true }); + if (result.ok) return true; + const payload = { + message: result.message, + error: result.message, + code: result.code, + policyVersion: disclosure.policyVersion + }; + socket.emit('commerce_ack_required', payload); + if (errorEvent) socket.emit(errorEvent, payload); + context.broadcastManager?.sendStatusUpdate?.(socket.id, 'warning', result.message); + return false; +} class SocketHandlers { constructor(io, activeGames, broadcastManager, debugManager, gameModeManager = null, walletService = null) { @@ -35,6 +60,8 @@ class SocketHandlers { this.gameModeManager = gameModeManager; this.walletService = walletService; this.MOVE_COOLDOWN = 100; // Minimum 100ms between moves + this.fairnessOffers = new FairnessOfferManager(); + this._isShuttingDown = false; // Initialize rate limiter with debug mode matching debugManager this.rateLimiter = new RateLimiter({ @@ -72,7 +99,8 @@ class SocketHandlers { this.sessionManager = new SessionManager({ db: this.gameModeManager.db, debugManager: this.debugManager, - gameModeManager: this.gameModeManager + gameModeManager: this.gameModeManager, + io: this.io }); // IDENTITY (Phase 2.1): give GameModeManager the session manager so getOrCreateUser // resolves through the stable anon_token identity instead of the mutable socket_id. @@ -224,7 +252,8 @@ class SocketHandlers { this.matchQueue = new MatchQueue({ db: this.gameModeManager?.db || null, gameModeManager: this.gameModeManager, - debugManager: this.debugManager + debugManager: this.debugManager, + isFinancialRecoveryReady: () => this.matchManager?.financialRecoveryReady === true }); this.matchManager = new MatchManager({ io: this.io, @@ -238,16 +267,10 @@ class SocketHandlers { matchManager: this.matchManager, debugManager: this.debugManager }); - this.matchQueue.initialize().then(() => { - this.matchManager.initialize?.(); - this.tavernMatchBridge = new TavernMatchBridge({ - matchManager: this.matchManager, - tavernManager: this.tavernManager, - io: this.io, - debugManager: this.debugManager - }); - this.tavernMatchBridge.initialize(); - }).catch(err => console.error('[SocketHandlers] Match queue init failed:', err)); + // Database connection + migrations happen later in startServer(). Initializing the + // durable queue here raced that lifecycle and silently left production match mode dead. + // initializeMatchMode() is awaited after DatabaseManager.initialize() instead. + this._matchInitializePromise = null; @@ -257,6 +280,7 @@ class SocketHandlers { activeGames: this.activeGames, cleanupTimeoutMs: 300000 // 5 minutes to reconnect before game is lost }); + this.gameManager.setSuspendedGameManager(this.suspendedGameManager); // Movement manager abstraction (owns its own move-cooldown state) this.movementManager = new MovementManager({ @@ -272,6 +296,40 @@ class SocketHandlers { this._registerMemoryCleanups(); } + /** Initialize durable match state only after the database is connected and migrated. */ + async initializeMatchMode() { + if (this._matchInitializePromise) return this._matchInitializePromise; + this._matchInitializePromise = (async () => { + await this.matchQueue.initialize(); + await this.matchManager.initialize?.(); + this.tavernMatchBridge = new TavernMatchBridge({ + matchManager: this.matchManager, + tavernManager: this.tavernManager, + io: this.io, + debugManager: this.debugManager + }); + this.tavernMatchBridge.initialize(); + })(); + try { + await this._matchInitializePromise; + } catch (error) { + this._matchInitializePromise = null; + throw error; + } + } + + /** Stop new solo movement before taking the graceful-shutdown settlement snapshot. */ + beginShutdown() { + this._isShuttingDown = true; + this.movementManager?.shutdown?.(); + } + + /** Dynamic financial-recovery gate used by readiness and paid admission. */ + isFinancialRecoveryReady() { + return this.matchQueue?.initialized === true + && this.matchManager?.financialRecoveryReady === true; + } + /** * Register memory cleanup functions to prevent memory leaks */ @@ -339,6 +397,37 @@ class SocketHandlers { } } + _emitFairnessOffer(socket) { + if (!socket || !this.fairnessOffers) return null; + const offer = this.fairnessOffers.ensureOffer(socket.id); + socket.emit('fairness_offer', offer); + return offer; + } + + /** + * Atomically consume the precommit echoed by a start request. The replacement offer is + * published immediately for the next attempt, but the returned private proof input remains + * bound to the consumed commitment and is the only value passed into Game. + */ + _consumeFairnessAttempt(socket, opts = {}) { + const result = this.fairnessOffers.consume(socket.id, { + offerId: typeof opts.fairnessOfferId === 'string' ? opts.fairnessOfferId : null, + clientSeed: typeof opts.clientSeed === 'string' ? opts.clientSeed : '' + }); + if (!result.success) { + socket.emit('fairness_error', { code: result.code, message: result.reason }); + this._emitFairnessOffer(socket); + return null; + } + this._emitFairnessOffer(socket); + return result.proofInput; + } + + /** Refuse value-bearing actions unless the player echoed the current public disclosures. */ + _requirePaidActionAcknowledgement(socket, raw, errorEvent = null) { + return requirePaidActionAcknowledgement(this, socket, raw, errorEvent); + } + /** * Initialize socket event handlers for a new connection */ @@ -346,6 +435,10 @@ class SocketHandlers { const connectionResult = await this.connectionHandler.handleConnection(socket); if (!connectionResult) return; // Connection was rejected or failed + // Publish the server commitment before the client can submit a client seed. register_client + // re-emits this same offer (never swaps it) in case the browser attached listeners late. + this._emitFairnessOffer(socket); + const { sessionInfo } = connectionResult; const dbUserId = sessionInfo?.user?.id; @@ -375,23 +468,35 @@ class SocketHandlers { console.log(`🔄 [handleConnection] Restored game ${restoredGame.id} for user ${dbUserId}`); } - // Re-emit the game state to the reconnecting client - const gameState = restoredGame.getState(); - gameState.restored = true; - gameState.restoredMessage = 'Game restored! Continue playing.'; - - // Include provably fair commitment - if (restoredGame.getProofCommitment) { - gameState.proof = restoredGame.getProofCommitment(); + if (restoredGame.settlementPending) { + // A terminal run is retained solely to retry its atomic DB completion. Do + // not render it as playable after reconnect or reveal a second result. + socket.emit('game_settlement_pending', { + gameId: restoredGame.id, + code: 'GAME_FINISH_NOT_DURABLE', + retrying: true + }); + this.broadcastManager.sendStatusUpdate(socket.id, 'warning', + 'Your finished game is being saved. A new game will be available when settlement completes.'); + } else { + // Re-emit the game state to the reconnecting client + const gameState = restoredGame.getState(); + gameState.restored = true; + gameState.restoredMessage = 'Game restored! Continue playing.'; + + // Include provably fair commitment + if (restoredGame.getProofCommitment) { + gameState.proof = restoredGame.getProofCommitment(); + } + + // Send game_start to resume the game on client + socket.emit('game_start', gameState); + + // Also send a status message + this.broadcastManager.sendStatusUpdate(socket.id, 'info', + 'Welcome back! Your game has been restored.'); } - // Send game_start to resume the game on client - socket.emit('game_start', gameState); - - // Also send a status message - this.broadcastManager.sendStatusUpdate(socket.id, 'info', - 'Welcome back! Your game has been restored.'); - // Restore payment monitoring if it was active if (suspendedState.paymentMonitoringActive && this.paymentHandlers) { this._restorePaymentMonitoring(socket, dbUserId); @@ -446,7 +551,9 @@ class SocketHandlers { // Register event handlers socket.on('chat message', (msg) => this.chatHandler.handleChatMessage(socket, msg, { - handleGameQueue: (socket) => this.queueHandler.handleGameQueue(socket, (socketId) => this.connectionHandler.getUserBySocket(socketId)), + // Legacy text command has no client contribution, but still consumes the already + // published one-time server offer with an empty clientSeed. + handleGameQueue: (socket) => this.handleJoinQueue(socket, {}), handleCancelEntry: (socket) => this.queueHandler.handleCancelEntry(socket), handleStatsRequest: (socket) => this.handleStatsRequest(socket) })); @@ -457,6 +564,7 @@ class SocketHandlers { socket.on('debug_ping', (data) => this.handleDebugPing(socket, data)); socket.on('register_client', async (data) => { this.connectionHandler.handleRegisterClient(socket, data); + this._emitFairnessOffer(socket); // Re-send game mode info after client registers handlers (fixes race condition) if (this.gameModeManager) { socket.emit('game_mode_info', this.gameModeManager.getGameModeInfo()); @@ -481,11 +589,12 @@ class SocketHandlers { socket.on('auto_start', (data) => this.handleAutoStart(socket, (data && typeof data === 'object') ? data : {})); // New handler for start button socket.on('play_free', (data) => this.handleAutoStart(socket, { ...((data && typeof data === 'object') ? data : {}), free: true })); // Explicit free-play choice socket.on('join_queue', (data) => this.handleJoinQueue(socket, (data && typeof data === 'object') ? data : {})); // Queue instead of auto-start ({ free: true } for Pleb-board free play) - socket.on('early_entry', () => this.handleEarlyEntry(socket)); // Early entry without waiting for block + socket.on('early_entry', (data) => this.handleEarlyEntry(socket, (data && typeof data === 'object') ? data : {})); // Early entry without waiting for block + socket.on('fairness_offer_request', () => this._emitFairnessOffer(socket)); socket.on('address:prompt', () => this.handleAddressPrompt(socket)); // Payment system handlers - socket.on('request_payment', (data) => this.paymentHandlers.handlePaymentRequest(socket, data)); + socket.on('request_payment', (data) => this.handlePaymentRequest(socket, data)); socket.on('check_payment_status', (data) => this.handleCheckPaymentStatus(socket, data)); socket.on('get_user_credits', () => this.handleGetUserCredits(socket)); socket.on('address:update', (data) => this.handleAddressUpdate(socket, data)); @@ -538,6 +647,48 @@ class SocketHandlers { } + async handlePaymentRequest(socket, data = {}) { + const request = (data && typeof data === 'object') ? data : {}; + const rlId = stableId(socket, this.sessionManager); + const rlIp = clientIp(socket); + const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'payment:create', rlIp); + if (!rateLimitResult.allowed) { + socket.emit('payment_error', { + error: 'Too many payment requests. Please wait and try again.', + code: 'RATE_LIMITED', + retryAfterMs: rateLimitResult.retryAfter + }); + return; + } + // Count every accepted attempt before any wallet/DB work so invalid product ids or + // fairness payloads cannot be used to bypass the IP + stable-user bucket. + await this.rateLimiter.recordAttempt(rlId, 'payment:create', rlIp); + + // Check before consuming a one-time fairness offer or touching wallet/DB state. + if (!requirePaidActionAcknowledgement( + this, + socket, + request.legalAcknowledgement, + 'payment_error' + )) return; + + const paymentType = request.type || request.gameMode || 'single_game'; + if (typeof this.gameModeManager?.isPaymentIntakeEnabled === 'function' + && !this.gameModeManager.isPaymentIntakeEnabled(paymentType)) { + socket.emit('payment_error', { + error: 'That paid product is not available on this server.', + code: 'PAYMENT_INTAKE_DISABLED' + }); + return; + } + if (paymentType !== 'single_game') { + return this.paymentHandlers.handlePaymentRequest(socket, request); + } + const fairnessProof = this._consumeFairnessAttempt(socket, request); + if (!fairnessProof) return; + return this.paymentHandlers.handlePaymentRequest(socket, { ...request, fairnessProof }); + } + /** * Handle immediate start button (auto_start) * Applies payment eligibility + payout address gating (for payout-eligible modes) @@ -545,16 +696,16 @@ class SocketHandlers { */ async handleAutoStart(socket, opts = {}) { try { + const wantsExplicitFree = opts.free === true && this.gameModeManager?.freePlayEnabled; + if (!wantsExplicitFree + && !requirePaidActionAcknowledgement(this, socket, opts.legalAcknowledgement)) return; + const fairnessProof = this._consumeFairnessAttempt(socket, opts); + if (!fairnessProof) return; + let gameFairnessProof = fairnessProof; + // The player explicitly chose FREE play (Pleb board, no payment/payout). Only // honoured when the instance allows free play; otherwise fall through to paid. - const wantsFree = opts.free === true && this.gameModeManager?.freePlayEnabled; - - // C7: optional client-supplied entropy for provably-fair seed derivation. Must be - // hex (≤64 chars); anything malformed is silently ignored (falls back to ''). This - // is purely additive — existing clients that send no seed are unaffected. - const clientSeed = (typeof opts.clientSeed === 'string' && /^[a-fA-F0-9]{0,64}$/.test(opts.clientSeed)) - ? opts.clientSeed - : undefined; + const wantsFree = wantsExplicitFree; // Rate limiting for game starts — keyed on stable identity + IP so reconnecting // (new socket.id) can't reset the limit. @@ -582,9 +733,47 @@ class SocketHandlers { let canStart = { allowed: true, reason: 'Free mode' }; // Free play skips all payment/credits/payout-address gating below. if (this.gameModeManager && !wantsFree) { - // Payout address gating for modes that can payout - const payoutEligible = (this.gameModeManager.gameMode === 'PAID_SINGLE') || (this.gameModeManager.gameMode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled); - if (payoutEligible) { + try { + canStart = await this.gameModeManager.canUserStartGame(socket.id); + } catch (e) { + console.error('Eligibility check failed:', e.message); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Eligibility check failed.'); + return; + } + + if (!canStart.allowed) { + // Trigger payment request automatically for any payment-related action + if (canStart.action === 'make_payment' || canStart.action === 'purchase_credits' || canStart.action === 'choose_payment') { + await this.paymentHandlers.createAndShowPaymentRequest(socket, { + fairnessProof, + legalAcknowledgement: opts.legalAcknowledgement + }); + return; + } + this.broadcastManager.sendStatusUpdate(socket.id, 'error', canStart.reason || 'Not allowed to start'); + return; + } + + // Address-gate the effective entry mode, not the instance's legacy/global mode. + // This matters on mixed direct+credits servers and when the master payout switch + // is off (no payout means no address should be required). + const effectiveMode = canStart.effectiveMode || this.gameModeManager.gameMode; + if (effectiveMode === 'PAID_SINGLE' && canStart.paymentId) { + // A direct invoice is already bound to the offer that was published before + // its address was shown. Never replace it with the fresh socket offer chosen + // after payment/reconnect. + gameFairnessProof = canStart.fairnessProof || null; + if (this.gameModeManager._requiresPaidFairnessV2?.() && !gameFairnessProof) { + this.broadcastManager.sendStatusUpdate(socket.id, 'error', + 'This paid entry has no durable fairness binding and requires support review.'); + return; + } + } + const needsAddress = typeof this.gameModeManager.requiresPayoutAddressForMode === 'function' + ? this.gameModeManager.requiresPayoutAddressForMode(effectiveMode) + : ((effectiveMode === 'PAID_SINGLE') + || (effectiveMode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled)); + if (needsAddress) { try { // Always do fresh DB lookup for payout address (cache may be stale after address update) const dbUser = await this.gameModeManager.getOrCreateUser(socket.id); @@ -598,24 +787,6 @@ class SocketHandlers { return; } } - - try { - canStart = await this.gameModeManager.canUserStartGame(socket.id); - } catch (e) { - console.error('Eligibility check failed:', e.message); - this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Eligibility check failed.'); - return; - } - - if (!canStart.allowed) { - // Trigger payment request automatically for any payment-related action - if (canStart.action === 'make_payment' || canStart.action === 'purchase_credits' || canStart.action === 'choose_payment') { - await this.paymentHandlers.createAndShowPaymentRequest(socket); - return; - } - this.broadcastManager.sendStatusUpdate(socket.id, 'error', canStart.reason || 'Not allowed to start'); - return; - } } // Record the game start attempt (same stable id + IP as the check above) @@ -624,7 +795,9 @@ class SocketHandlers { // Create game immediately const blockHeight = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null; memUser.blockRec = blockHeight; // keep legacy timeout logic consistent - const game = await this.gameManager.createGameForUser(memUser, 'standard'); + const game = await this.gameManager.createGameForUser(memUser, 'standard', { + fairnessProof: gameFairnessProof + }); const state = game.getState(); state.blockHeight = blockHeight; @@ -635,10 +808,17 @@ class SocketHandlers { // Process start (free / credits deduction / payment link) if (this.gameModeManager) { - const startRes = await this.gameModeManager.processGameStart(socket.id, game.id, { forceFree: wantsFree, clientSeed }); + const startRes = await this.gameModeManager.processGameStart(socket.id, game.id, { forceFree: wantsFree }); if (!startRes.success) { // Abort game this.activeGames.delete(socket.id); + if (game.dbId && this.gameModeManager?.db) { + await this.gameModeManager.db.query(` + UPDATE games + SET status = 'expired', outcome = 'aborted', completed_at = NOW() + WHERE id = $1 AND entry_consumed_at IS NULL AND status = 'active' + `, [game.dbId]); + } this.broadcastManager.sendStatusUpdate(socket.id, 'error', startRes.reason || 'Failed to start game.'); return; } @@ -664,10 +844,29 @@ class SocketHandlers { */ async handleJoinQueue(socket, opts = {}) { try { + const rlId = stableId(socket, this.sessionManager); + const rlIp = clientIp(socket); + const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'game:queue', rlIp); + if (!rateLimitResult.allowed) { + socket.emit('queue_error', { + message: 'Too many queue attempts. Please wait and try again.', + code: 'RATE_LIMITED', + retryAfterMs: rateLimitResult.retryAfter + }); + return; + } + await this.rateLimiter.recordAttempt(rlId, 'game:queue', rlIp); + + const wantsExplicitFree = opts.free === true && this.gameModeManager?.freePlayEnabled; + if (!wantsExplicitFree + && !requirePaidActionAcknowledgement(this, socket, opts.legalAcknowledgement)) return; + + const fairnessProof = this._consumeFairnessAttempt(socket, opts); + if (!fairnessProof) return; await this.queueHandler.handleGameQueue( socket, this.connectionHandler.getUserBySocket.bind(this.connectionHandler), - opts + { ...opts, fairnessProof } ); // queueHandler already emits queue status and adds to queue // Client receives queue_joined event from broadcastManager @@ -690,11 +889,18 @@ class SocketHandlers { * Handle early entry request - start game immediately without waiting for block * Only available for free mode and credits mode (not direct payment mode) */ - async handleEarlyEntry(socket) { + async handleEarlyEntry(socket, opts = {}) { try { + // Early entry is free only on a free-only instance. Mixed/credits entry may consume a + // credit, so treat ambiguous mixed-mode requests as paid and fail closed. + if (this.gameModeManager?.gameMode !== 'FREE' + && !requirePaidActionAcknowledgement(this, socket, opts.legalAcknowledgement, 'early_entry_error')) return; + const fairnessProof = this._consumeFairnessAttempt(socket, opts); + if (!fairnessProof) return; const result = await this.queueHandler.handleEarlyEntry( - socket, - this.connectionHandler.getUserBySocket.bind(this.connectionHandler) + socket, + this.connectionHandler.getUserBySocket.bind(this.connectionHandler), + { fairnessProof } ); if (result.success) { @@ -806,6 +1012,34 @@ class SocketHandlers { return; } + const action = (data && data.action === 'leave') ? 'leave' : 'join'; + if (action === 'join' && this.activeGames?.has(socket.id)) { + const soloGame = this.activeGames.get(socket.id); + socket.emit('match_error', { + message: soloGame?.settlementPending + ? 'Your finished solo game is still being saved. Wait for settlement before joining a match.' + : 'Finish your solo game before joining a match.', + code: soloGame?.settlementPending ? 'SOLO_SETTLEMENT_PENDING' : 'SOLO_GAME_ACTIVE' + }); + return; + } + // A leave is never throttled: users must always be able to release queued escrow. + // Joins share the reconnect-proof solo/match queue bucket to bound DB churn. + if (action === 'join') { + const rlId = stableId(socket, this.sessionManager); + const rlIp = clientIp(socket); + const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'game:queue', rlIp); + if (!rateLimitResult.allowed) { + socket.emit('match_error', { + message: 'Too many queue attempts. Please wait and try again.', + code: 'RATE_LIMITED', + retryAfterMs: rateLimitResult.retryAfter + }); + return; + } + await this.rateLimiter.recordAttempt(rlId, 'game:queue', rlIp); + } + const session = await this._resolveMatchSession(socket); if (!session) { socket.emit('match_error', { message: 'Please reconnect before joining a match.' }); @@ -813,16 +1047,18 @@ class SocketHandlers { } const economy = (data && typeof data.economy === 'string') ? data.economy : undefined; - const action = (data && data.action === 'leave') ? 'leave' : 'join'; - // Carry economy on the session too, so a leave(session) that keys on economy and // an enqueue(session,{economy}) that keys on the explicit arg both stay correct. const matchSession = { ...session, economy }; if (action === 'leave') { - await this.matchQueue.leave(matchSession); + const result = await this.matchQueue.leave(matchSession); + socket.emit('match_queue_left', { ...(result || { success: false }), economy }); } else { - await this.matchQueue.enqueue(matchSession, { economy }); + if ((economy === 'credits_prestige' || economy === 'crypto_race') + && !requirePaidActionAcknowledgement(this, socket, data.legalAcknowledgement, 'match_error')) return; + const result = await this.matchQueue.enqueue(matchSession, { economy }); + socket.emit('match_queue_joined', { ...(result || { success: false }), economy }); } } catch (err) { if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] _handleMatchQueue error:', err.message); @@ -911,6 +1147,13 @@ class SocketHandlers { * Preserves game state for reconnection when user has a valid session */ handleDisconnect(socket) { + if (this.fairnessOffers) this.fairnessOffers.discardSocket(socket.id); + if (this.paymentHandlers?.clearPendingCommerceAcknowledgement) { + this.paymentHandlers.clearPendingCommerceAcknowledgement(socket.id); + } + if (this.paymentHandlers?.pendingEntryFairness) { + this.paymentHandlers.pendingEntryFairness.delete(socket.id); + } // Use connection handler for main disconnect logic this.connectionHandler.handleDisconnect(socket, async (socket) => { const socketId = socket.id; @@ -932,7 +1175,12 @@ class SocketHandlers { } } - if (activeGame && dbUserId && this.suspendedGameManager) { + // A retry can commit while the async identity lookup above is in flight. Re-check the + // retained object immediately before suspension so a completed terminal game cannot + // be resurrected as a reconnectable/"playable" game. + if (activeGame?.settlementCommitted) { + this.activeGames.delete(socketId); + } else if (activeGame && dbUserId && this.suspendedGameManager) { // Suspend the game so it can be restored on reconnect (keyed by dbUserId). const suspended = this.suspendedGameManager.suspendGame(dbUserId, socketId, activeGame, { paymentMonitoringActive: this.paymentHandlers?.hasActiveMonitoring?.(socketId) || false @@ -1081,7 +1329,10 @@ class SocketHandlers { try { // Query for pending (unpaid) payment requests for this user const pendingResult = await this.gameModeManager.db.query(` - SELECT id, subaddress, expected_amount, payment_type, description, expires_at, address_index + SELECT id, subaddress, expected_amount, payment_type, description, expires_at, address_index, + fairness_proof_version, fairness_offer_id, fairness_offer_issued_at, + fairness_commitment, fairness_server_seed, fairness_client_seed, + fairness_bound_at, fairness_consumed_at FROM payments WHERE user_id = $1 AND status = 'pending' @@ -1099,6 +1350,19 @@ class SocketHandlers { } const payment = pendingResult.rows[0]; + const fairnessProof = payment.payment_type === 'single_game' + && typeof this.gameModeManager._paymentFairnessProofFromRow === 'function' + ? this.gameModeManager._paymentFairnessProofFromRow(payment) + : null; + if (payment.payment_type === 'single_game' + && typeof this.gameModeManager._requiresPaidFairnessV2 === 'function' + && this.gameModeManager._requiresPaidFairnessV2() + && (!fairnessProof || payment.fairness_consumed_at != null)) { + console.error(`[RestorePayment] Refusing to monitor unbound paid entry ${payment.id}`); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', + 'This paid entry has no durable fairness binding and requires support review.'); + return; + } const cryptoType = this.gameModeManager.cryptoType; const formattedAmount = this.gameModeManager.formatAtomicHuman ? this.gameModeManager.formatAtomicHuman(payment.expected_amount, 3) @@ -1131,7 +1395,14 @@ class SocketHandlers { description: payment.description, expiresAt: payment.expires_at, qr: qrDataUrl, - restored: true + restored: true, + fairness: fairnessProof ? { + proofVersion: fairnessProof.proofVersion, + offerId: fairnessProof.offerId, + offerIssuedAt: fairnessProof.offerIssuedAt, + commitment: fairnessProof.commitment, + clientSeed: fairnessProof.clientSeed + } : null }); // Get current user object for queue operations @@ -1143,7 +1414,8 @@ class SocketHandlers { address: payment.subaddress, amount: payment.expected_amount, amountFormatted: formattedAmount, - package: null + package: null, + fairnessProof }; // Re-register address in wallet service so checkPaymentStatus() can query the wallet RPC. @@ -1152,7 +1424,7 @@ class SocketHandlers { this.walletService.addressToUser.set(payment.subaddress, { userId: String(dbUserId), socketId: socket.id, - amount: Number(payment.expected_amount), + amount: money.toSafe(money.toBig(payment.expected_amount)), addressIndex: payment.address_index ?? 0, accountIndex: 0, detected: false, @@ -1173,7 +1445,8 @@ class SocketHandlers { payment.expected_amount, cryptoType, currentUser, - payment.payment_type + payment.payment_type, + fairnessProof ); } @@ -1262,6 +1535,9 @@ class SocketHandlers { // was concurrently won or otherwise ended (removed from activeGames on another // async path), never re-end it here as a loss/timeout. if (!this.activeGames.has(socketId)) continue; + // Terminal games deliberately remain mapped until their DB transaction succeeds. + // Never race that frozen result with a later block-timeout loss. + if (game?.settlementPending || game?.settlementCommitted || game?.gameState === 'ended') continue; const user = this.connectionHandler.getUserBySocket(socketId); if (!user) continue; @@ -1405,22 +1681,32 @@ class SocketHandlers { * Shutdown method to clean up all resources and prevent memory leaks */ async shutdown() { + this.beginShutdown(); if (this.debugManager.CONSOLE_LOGGING) { console.log('🛑 SocketHandlers shutting down...'); } + // Settle terminal solo runs while the DB pool is still open. This is idempotent if the + // top-level graceful-shutdown path already drained them. + if (this.gameManager?.shutdown) { + const soloDrain = await this.gameManager.shutdown(); + if (soloDrain.pending > 0) { + console.error(`[SocketHandlers] ${soloDrain.pending} solo settlement(s) remain pending at shutdown.`); + } + } + // Shutdown components in reverse order of initialization if (this.tavernManager) { this.tavernManager.shutdown(); } - if (this.matchManager) { - this.matchManager.shutdown(); + if (this.matchScheduler) { + await this.matchScheduler.shutdown(); } if (this.matchQueue) { - this.matchQueue.shutdown().catch(() => {}); + await this.matchQueue.shutdown().catch(() => {}); } - if (this.matchScheduler) { - this.matchScheduler.shutdown(); + if (this.matchManager) { + this.matchManager.shutdown(); } if (this.spectatorManager) { diff --git a/src/network/socketOriginPolicy.js b/src/network/socketOriginPolicy.js new file mode 100644 index 0000000..7532a59 --- /dev/null +++ b/src/network/socketOriginPolicy.js @@ -0,0 +1,81 @@ +'use strict'; + +const HTTP_PROTOCOLS = new Set(['http:', 'https:']); + +function normalizedConfiguredOrigin(hostedBy) { + if (typeof hostedBy !== 'string' || hostedBy.trim() === '') return null; + + try { + const parsed = new URL(hostedBy.trim()); + if (!HTTP_PROTOCOLS.has(parsed.protocol) || parsed.username || parsed.password) return null; + return parsed.origin; + } catch (_) { + return null; + } +} + +function normalizedRequestOrigin(origin) { + if (typeof origin !== 'string' || origin.trim() === '') return null; + + try { + const raw = origin.trim(); + // URL normalizes dot-segments, so validate the Origin header's shape first. A browser + // Origin is only scheme://authority (an optional trailing slash is tolerated). + if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]+\/?$/.test(raw)) return null; + const parsed = new URL(raw); + if (!HTTP_PROTOCOLS.has(parsed.protocol) + || parsed.username + || parsed.password + || parsed.pathname !== '/' + || parsed.search + || parsed.hash) { + return null; + } + return parsed.origin; + } catch (_) { + return null; + } +} + +/** + * Build Socket.IO's allowRequest hook from an immutable environment snapshot. + * + * Development keeps the existing permissive behavior. Production requires a valid public + * HOSTED_BY origin before any handshake is accepted. Once configured, requests without an + * Origin header remain available to health probes and non-browser clients; browser handshakes + * must present the exact normalized HTTP(S) scheme + host (including any non-default port). + */ +function createSocketOriginAllowRequest(env = process.env) { + const production = String(env.NODE_ENV || '').trim().toLowerCase() === 'production'; + const expectedOrigin = production ? normalizedConfiguredOrigin(env.HOSTED_BY) : null; + + return (req, callback) => { + if (!production) { + callback(null, true); + return; + } + + // Missing or malformed production HOSTED_BY is configuration-invalid. Reject every + // handshake rather than silently degrading to wildcard browser access. + if (!expectedOrigin) { + callback(null, false); + return; + } + + const headers = req && req.headers; + const hasOrigin = !!headers && Object.prototype.hasOwnProperty.call(headers, 'origin'); + if (!hasOrigin) { + callback(null, true); + return; + } + + const requestOrigin = normalizedRequestOrigin(headers.origin); + callback(null, requestOrigin !== null && requestOrigin === expectedOrigin); + }; +} + +module.exports = { + createSocketOriginAllowRequest, + normalizedConfiguredOrigin, + normalizedRequestOrigin +}; diff --git a/src/network/suspendedGameManager.js b/src/network/suspendedGameManager.js index a0e02e6..66f34a0 100644 --- a/src/network/suspendedGameManager.js +++ b/src/network/suspendedGameManager.js @@ -119,6 +119,13 @@ class SuspendedGameManager { const game = suspended.game; + // A settlement retry may have committed after disconnect but before reconnect. Never + // resurrect that terminal object as an active game; the durable result is authoritative. + if (game?.settlementCommitted) { + this.suspendedGames.delete(dbUserId); + return null; + } + // Update game with new socket ID game.socketId = newSocketId; @@ -151,7 +158,10 @@ class SuspendedGameManager { // Update the user reference if provided if (newUser) { game.user = newUser; - newUser.joinGame(game); + // A terminal-pending object is restored only as an ownership lock while its DB + // transaction retries. Re-counting it as a newly joined game would corrupt in-memory + // stats and leave currentGame pointing at an ended run after settlement succeeds. + if (!game.settlementPending && !game.settlementCommitted) newUser.joinGame(game); } // Re-add to active games with new socket ID @@ -181,10 +191,10 @@ class SuspendedGameManager { * @param {number} dbUserId - Database user ID * @returns {boolean} True if game was removed */ - removeSuspendedGame(dbUserId) { + removeSuspendedGame(dbUserId, { countExpired = true } = {}) { this._cancelCleanup(dbUserId); const had = this.suspendedGames.delete(dbUserId); - if (had) { + if (had && countExpired) { this.stats.gamesExpired++; } return had; diff --git a/src/network/tavernMatchBridge.js b/src/network/tavernMatchBridge.js index b744f83..9acbb87 100644 --- a/src/network/tavernMatchBridge.js +++ b/src/network/tavernMatchBridge.js @@ -67,31 +67,48 @@ class TavernMatchBridge { } _publicSnapshot(room) { + // Use the same renderer contract as a player match tick, but copy only explicitly public + // fields. In particular, the committed seed hash is public while the unrevealed seed and + // internal event/input state never cross into the tavern feed. + const renderState = typeof room.toGameState === 'function' ? room.toGameState() : null; + const fallbackMap = room.dungeon?.map || []; + const players = renderState?.players || Array.from(room.occupants.values()).map(o => { + const s = room.playerStates.get(o.id); + return { + id: o.id, + x: o.x, + y: o.y, + name: o.name || null, + avatar: o.avatar, + appearance: o.appearance, + facing: o.facing, + alive: s ? s.alive : true, + finished: s ? s.finished : false, + escaped: s ? s.escaped : false, + hasTreasure: s ? s.hasTreasure : false, + placement: s ? s.placement : null + }; + }); return { matchId: room.id, economy: room.economy, + variant: room.variant || renderState?.variant || 'race', + rulesetId: room.ruleset?.id || room.variant || 'race', + ruleset: renderState?.ruleset || (room._rulesetSummary?.() || null), status: room.status, tick: room.tickCount, - players: Array.from(room.occupants.values()).map(o => { - const s = room.playerStates.get(o.id); - return { - id: o.id, - x: o.x, - y: o.y, - name: o.name || null, - avatar: o.avatar, - facing: o.facing, - alive: s ? s.alive : true, - finished: s ? s.finished : false, - escaped: s ? s.escaped : false, - hasTreasure: s ? s.hasTreasure : false, - placement: s ? s.placement : null - }; - }), - monster: room.monster ? { x: room.monster.x, y: room.monster.y } : null, - treasure: room.treasure, - exit: room.dungeon.exit, + visibleTiles: renderState?.visibleTiles || fallbackMap, + exploredTiles: renderState?.exploredTiles || fallbackMap, + lighting: renderState?.lighting || {}, + dungeonRows: renderState?.dungeonRows || room.rows || fallbackMap.length, + dungeonCols: renderState?.dungeonCols || room.cols || fallbackMap[0]?.length || 0, + players, + monster: renderState?.monster || (room.monster ? { x: room.monster.x, y: room.monster.y } : null), + treasure: renderState?.treasure || room.treasure, + entrance: renderState?.entrance || room.dungeon?.entrance || null, + exit: renderState?.exit || room.dungeon?.exit || null, winnerId: room.winnerId, + endReason: room.endReason || null, seedHash: room.seedHash }; } diff --git a/src/package-lock.json b/src/package-lock.json index bff2bd1..888b92f 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -8,6 +8,10 @@ "name": "wownerogue", "version": "1.0.0", "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.0.0" + }, "dependencies": { "axios": "^1.9.0", "dotenv": "^16.0.3", diff --git a/src/package.json b/src/package.json index 0ce623e..0a34cf1 100644 --- a/src/package.json +++ b/src/package.json @@ -5,13 +5,20 @@ "main": "index.js", "scripts": { "start": "NODE_ENV=production node index.js", + "preflight": "NODE_ENV=production node scripts/preflight.js", "dev": "NODE_ENV=development node index.js", "debug": "NODE_ENV=debug node index.js", "test": "jest --runInBand", + "capture:pvp": "node scripts/pvp-capture.js", + "canary:stagenet": "node scripts/stagenet-financial-canary.js", + "db:migrate:clone": "node scripts/migrate-disposable-clone.js", "db:create": "node scripts/setup_db.js", "db:drop": "node scripts/setup_db.js drop", - "db:reset": "npm run db:drop && npm run db:create", - "docs:api": "jsdoc2md --files game/**/*.js network/**/*.js payments/**/*.js multiplayer/*.js chain/*.js utils/*.js money/*.js services/*.js config/*.js > ../docs/API.md" + "db:reset": "npm run db:drop && npm run db:create" + }, + "engines": { + "node": ">=22.0.0", + "npm": ">=10.0.0" }, "config": { "debug": { @@ -45,7 +52,6 @@ }, "devDependencies": { "jest": "^29.7.0", - "jsdoc-to-markdown": "^9.1.1", "playwright-core": "^1.61.1" } } diff --git a/src/payments/digestAuth.js b/src/payments/digestAuth.js new file mode 100644 index 0000000..9b0ad6b --- /dev/null +++ b/src/payments/digestAuth.js @@ -0,0 +1,137 @@ +'use strict'; + +const crypto = require('crypto'); + +function md5(value) { + return crypto.createHash('md5').update(String(value)).digest('hex'); +} + +function parseDigestChallenge(header) { + const value = Array.isArray(header) ? header.find(item => /^Digest\s/i.test(item)) : header; + if (!value || !/^Digest\s/i.test(String(value))) return null; + const params = {}; + const body = String(value).replace(/^Digest\s+/i, ''); + const pattern = /([a-z0-9_-]+)\s*=\s*(?:"([^"]*)"|([^,\s]+))/gi; + let match; + while ((match = pattern.exec(body))) { + params[match[1].toLowerCase()] = match[2] !== undefined ? match[2] : match[3]; + } + if (!params.realm || !params.nonce) return null; + return params; +} + +function challengeFrom(error) { + if (Number(error?.response?.status) !== 401) return null; + const headers = error.response.headers || {}; + return parseDigestChallenge(headers['www-authenticate'] || headers['WWW-Authenticate']); +} + +function quote(value) { + return String(value).replace(/(["\\])/g, '\\$1'); +} + +/** Minimal RFC 7616/2617 MD5 Digest client for monero-wallet-rpc. */ +class DigestAuthClient { + constructor(http, { username, password, randomBytes = crypto.randomBytes } = {}) { + this.http = http; + this.username = String(username || ''); + this.password = String(password || ''); + this.randomBytes = randomBytes; + this.challenge = null; + this.nonceCount = 0; + } + + /** + * Prove that the server rejects an intentionally unauthenticated, read-only request with a + * usable Digest challenge. A configured credential is not an authentication boundary when + * wallet-rpc silently accepts the same request without it. + */ + async verifyEnforced(url, body, config = {}) { + try { + await this.http.post(url, body, config); + } catch (error) { + const challenge = challengeFrom(error); + if (!challenge) { + const unavailable = new Error('Wallet RPC did not provide a valid Digest challenge.'); + unavailable.code = 'WALLET_RPC_AUTH_UNVERIFIED'; + unavailable.cause = error; + throw unavailable; + } + this.challenge = challenge; + this.nonceCount = 0; + return true; + } + const notEnforced = new Error('Wallet RPC accepted an unauthenticated request.'); + notEnforced.code = 'WALLET_RPC_AUTH_NOT_ENFORCED'; + throw notEnforced; + } + + _authorization(url, method = 'POST') { + const challenge = this.challenge; + if (!challenge) return null; + const algorithm = String(challenge.algorithm || 'MD5').toUpperCase(); + if (!['MD5', 'MD5-SESS'].includes(algorithm)) { + throw new Error(`Unsupported wallet RPC Digest algorithm: ${algorithm}`); + } + const qops = String(challenge.qop || 'auth').split(',').map(value => value.trim().toLowerCase()); + if (!qops.includes('auth')) throw new Error('Wallet RPC Digest challenge does not support qop=auth'); + + const endpoint = new URL(url); + const uri = `${endpoint.pathname || '/'}${endpoint.search || ''}`; + const cnonce = this.randomBytes(16).toString('hex'); + const nc = (++this.nonceCount).toString(16).padStart(8, '0'); + const initialHa1 = md5(`${this.username}:${challenge.realm}:${this.password}`); + const ha1 = algorithm === 'MD5-SESS' + ? md5(`${initialHa1}:${challenge.nonce}:${cnonce}`) + : initialHa1; + const ha2 = md5(`${method.toUpperCase()}:${uri}`); + const response = md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:auth:${ha2}`); + const fields = [ + `username="${quote(this.username)}"`, + `realm="${quote(challenge.realm)}"`, + `nonce="${quote(challenge.nonce)}"`, + `uri="${quote(uri)}"`, + `algorithm=${algorithm}`, + `response="${response}"`, + 'qop=auth', + `nc=${nc}`, + `cnonce="${cnonce}"` + ]; + if (challenge.opaque) fields.push(`opaque="${quote(challenge.opaque)}"`); + return `Digest ${fields.join(', ')}`; + } + + async post(url, body, config = {}) { + const sendAuthenticated = () => { + const authorization = this._authorization(url, 'POST'); + return this.http.post(url, body, { + ...config, + headers: { ...(config.headers || {}), Authorization: authorization } + }); + }; + + if (this.challenge) { + try { + return await sendAuthenticated(); + } catch (error) { + const refreshed = challengeFrom(error); + if (!refreshed) throw error; + this.challenge = refreshed; + this.nonceCount = 0; + return sendAuthenticated(); + } + } + + try { + return await this.http.post(url, body, config); + } catch (error) { + const challenge = challengeFrom(error); + if (!challenge) throw error; + this.challenge = challenge; + this.nonceCount = 0; + return sendAuthenticated(); + } + } +} + +module.exports = { DigestAuthClient, parseDigestChallenge }; diff --git a/src/payments/payoutRetryService.js b/src/payments/payoutRetryService.js index e7a3db4..8b25feb 100644 --- a/src/payments/payoutRetryService.js +++ b/src/payments/payoutRetryService.js @@ -6,15 +6,36 @@ */ const { AppError } = require('../utils/errors'); +const TX_HASH_PATTERN = /^[0-9a-f]{64}$/i; + +function errorText(error, depth = 0) { + if (depth > 4 || error == null) return ''; + if (typeof error === 'string') return error; + const parts = [error.message, error.details && JSON.stringify(error.details)]; + if (error.cause && error.cause !== error) parts.push(errorText(error.cause, depth + 1)); + return parts.filter(Boolean).join(' '); +} + +// These wallet failures happen before transfer_split broadcasts and are therefore safe to retry. +function isKnownPreBroadcastFailure(error) { + return /not enough (unlocked )?(money|balance|outputs)|insufficient|no unlocked|unlocked balance/i + .test(errorText(error)); +} class PayoutRetryService { - constructor({ db, walletService, debugManager, maxRetries = 3, retryIntervalMs = 300000, alertService = null }) { + constructor({ db, walletService, debugManager, maxRetries = 3, retryIntervalMs = 300000, staleProcessingMs = null, alertService = null, isEnabled = null }) { this.db = db; this.walletService = walletService; this.debugManager = debugManager; this.maxRetries = maxRetries; this.retryIntervalMs = retryIntervalMs; // Default: 5 minutes + // A worker that dies after claiming a payout leaves an ambiguous `processing` row. + // Never infer that no transfer happened and resend it: quarantine it for reconciliation. + this.staleProcessingMs = staleProcessingMs == null + ? Math.max(retryIntervalMs * 3, 15 * 60 * 1000) + : Math.max(60000, Number(staleProcessingMs) || 0); this.alertService = alertService; // For sending failure notifications + this.isEnabled = typeof isEnabled === 'function' ? isEnabled : () => true; this.isProcessing = false; this.retryTimer = null; } @@ -23,6 +44,10 @@ class PayoutRetryService { * Start the retry service */ start() { + if (!this.isEnabled()) { + console.log('🛑 Payout retry service not started (master payouts disabled)'); + return; + } if (this.retryTimer) { console.log('⚠️ Payout retry service already running'); return; @@ -52,6 +77,7 @@ class PayoutRetryService { * Process all retryable payouts */ async processRetries() { + if (!this.isEnabled()) return; if (this.isProcessing) { if (this.debugManager?.CONSOLE_LOGGING) { console.log('⏳ Payout retry already in progress, skipping...'); @@ -62,24 +88,48 @@ class PayoutRetryService { this.isProcessing = true; try { - // Find payouts that need retry: - // 1. Status is 'pending' (stuck - RPC may have succeeded but DB update failed) - // 2. Status is 'failed' with retry_count < maxRetries - // Exclude very recent failures (wait at least 1 minute before retry) - // FOR UPDATE SKIP LOCKED prevents race with batch processor in gameModeManager + const recovered = await this.db.query(` + UPDATE payouts + SET status = 'needs_review', + last_error = CASE + WHEN last_error IS NULL OR last_error = '' + THEN 'Stale processing claim; broadcast outcome is unknown' + ELSE LEFT(last_error, 400) || ' | Stale processing claim; broadcast outcome is unknown' + END, + last_retry_at = NOW() + WHERE status = 'processing' + AND COALESCE(last_retry_at, created_at) < + NOW() - ($1::bigint * INTERVAL '1 millisecond') + RETURNING id + `, [this.staleProcessingMs]); + if ((recovered.rowCount || recovered.rows?.length || 0) > 0) { + console.error(`❌ Quarantined ${recovered.rowCount || recovered.rows.length} stale processing payout(s) for manual review`); + } + + // Atomically CLAIM legacy failed payouts before releasing row locks. Pending payouts + // belong exclusively to GameModeManager's batcher; selecting them here created a race + // where both workers could send the same row after this transaction committed. const result = await this.db.withTransaction(async (client) => { - return await client.query(` + const candidates = await client.query(` SELECT p.* FROM payouts p - WHERE ( - (p.status = 'pending' AND p.created_at < NOW() - INTERVAL '2 minutes') - OR (p.status = 'failed' AND p.retry_count < $1 AND (p.last_retry_at IS NULL OR p.last_retry_at < NOW() - INTERVAL '1 minute')) - ) - AND p.status NOT IN ('permanently_failed', 'processing') + WHERE p.status = 'failed' + AND p.retry_count < $1 + AND (p.last_retry_at IS NULL OR p.last_retry_at < NOW() - INTERVAL '1 minute') ORDER BY p.created_at ASC LIMIT 10 FOR UPDATE SKIP LOCKED `, [this.maxRetries]); + if (!candidates.rows.length) return candidates; + + const ids = candidates.rows.map(row => row.id); + const claimed = await client.query(` + UPDATE payouts + SET status = 'processing', last_retry_at = NOW() + WHERE id = ANY($1) AND status = 'failed' + RETURNING * + `, [ids]); + return claimed; }); if (result.rows.length === 0) { @@ -108,6 +158,16 @@ class PayoutRetryService { async retryPayout(payout) { const { id, user_id, game_id, payout_address, amount, multiplier, reason, retry_count, status } = payout; + if (!this.isEnabled()) { + // If the switch flipped after processRetries claimed the row, release it without + // changing retry_count. It remains a liability but cannot be dispatched. + await this.db.query(` + UPDATE payouts SET status = 'failed', last_error = 'Retry paused: payouts disabled' + WHERE id = $1 AND status = 'processing' + `, [id]).catch(() => {}); + return; + } + console.log(`🔄 Retrying payout ${id} (attempt ${(retry_count || 0) + 1}/${this.maxRetries})`); try { @@ -142,8 +202,18 @@ class PayoutRetryService { console.log(`✅ Payout ${id} already in blockchain (${txStatus.confirmations} confirmations), marked completed`); return; } - // tx_hash exists in DB but NOT in blockchain - safe to retry - console.log(`⚠️ Payout ${id} has tx_hash but not found in blockchain, will retry`); + // A single "not found" is not proof that a transaction was never broadcast: + // wallet/daemon lag, pruning, or a transient index failure can all hide it. + // Never turn a recorded transaction hash into an automatic second send. + await this.db.query(` + UPDATE payouts + SET status = 'needs_review', + last_error = $1, + last_retry_at = NOW() + WHERE id = $2 AND status IN ('processing', 'failed') + `, ['Recorded transaction hash was not found; manual reconciliation required', id]); + console.error(`❌ Payout ${id} has a recorded tx_hash that was not found; marked needs_review`); + return; } catch (checkError) { // If we can't check blockchain status, don't risk double-spend - mark for manual review console.error(`❌ Cannot verify tx_hash for payout ${id}, marking for manual review:`, checkError.message); @@ -158,6 +228,21 @@ class PayoutRetryService { } } + // A legacy failure with no transaction hash is only auto-retryable when its recorded + // error proves transfer_split failed before broadcast. Timeouts/transport errors are + // ambiguous and go to manual review instead of risking a second on-chain payment. + if (!payout.tx_hash && !isKnownPreBroadcastFailure(payout.last_error || '')) { + await this.db.query(` + UPDATE payouts + SET status = 'needs_review', + last_error = $1, + last_retry_at = NOW() + WHERE id = $2 AND status IN ('processing', 'failed') + `, [`Automatic retry refused: ambiguous prior failure (${payout.last_error || 'no error recorded'})`, id]); + console.error(`❌ Payout ${id} has no provably pre-broadcast failure; marked needs_review`); + return; + } + // Attempt the payout if (!payout_address) { throw new AppError(`No payout address found for payout ${id}`); @@ -172,8 +257,11 @@ class PayoutRetryService { description: `Retry payout ${id} for game ${game_id}` }); - if (!payoutResult.success) { - throw new AppError(`Payout ${id} retry did not succeed`); + const txHash = typeof payoutResult?.txHash === 'string' + ? payoutResult.txHash.trim() + : ''; + if (payoutResult?.success !== true || !TX_HASH_PATTERN.test(txHash)) { + throw new AppError(`Payout ${id} retry returned no valid transaction-hash evidence`); } // Store tx_hash, mark completed, and count stats ATOMICALLY in one transaction, @@ -189,9 +277,9 @@ class PayoutRetryService { processed_at = NOW(), retry_count = COALESCE(retry_count, 0) + 1, last_retry_at = NOW() - WHERE id = $3 AND status <> 'completed' + WHERE id = $3 AND status = 'processing' RETURNING id - `, [payoutResult.txHash || null, payoutResult.fee || null, id]); + `, [txHash, payoutResult.fee || null, id]); if (transitioned.rows.length > 0) { await client.query(` @@ -203,11 +291,14 @@ class PayoutRetryService { } }); - console.log(`✅ Payout ${id} retry succeeded: ${payoutResult.txHash}`); + console.log(`✅ Payout ${id} retry succeeded: ${txHash}`); } catch (error) { const newRetryCount = (retry_count || 0) + 1; - const newStatus = newRetryCount >= this.maxRetries ? 'permanently_failed' : 'failed'; + const safeToRetry = isKnownPreBroadcastFailure(error); + const newStatus = safeToRetry + ? (newRetryCount >= this.maxRetries ? 'permanently_failed' : 'failed') + : 'needs_review'; await this.db.query(` UPDATE payouts @@ -215,10 +306,12 @@ class PayoutRetryService { retry_count = $2, last_error = $3, last_retry_at = NOW() - WHERE id = $4 - `, [newStatus, newRetryCount, error.message, id]); + WHERE id = $4 AND status IN ('processing', 'failed') + `, [newStatus, newRetryCount, errorText(error).slice(0, 500), id]); - if (newStatus === 'permanently_failed') { + if (newStatus === 'needs_review') { + console.error(`❌ Payout ${id} failed ambiguously and requires manual review: ${errorText(error)}`); + } else if (newStatus === 'permanently_failed') { console.error(`❌ Payout ${id} permanently failed after ${newRetryCount} attempts: ${error.message}`); // Send email alert for permanent failure diff --git a/src/payments/productGrants.js b/src/payments/productGrants.js index 9caaea8..82bc81e 100644 --- a/src/payments/productGrants.js +++ b/src/payments/productGrants.js @@ -5,6 +5,17 @@ function asInt(value, fallback = 0) { return Number.isFinite(n) ? Math.trunc(n) : fallback; } +function asPositiveAtomicString(value) { + const raw = String(value ?? '').trim().replace(/_/g, ''); + if (!/^\d+$/.test(raw)) return null; + try { + const amount = BigInt(raw); + return amount > 0n ? amount.toString() : null; + } catch (_) { + return null; + } +} + function normalizePremiumLevel(value) { if (typeof value !== 'string') return null; const level = value.trim().toLowerCase(); @@ -48,6 +59,9 @@ function normalizeProductGrants(product = {}, fallback = {}) { if (explicit.race_entries != null || explicit.raceEntries != null) { raceEntries = asInt(explicit.race_entries || explicit.raceEntries, 0); } + const raceEntryValueAtomic = asPositiveAtomicString( + explicit.race_entry_value_atomic ?? explicit.raceEntryValueAtomic + ); const packs = []; const packInputs = Array.isArray(explicit.packs) ? explicit.packs : []; @@ -62,6 +76,7 @@ function normalizeProductGrants(product = {}, fallback = {}) { credits: Math.max(0, credits), packs, raceEntries: Math.max(0, raceEntries), + raceEntryValueAtomic, premiumLevel: normalizePremiumLevel(explicit.premiumLevel || explicit.premium_level) }; } @@ -70,6 +85,9 @@ function serializeProductGrants(grants = {}) { return { credits: asInt(grants.credits, 0), raceEntries: asInt(grants.raceEntries || grants.race_entries, 0), + raceEntryValueAtomic: asPositiveAtomicString( + grants.raceEntryValueAtomic ?? grants.race_entry_value_atomic + ), packs: Array.isArray(grants.packs) ? grants.packs.map(normalizePackGrant).filter(Boolean) : [], diff --git a/src/payments/providers/btcpayProvider.js b/src/payments/providers/btcpayProvider.js index 335a96d..b5c95f2 100644 --- a/src/payments/providers/btcpayProvider.js +++ b/src/payments/providers/btcpayProvider.js @@ -11,7 +11,7 @@ function decimalToAtomic(dec, decimals) { const [ip, fpRaw = ''] = s.replace('-', '').split('.'); const fp = (fpRaw + '0'.repeat(decimals)).slice(0, decimals); const atomic = BigInt(ip || '0') * (10n ** BigInt(decimals)) + BigInt(fp || '0'); - return Number(neg ? -atomic : atomic); + return Atomic.toSafe(neg ? -atomic : atomic); } /** @@ -139,6 +139,17 @@ class BTCPayProvider extends PaymentProvider { complete: mapped.complete, amount, required, + receipts: mapped.status === 'paid' && Atomic.toBig(amount) > 0n + ? [{ + evidenceType: 'provider_invoice', + evidenceId: String(id), + providerId: this.id, + txHash: null, + addressIndex: null, + amount: Atomic.toBig(amount).toString(), + confirmed: true + }] + : [], confirmations: null, _terminal: mapped.status === 'expired' || mapped.status === 'invalid' }; diff --git a/src/payments/providers/nativeMoneroProvider.js b/src/payments/providers/nativeMoneroProvider.js index 6439e03..c897b12 100644 --- a/src/payments/providers/nativeMoneroProvider.js +++ b/src/payments/providers/nativeMoneroProvider.js @@ -68,12 +68,15 @@ class NativeMoneroProvider extends PaymentProvider { async validateAddress(chain, address) { try { + if (typeof this.wallet.validateAddress === 'function') { + return await this.wallet.validateAddress(address); + } if (typeof this.wallet.rpcCall === 'function') { const r = await this.wallet.rpcCall('validate_address', { address }); - return { valid: !!(r && r.valid) }; + return { valid: !!(r && (r.valid === true || r.result?.valid === true)) }; } } catch (_) { /* fall through */ } - return { valid: true }; + return { valid: false, reason: 'wallet address validation unavailable' }; } } diff --git a/src/payments/qrService.js b/src/payments/qrService.js index 18c750b..b8225d1 100644 --- a/src/payments/qrService.js +++ b/src/payments/qrService.js @@ -2,6 +2,7 @@ // Uses the 'qrcode' package if available; degrades gracefully if not. let QR; +const money = require('../money/atomic'); try { QR = require('qrcode'); } catch (e) { @@ -9,11 +10,12 @@ try { } function formatAmount(atomic, decimals = 12) { - if (!atomic || atomic <= 0) return '0'; - const divisor = Math.pow(10, decimals); - return (Number(atomic) / divisor).toFixed(decimals) - .replace(/0+$/, '') - .replace(/\.$/, ''); + try { + const exact = money.toBig(atomic); + return exact > 0n ? money.format(exact, decimals) : '0'; + } catch (_) { + return '0'; + } } async function generatePaymentQR(address, amountAtomic, cryptoType, description, decimals = 12) { @@ -33,4 +35,4 @@ async function generatePaymentQR(address, amountAtomic, cryptoType, description, } } -module.exports = { generatePaymentQR }; \ No newline at end of file +module.exports = { generatePaymentQR }; diff --git a/src/payments/walletRPCService.js b/src/payments/walletRPCService.js index b729da0..bb2316d 100644 --- a/src/payments/walletRPCService.js +++ b/src/payments/walletRPCService.js @@ -2,57 +2,315 @@ const axios = require('axios'); const { v4: uuidv4 } = require('uuid'); const { ExternalServiceError, AppError } = require('../utils/errors'); const money = require('../money/atomic'); +const TX_HASH_PATTERN = /^[0-9a-f]{64}$/i; +const { DigestAuthClient } = require('./digestAuth'); +const XMR_ADDRESS_PATTERN = /^(?:4|8|5|7|9|B)[1-9A-HJ-NP-Za-km-z]{90,110}$/; +const WOW_ADDRESS_PATTERN = /^(?:(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})$/; +const KNOWN_NETWORKS = new Set(['mainnet', 'stagenet', 'testnet']); + +function normalizeNetwork(value) { + const normalized = String(value || '').trim().toLowerCase(); + return KNOWN_NETWORKS.has(normalized) ? normalized : null; +} + +function endpointLabel(value) { + try { + const endpoint = new URL(value); + endpoint.username = ''; + endpoint.password = ''; + return endpoint.toString().replace(/\/$/, ''); + } catch (_) { + return '[invalid endpoint]'; + } +} + +function cryptoTypeFromAddress(address) { + const value = String(address || '').trim(); + if (XMR_ADDRESS_PATTERN.test(value)) return 'XMR'; + if (WOW_ADDRESS_PATTERN.test(value)) return 'WOW'; + return null; +} class WalletRPCService { - constructor(debugManager) { + constructor(debugManager, options = {}) { + const env = options.env || process.env; this.debugManager = debugManager; - this.walletEndpoint = process.env.PRIMARY_WALLET_ENDPOINT || 'http://127.0.0.1:34570'; + this.http = options.http || axios; + this.walletEndpoint = options.walletEndpoint || env.PRIMARY_WALLET_ENDPOINT || 'http://127.0.0.1:34570'; + this.authRequired = options.authRequired === undefined + ? String(env.NODE_ENV || '').toLowerCase() === 'production' + : options.authRequired === true; + const rpcUser = options.rpcUser || env.WALLET_RPC_USER; + const rpcPassword = options.rpcPassword || env.WALLET_RPC_PASSWORD; + // Monero wallet-rpc uses HTTP Digest auth. Keep unauthenticated mode available for + // development fixtures; production validation requires an intentional protected setup. + this.walletAuth = rpcUser && rpcPassword + ? { username: rpcUser, password: rpcPassword } + : null; + this.rpcHttp = this.walletAuth + ? new DigestAuthClient(this.http, this.walletAuth) + : this.http; + this.authMaxAgeMs = Math.max(1000, Number(options.authMaxAgeMs) || 30000); + this.authentication = { + verified: false, + reason: this.walletAuth ? 'not_checked' : 'credentials_missing', + checkedAt: 0 + }; this.accountIndex = 0; this.paymentWatchers = new Map(); this.addressToUser = new Map(); this.addressToSocket = new Map(); this.isHealthy = false; + this.lastHealthSuccessAt = 0; + this.lastHealthFailureAt = 0; + const configuredCrypto = String(options.cryptoType || env.CRYPTO_TYPE || 'WOW').trim().toUpperCase(); + const configuredNetwork = normalizeNetwork(options.network || env.MONERO_NETWORK) || 'mainnet'; + this.expectedIdentity = { + cryptoType: configuredCrypto, + network: configuredNetwork + }; + this.identityRequired = options.identityRequired === undefined + ? String(env.NODE_ENV || '').toLowerCase() === 'production' + : options.identityRequired === true; + this.identityMaxAgeMs = Math.max(1000, Number(options.identityMaxAgeMs) || 30000); + this.networkIdentity = { + verified: false, + expected: { ...this.expectedIdentity }, + actual: null, + reason: 'not_checked', + checkedAt: 0 + }; + // The production composition root replaces this with the single payout/readiness + // predicate. Isolated diagnostics and the explicitly guarded stagenet canary retain the + // legacy default unless they deliberately inject a stricter boundary. + this.transferAllowed = typeof options.transferAllowed === 'function' + ? options.transferAllowed + : () => true; + const configuredConfirmations = Number(options.minConfirmations ?? 1); + this.minConfirmations = Number.isSafeInteger(configuredConfirmations) + && configuredConfirmations >= 1 ? configuredConfirmations : 1; } async initialize() { try { + await this.ensureAuthentication({ force: true }); // Test wallet connection const response = await this.rpcCall('get_version'); - this.isHealthy = true; + if (!response?.result) throw new Error('Wallet version response is invalid.'); if (this.debugManager?.CONSOLE_LOGGING) { console.log('✅ Wallet RPC service initialized:', { - version: response.result?.version || 'unknown', - endpoint: this.walletEndpoint + version: response.result?.version || 'unknown' }); } // Get wallet height to ensure it's synced const heightResponse = await this.rpcCall('get_height'); + const height = Number(heightResponse.result?.height); + if (!Number.isSafeInteger(height) || height <= 0) { + throw new Error('Wallet returned an invalid height.'); + } + const identity = await this.verifyNetworkIdentity({ force: true }); + if (this.identityRequired && !identity.verified) { + throw this._identityError(); + } + // Transport success alone must not revive a production wallet that failed identity + // verification. initialize()/ensureNetworkIdentity() promote it after verification. + this.isHealthy = (!this.identityRequired || this.isIdentityFresh()) + && (!this.authRequired || this.isAuthenticationFresh()); if (this.debugManager?.CONSOLE_LOGGING) { - console.log('📊 Wallet height:', heightResponse.result?.height || 0); + console.log('📊 Wallet height:', height); } return true; } catch (error) { this.isHealthy = false; - console.error('❌ Wallet RPC connection failed:', error.message); - console.error(' Make sure wownero-wallet-rpc is running on', this.walletEndpoint); + console.error('❌ Wallet RPC initialization failed:', error?.code || 'wallet_unavailable'); return false; } } + _identityError() { + return new ExternalServiceError('Wallet identity does not match the configured chain/network.', { + code: 'WALLET_IDENTITY_MISMATCH', + safeMessage: 'Wallet service is unavailable due to a network configuration mismatch.' + }); + } + + _authenticationError(reason = this.authentication.reason) { + const notEnforced = reason === 'not_enforced'; + return new ExternalServiceError( + notEnforced + ? 'Wallet RPC does not enforce configured Digest authentication.' + : 'Wallet RPC authentication could not be verified.', + { + statusCode: 503, + code: notEnforced ? 'WALLET_RPC_AUTH_NOT_ENFORCED' : 'WALLET_RPC_AUTH_UNVERIFIED', + safeMessage: 'Wallet service authentication is unavailable.' + } + ); + } + + async ensureAuthentication({ force = false } = {}) { + if (!this.authRequired) return this.authentication; + if (!this.walletAuth || typeof this.rpcHttp?.verifyEnforced !== 'function') { + this.authentication = { + verified: false, + reason: 'credentials_missing', + checkedAt: Date.now() + }; + this.isHealthy = false; + throw this._authenticationError(); + } + if (!force && this.isAuthenticationFresh()) return this.authentication; + + const url = `${this.walletEndpoint}/json_rpc`; + const body = { jsonrpc: '2.0', id: 'auth-probe', method: 'get_version', params: {} }; + const requestConfig = { + headers: { 'Content-Type': 'application/json' }, + timeout: 10000 + }; + try { + await this.rpcHttp.verifyEnforced(url, body, requestConfig); + const authenticated = await this.rpcHttp.post(url, body, requestConfig); + if (!authenticated?.data?.result || authenticated.data.error) { + throw new Error('Authenticated wallet version response is invalid.'); + } + this.authentication = { + verified: true, + reason: null, + checkedAt: Date.now() + }; + return this.authentication; + } catch (error) { + const reason = error?.code === 'WALLET_RPC_AUTH_NOT_ENFORCED' + ? 'not_enforced' + : 'unverified'; + this.authentication = { + verified: false, + reason, + checkedAt: Date.now() + }; + this.isHealthy = false; + throw this._authenticationError(reason); + } + } + + isAuthenticationFresh() { + return this.authentication.verified === true + && (Date.now() - this.authentication.checkedAt) <= this.authMaxAgeMs; + } + + async verifyNetworkIdentity({ force = false } = {}) { + if (!force && this.networkIdentity.verified + && (Date.now() - this.networkIdentity.checkedAt) <= this.identityMaxAgeMs) { + return this.networkIdentity; + } + + try { + const addressResponse = await this.rpcCall('get_address', { + account_index: this.accountIndex, + address_index: [0] + }); + // The primary address is used only in this stack frame. Never retain it in health + // state or logs; it is identity evidence, not public diagnostics. + const address = String(addressResponse.result?.address + || addressResponse.result?.addresses?.[0]?.address || '').trim(); + const validationResponse = await this.rpcCall('validate_address', { + address, + any_net_type: true + }); + const validation = validationResponse.result || {}; + const actual = { + cryptoType: cryptoTypeFromAddress(address), + network: normalizeNetwork(validation.nettype) + }; + let reason = null; + if (validation.valid !== true || !actual.cryptoType || !actual.network) { + reason = 'identity_unavailable'; + } else if (actual.cryptoType !== this.expectedIdentity.cryptoType) { + reason = 'chain_mismatch'; + } else if (actual.network !== this.expectedIdentity.network) { + reason = 'network_mismatch'; + } + this.networkIdentity = { + verified: reason === null, + expected: { ...this.expectedIdentity }, + actual, + reason, + checkedAt: Date.now() + }; + if (this.identityRequired && reason) this.isHealthy = false; + return this.networkIdentity; + } catch (error) { + this.networkIdentity = { + verified: false, + expected: { ...this.expectedIdentity }, + actual: null, + reason: 'rpc_unavailable', + checkedAt: Date.now() + }; + throw error; + } + } + + async ensureNetworkIdentity({ force = false } = {}) { + await this.ensureAuthentication({ force }); + if (!this.identityRequired) return this.networkIdentity; + let identity; + try { + identity = await this.verifyNetworkIdentity({ force }); + } catch (_) { + this.isHealthy = false; + throw this._identityError(); + } + if (!identity.verified) { + this.isHealthy = false; + throw this._identityError(); + } + this.isHealthy = true; + return identity; + } + + _assertTransferAllowed() { + if (!this.transferAllowed()) { + throw new AppError('Wallet transfer blocked by the payout safety gate.', { + statusCode: 503, + code: 'PAYOUT_DISPATCH_DISABLED', + safeMessage: 'Payout transfers are currently disabled.' + }); + } + } + + getIdentityStatus() { + return { + required: this.identityRequired, + verified: this.isIdentityFresh(), + expected: { ...this.expectedIdentity }, + actual: this.networkIdentity.actual ? { ...this.networkIdentity.actual } : null, + reason: this.networkIdentity.reason || 'not_checked', + checkedAt: Number(this.networkIdentity.checkedAt || 0) + }; + } + + isIdentityFresh() { + return this.networkIdentity.verified === true + && (Date.now() - this.networkIdentity.checkedAt) <= this.identityMaxAgeMs; + } + async rpcCall(method, params = {}) { try { - const response = await axios.post(`${this.walletEndpoint}/json_rpc`, { + const requestConfig = { + headers: { 'Content-Type': 'application/json' }, + timeout: 10000 + }; + + const response = await this.rpcHttp.post(`${this.walletEndpoint}/json_rpc`, { jsonrpc: '2.0', id: '0', method: method, params: params - }, { - headers: { 'Content-Type': 'application/json' }, - timeout: 10000 - }); + }, requestConfig); if (response.data.error) { throw new ExternalServiceError(`Wallet RPC responded with error for method ${method}`, { @@ -62,8 +320,14 @@ class WalletRPCService { }); } + this.isHealthy = (!this.identityRequired || this.isIdentityFresh()) + && (!this.authRequired || this.isAuthenticationFresh()); + this.lastHealthSuccessAt = Date.now(); + return response.data; } catch (error) { + this.isHealthy = false; + this.lastHealthFailureAt = Date.now(); const wrapped = error instanceof ExternalServiceError ? error : new ExternalServiceError(`Wallet RPC call failed (${method})`, { @@ -80,6 +344,7 @@ class WalletRPCService { async createPaymentRequest(amount, description, userId, socketId) { try { + await this.ensureNetworkIdentity(); // Create a new subaddress for this payment const response = await this.rpcCall('create_address', { account_index: this.accountIndex, @@ -136,6 +401,7 @@ class WalletRPCService { async checkPaymentStatus(address) { try { + await this.ensureNetworkIdentity(); const userInfo = this.addressToUser.get(address); if (!userInfo) { return { @@ -147,7 +413,10 @@ class WalletRPCService { }; } - // Get incoming transfers for this specific subaddress + // `get_transfers` supplies transaction state (pool, double-spend, timelock and + // confirmation metadata), but it does not expose a stable identity for each owned + // output. `incoming_transfers` supplies that output-level identity (`pubkey` and + // `global_index`). Entitlements require both views to agree for this subaddress. const response = await this.rpcCall('get_transfers', { in: true, pending: true, @@ -156,19 +425,64 @@ class WalletRPCService { account_index: userInfo.accountIndex, subaddr_indices: [userInfo.addressIndex] }); + const outputResponse = await this.rpcCall('incoming_transfers', { + transfer_type: 'all', + account_index: userInfo.accountIndex, + subaddr_indices: [userInfo.addressIndex] + }); - let totalReceived = 0; + let confirmedReceived = 0n; + let poolReceived = 0n; let inMempool = false; let confirmed = false; let confirmations = 0; - let txHash = null; + const confirmedReceipts = new Map(); + const receiptAddressIndex = (tx) => { + const raw = tx?.subaddr_index; + if (Number.isInteger(raw) && raw >= 0) return raw; + if (raw && Number.isInteger(raw.minor) && raw.minor >= 0) return raw.minor; + return null; + }; + const receiptAccountIndex = (tx) => { + const raw = tx?.subaddr_index; + return raw && Number.isInteger(raw.major) && raw.major >= 0 ? raw.major : null; + }; + const receiptOutputId = (tx) => { + const pubkey = String(tx?.pubkey || '').trim().toLowerCase(); + if (/^[0-9a-f]{64}$/.test(pubkey)) return pubkey; + const globalIndex = tx?.global_index; + if (typeof globalIndex === 'number' && !Number.isSafeInteger(globalIndex)) return null; + const canonical = String(globalIndex ?? '').trim(); + if (!/^\d+$/.test(canonical)) return null; + const parsed = BigInt(canonical); + if (parsed > 18446744073709551615n) return null; + return `global:${parsed.toString()}`; + }; + const hasSafeUnlockTime = (tx) => { + if (tx?.unlock_time == null) return false; + const raw = tx.unlock_time; + if (typeof raw === 'number') return Number.isSafeInteger(raw) && raw === 0; + if (typeof raw === 'bigint') return raw === 0n; + const canonical = String(raw).trim(); + return /^\d+$/.test(canonical) && BigInt(canonical) === 0n; + }; + const belongsToRequestedSubaddress = (tx) => { + const direct = receiptAddressIndex(tx); + if (direct !== null) return direct === userInfo.addressIndex; + const indices = Array.isArray(tx?.subaddr_indices) ? tx.subaddr_indices : []; + return indices.some(index => { + if (Number.isInteger(index)) return index === userInfo.addressIndex; + return index && Number.isInteger(index.minor) + && index.minor === userInfo.addressIndex + && (!Number.isInteger(index.major) || index.major === userInfo.accountIndex); + }); + }; // Check pending (mempool) transactions if (response.result?.pool && response.result.pool.length > 0) { for (const tx of response.result.pool) { - totalReceived += tx.amount; + poolReceived += money.toBig(tx.amount); inMempool = true; - txHash = tx.txid; // Mark as detected if this is first time seeing it if (!userInfo.detected) { @@ -176,53 +490,123 @@ class WalletRPCService { if (this.debugManager?.CONSOLE_LOGGING) { console.log(`🎯 Payment detected (mempool):`, { address: address.substring(0, 10) + '...', - amount: tx.amount, - txid: txHash + amount: money.toSafe(money.toBig(tx.amount)), + txid: tx.txid }); } } } } - // Check confirmed transactions - if (response.result?.in && response.result.in.length > 0) { - for (const tx of response.result.in) { - totalReceived += tx.amount; - confirmed = true; - txHash = tx.txid; - - // Calculate confirmations - if (tx.height > 0) { - const heightResp = await this.rpcCall('get_height'); - confirmations = Math.max(confirmations, heightResp.result.height - tx.height); - } - - if (!userInfo.confirmed) { - userInfo.confirmed = true; - if (this.debugManager?.CONSOLE_LOGGING) { - console.log(`✅ Payment included in block (confirmed):`, { - address: address.substring(0, 10) + '...', - confirmations: confirmations, - txid: txHash - }); - } + // Build a fail-closed transaction-state index. A chain output is never enough on its + // own: its transaction must also appear as a safe, mined incoming transfer for the + // exact requested subaddress. + const confirmedTx = new Map(); + for (const tx of (response.result?.in || [])) { + const txHash = String(tx?.txid || '').trim().toLowerCase(); + const height = Number(tx?.height); + const accountIndex = receiptAccountIndex(tx); + const reportedConfirmations = Number(tx?.confirmations); + let transactionAmount = null; + try { transactionAmount = money.toBig(tx?.amount); } catch (_) { /* rejected below */ } + if (!TX_HASH_PATTERN.test(txHash) + || !belongsToRequestedSubaddress(tx) + || (accountIndex !== null && accountIndex !== userInfo.accountIndex) + || tx?.double_spend_seen !== false + || !hasSafeUnlockTime(tx) + || !Number.isSafeInteger(height) || height <= 0 + || !Number.isSafeInteger(reportedConfirmations) + || reportedConfirmations < this.minConfirmations + || transactionAmount === null || transactionAmount <= 0n) { + continue; + } + confirmedTx.set(txHash, { + height, + confirmations: reportedConfirmations, + remainingAmount: transactionAmount + }); + } + + for (const output of (outputResponse.result?.transfers || [])) { + const txHash = String(output?.tx_hash || '').trim().toLowerCase(); + const txState = confirmedTx.get(txHash); + const addressIndex = receiptAddressIndex(output); + const accountIndex = receiptAccountIndex(output); + const outputId = receiptOutputId(output); + const blockHeight = Number(output?.block_height); + if (!txState + || addressIndex !== userInfo.addressIndex + || (accountIndex !== null && accountIndex !== userInfo.accountIndex) + || !outputId + || !Number.isSafeInteger(blockHeight) || blockHeight <= 0 + || blockHeight !== txState.height + || output?.frozen === true) { + continue; + } + const amount = money.toBig(output.amount); + if (amount <= 0n || amount > txState.remainingAmount) continue; + const receiptKey = `${txHash}:${outputId}`; + // RPC responses may repeat the same output; replaying identical evidence must + // never increase confirmed coverage. + if (confirmedReceipts.has(receiptKey)) continue; + confirmedReceipts.set(receiptKey, { + evidenceType: 'chain_output', + evidenceId: receiptKey, + providerId: 'native-monero', + txHash, + outputId, + addressIndex, + amount: amount.toString(), + confirmed: true + }); + confirmedReceived += amount; + txState.remainingAmount -= amount; + } + + confirmed = confirmedReceipts.size > 0; + if (confirmed) { + // Report the least-confirmed counted receipt, never the oldest/top value. + const depths = Array.from(confirmedReceipts.values()).map(receipt => { + const state = confirmedTx.get(receipt.txHash); + return state.confirmations; + }); + confirmations = Math.min(...depths); + if (!userInfo.confirmed) { + userInfo.confirmed = true; + if (this.debugManager?.CONSOLE_LOGGING) { + console.log(`✅ Payment included in block (confirmed):`, { + address: address.substring(0, 10) + '...', + confirmations, + txid: Array.from(confirmedReceipts.values())[0].txHash + }); } } } - const paymentComplete = totalReceived >= userInfo.amount; + const requiredAmount = money.toBig(userInfo.amount); + // Pool transfers are useful detection/UI evidence only. Entitlements are authorized + // exclusively by confirmed receipts, so an old 1-atomic confirmed transfer plus an + // unconfirmed pool top-up can never satisfy the invoice. + const paymentComplete = confirmedReceived >= requiredAmount; + const receipts = Array.from(confirmedReceipts.values()) + .sort((left, right) => left.evidenceId.localeCompare(right.evidenceId)); return { complete: paymentComplete, in_mempool: inMempool, confirmed: confirmed, - amount: totalReceived, + amount: money.toSafe(confirmedReceived), + observedAmount: money.toSafe(confirmedReceived + poolReceived), + pendingAmount: money.toSafe(poolReceived), confirmations: confirmations, - required: userInfo.amount, - txHash: txHash + required: money.toSafe(requiredAmount), + txHash: receipts[0]?.txHash || null, + receipts }; } catch (error) { - console.error('❌ Failed to check payment status:', error); + // Axios/Digest errors retain request configuration, including Authorization. Never + // inspect the whole object in logs; rpcCall already records a credential-free label. + console.error('❌ Failed to check payment status:', error?.message || 'wallet RPC failure'); return { complete: false, in_mempool: false, @@ -279,11 +663,13 @@ class WalletRPCService { * @returns {Object} - { tx_hash_list, tx_key_list, fee_list, totalFee } */ async processBatchPayout(destinations) { + this._assertTransferAllowed(); if (!destinations || destinations.length === 0) { throw new AppError('No destinations provided for batch payout', { safeMessage: 'Batch payout requires at least one destination.' }); } + await this.ensureNetworkIdentity(); // Validate all addresses before attempting transfer for (const dest of destinations) { @@ -317,10 +703,25 @@ class WalletRPCService { }); } + // Address validation may take several RPC round trips. Recheck at the last possible + // point before transfer_split can broadcast. + this._assertTransferAllowed(); const response = await this.rpcCall('transfer_split', transferParams); + const hashes = Array.isArray(response.result?.tx_hash_list) + ? response.result.tx_hash_list.map(hash => String(hash || '').trim()) + : []; + if (hashes.length === 0 || hashes.some(hash => !TX_HASH_PATTERN.test(hash))) { + // The RPC call may already have broadcast. Never manufacture success without + // durable chain evidence; callers move the claimed rows to manual review. + throw new ExternalServiceError('Wallet returned no valid transaction-hash evidence', { + safeMessage: 'The payout transfer requires manual wallet reconciliation.' + }); + } + const result = { - tx_hash_list: response.result.tx_hash_list || [], + success: true, + tx_hash_list: hashes, tx_key_list: response.result.tx_key_list || [], fee_list: response.result.fee_list || [], totalFee: money.toSafe(money.sum(response.result.fee_list || [])) @@ -356,21 +757,20 @@ class WalletRPCService { return Number(value); } - const num = Number(value); - if (!Number.isFinite(num) || num <= 0) { + let exact; + try { + exact = money.toBig(value); + } catch (_) { throw new AppError('Payout amount must be a positive number', { safeMessage: 'Invalid payout amount supplied.' }); } - - const truncated = Math.trunc(num); - if (truncated <= 0) { + if (exact <= 0n) { throw new AppError('Payout amount rounded below minimum atomic unit', { safeMessage: 'Invalid payout amount supplied.' }); } - - return truncated; + return money.toSafe(exact); } /** @@ -379,6 +779,7 @@ class WalletRPCService { */ async getBalance() { try { + await this.ensureNetworkIdentity(); const response = await this.rpcCall('get_balance', { account_index: this.accountIndex }); @@ -403,6 +804,7 @@ class WalletRPCService { * @returns {Object} - { address: string, address_index: number } */ async getOwnAddress() { + await this.ensureNetworkIdentity(); const response = await this.rpcCall('get_address', { account_index: this.accountIndex, address_index: [0] @@ -420,6 +822,7 @@ class WalletRPCService { */ async validateAddress(address) { try { + await this.ensureNetworkIdentity(); if (!address || typeof address !== 'string') { return { valid: false, error: 'Address is empty or not a string' }; } @@ -450,11 +853,13 @@ class WalletRPCService { subaddressIndex = null } = {}) { try { + this._assertTransferAllowed(); if (!address || typeof address !== 'string') { throw new AppError('Payout address was not provided or invalid', { safeMessage: 'Invalid payout address supplied.' }); } + await this.ensureNetworkIdentity(); // Validate address with wallet RPC before attempting transfer const validation = await this.validateAddress(address); @@ -491,14 +896,28 @@ class WalletRPCService { }); } + this._assertTransferAllowed(); // Use transfer_split for better output handling — automatically splits // across multiple outputs if needed, more resilient than plain transfer const response = await this.rpcCall('transfer_split', transferParams); // transfer_split returns arrays: tx_hash_list, tx_key_list, fee_list - const txHash = response.result.tx_hash_list?.[0]; - const txKey = response.result.tx_key_list?.[0]; - const fee = response.result.fee_list?.reduce((a, b) => a + b, 0) || 0; + const txHashes = Array.isArray(response.result?.tx_hash_list) + ? response.result.tx_hash_list.map(hash => String(hash || '').trim()) + : []; + if (txHashes.length !== 1 || !TX_HASH_PATTERN.test(txHashes[0])) { + // One payout row must map to exactly one valid hash. transfer_split may have + // broadcast already, so this is deliberately an ambiguous failure and must not + // be automatically retried by callers. + throw new ExternalServiceError('Wallet returned ambiguous transaction-hash evidence', { + safeMessage: 'The payout transfer requires manual wallet reconciliation.' + }); + } + const txHash = txHashes[0]; + const txKey = response.result.tx_key_list?.length === 1 + ? response.result.tx_key_list[0] + : null; + const fee = money.toSafe(money.sum(response.result.fee_list || [])); if (this.debugManager?.CONSOLE_LOGGING) { console.log('💸 Payout sent successfully', { @@ -539,6 +958,7 @@ class WalletRPCService { */ async checkTransactionStatus(txHash) { try { + await this.ensureNetworkIdentity(); if (!txHash || typeof txHash !== 'string') { return { exists: false, confirmed: false, in_mempool: false }; } @@ -589,12 +1009,23 @@ class WalletRPCService { // Get service health status getHealthStatus() { return { - healthy: this.isHealthy, - endpoint: this.walletEndpoint, + healthy: this.isHealthy + && (!this.identityRequired || this.isIdentityFresh()) + && (!this.authRequired || this.isAuthenticationFresh()), + endpoint: endpointLabel(this.walletEndpoint), activeMonitors: this.paymentWatchers.size, - pendingPayments: this.addressToUser.size + pendingPayments: this.addressToUser.size, + identity: this.getIdentityStatus(), + authentication: { + required: this.authRequired, + verified: this.isAuthenticationFresh(), + reason: this.authentication.reason || null, + checkedAt: Number(this.authentication.checkedAt || 0) + } }; } } -module.exports = WalletRPCService; \ No newline at end of file +module.exports = WalletRPCService; +module.exports.cryptoTypeFromAddress = cryptoTypeFromAddress; +module.exports.endpointLabel = endpointLabel; diff --git a/src/routes/admin.js b/src/routes/admin.js index 4cdf265..0365455 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -7,11 +7,46 @@ const express = require('express'); const crypto = require('crypto'); const asyncHandler = require('../middleware/asyncHandler'); -const { AppError, ValidationError, NotFoundError } = require('../utils/errors'); +const { ValidationError, NotFoundError } = require('../utils/errors'); +const PaymentRefundService = require('../services/paymentRefundService'); + +const PAYOUT_STATUSES = Object.freeze([ + 'pending', + 'processing', + 'completed', + 'failed', + 'needs_review', + 'permanently_failed', + 'batched' +]); +const PAYOUT_ATTENTION_STATUSES = Object.freeze([ + 'pending', + 'processing', + 'failed', + 'needs_review', + 'permanently_failed' +]); +const REFUND_STATUSES = Object.freeze([ + 'recorded', + 'requested', + 'processing', + 'completed', + 'needs_review' +]); +const REFUND_ATTENTION_STATUSES = Object.freeze([ + 'requested', + 'processing', + 'needs_review' +]); module.exports = function createAdminRoutes(ctx) { const { db, gameModeManager, walletRPCService, socketHandlers, io } = ctx; const router = express.Router(); + const paymentRefundService = ctx.paymentRefundService || new PaymentRefundService({ + db, + walletService: walletRPCService, + isSendEnabled: ctx.canDispatchPayouts + }); // ============================================================================= // Admin API Endpoints (requires ADMIN_API_KEY) @@ -56,9 +91,11 @@ const adminAuth = (req, res, next) => { } const providedKey = req.headers['x-admin-key']; - if (!providedKey || - providedKey.length !== adminKey.length || - !crypto.timingSafeEqual(Buffer.from(providedKey), Buffer.from(adminKey))) { + const providedKeyBuffer = typeof providedKey === 'string' ? Buffer.from(providedKey) : null; + const adminKeyBuffer = Buffer.from(adminKey); + if (!providedKeyBuffer || + providedKeyBuffer.length !== adminKeyBuffer.length || + !crypto.timingSafeEqual(providedKeyBuffer, adminKeyBuffer)) { return res.status(401).json({ error: 'Unauthorized', message: 'Invalid or missing X-Admin-Key header.' @@ -75,144 +112,25 @@ const adminAuth = (req, res, next) => { */ router.post('/api/admin/refund/payment', adminAuth, asyncHandler(async (req, res) => { const { paymentId, reason, sendFunds } = req.body || {}; - - if (!paymentId || typeof paymentId !== 'number') { + + if (!Number.isSafeInteger(paymentId) || paymentId <= 0) { throw new ValidationError('Invalid paymentId', { - safeMessage: 'paymentId (number) is required.' + safeMessage: 'paymentId (positive integer) is required.' }); } - - // Get payment record - const paymentResult = await db.query(` - SELECT p.*, u.payout_address, u.socket_id - FROM payments p - LEFT JOIN users u ON p.user_id = u.id - WHERE p.id = $1 - `, [paymentId]); - - if (paymentResult.rows.length === 0) { - throw new NotFoundError('Payment not found', { - safeMessage: `Payment ${paymentId} not found.` + if (sendFunds !== undefined && typeof sendFunds !== 'boolean') { + throw new ValidationError('Invalid sendFunds', { + safeMessage: 'sendFunds must be a boolean when provided.' }); } - - const payment = paymentResult.rows[0]; - - if (payment.status === 'refunded') { - return res.json({ - success: false, - message: 'Payment already refunded.', - payment: { id: payment.id, status: payment.status } + if (reason !== undefined && (typeof reason !== 'string' || reason.length > 500)) { + throw new ValidationError('Invalid refund reason', { + safeMessage: 'reason must be a string of at most 500 characters.' }); } - - // Begin transaction - const client = await db.getClient(); - try { - await client.query('BEGIN'); - - // Mark payment as refunded - await client.query(` - UPDATE payments - SET status = 'refunded', - description = COALESCE(description, '') || ' | Refunded: ' || $2 - WHERE id = $1 - `, [paymentId, reason || 'Admin refund']); - - // If credits were purchased, deduct them - let creditsDeducted = 0; - if (payment.credits_purchased > 0 && payment.user_id) { - // SECURITY: Use FOR UPDATE to lock the row and prevent race conditions - const userResult = await client.query(` - SELECT credits FROM users WHERE id = $1 FOR UPDATE - `, [payment.user_id]); - const currentCredits = userResult.rows[0]?.credits || 0; - creditsDeducted = Math.min(payment.credits_purchased, currentCredits); - - if (creditsDeducted > 0) { - // SECURITY: Use atomic decrement with guard to prevent negative balance - const updateResult = await client.query(` - UPDATE users SET credits = credits - $1 WHERE id = $2 AND credits >= $1 - RETURNING credits - `, [creditsDeducted, payment.user_id]); - - let newBalance = updateResult.rows[0]?.credits; - - // If update failed (race condition changed credits), recalculate - if (updateResult.rows.length === 0) { - const recheckResult = await client.query(`SELECT credits FROM users WHERE id = $1 FOR UPDATE`, [payment.user_id]); - const actualCredits = recheckResult.rows[0]?.credits || 0; - creditsDeducted = Math.min(payment.credits_purchased, actualCredits); - if (creditsDeducted > 0) { - const retryResult = await client.query(` - UPDATE users SET credits = credits - $1 WHERE id = $2 AND credits >= $1 RETURNING credits - `, [creditsDeducted, payment.user_id]); - newBalance = retryResult.rows[0]?.credits ?? (actualCredits - creditsDeducted); - } else { - newBalance = actualCredits; - } - } - - // Record credit transaction - if (creditsDeducted > 0) { - await client.query(` - INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) - VALUES ($1, $2, $3, $4, 'refund') - `, [payment.user_id, -creditsDeducted, `Refund for payment ${paymentId}`, newBalance]); - } - } - } - - await client.query('COMMIT'); - - // Optionally send funds back via wallet RPC. Refund what the user ACTUALLY paid - // (received_amount, recorded at confirmation) when available, falling back to the - // expected amount for older records. - let fundsSent = false; - let txHash = null; - const refundAmount = (payment.received_amount != null && Number(payment.received_amount) > 0) - ? payment.received_amount - : payment.expected_amount; - if (sendFunds && payment.payout_address && Number(refundAmount) > 0) { - try { - // processPayout validates the address and performs the transfer; returns - // { success, txHash, fee }. (The previous code called a nonexistent sendPayment(), - // so refunds were silently never sent while still reporting success.) - const result = await walletRPCService.processPayout({ - address: payment.payout_address, - amount: refundAmount, - userId: payment.user_id, - description: `Refund for payment ${paymentId}` - }); - fundsSent = !!(result && result.success); - txHash = (result && result.txHash) || null; - } catch (err) { - console.error('Failed to send refund funds:', err.message); - // Don't fail the request - refund is recorded, funds can be sent manually - } - } - - res.json({ - success: true, - message: 'Payment refunded successfully.', - refund: { - paymentId, - originalAmount: payment.expected_amount, - originalAmountFormatted: gameModeManager.formatAtomicHuman(payment.expected_amount, 4), - creditsDeducted, - fundsSent, - txHash, - reason: reason || 'Admin refund' - } - }); - - } catch (err) { - await client.query('ROLLBACK'); - throw err; - } finally { - client.release(); - } + const result = await paymentRefundService.refundPayment({ paymentId, reason, sendFunds }); + res.json(PaymentRefundService.toApiResult(result, gameModeManager)); })); /** @@ -224,15 +142,15 @@ router.post('/api/admin/refund/payment', adminAuth, asyncHandler(async (req, res router.post('/api/admin/credits/adjust', adminAuth, asyncHandler(async (req, res) => { const { socketId, amount, reason } = req.body || {}; - if (!socketId || typeof socketId !== 'string') { + if (!socketId || typeof socketId !== 'string' || socketId.length > 200) { throw new ValidationError('Invalid socketId', { safeMessage: 'socketId (string) is required.' }); } - if (typeof amount !== 'number' || amount === 0) { + if (!Number.isSafeInteger(amount) || amount === 0 || Math.abs(amount) > 1000000) { throw new ValidationError('Invalid amount', { - safeMessage: 'amount (non-zero number) is required. Positive to add, negative to subtract.' + safeMessage: 'amount must be a non-zero safe integer between -1000000 and 1000000.' }); } @@ -577,12 +495,26 @@ router.get('/api/admin/stats/overview', adminAuth, asyncHandler(async (req, res) const payoutStats = await db.query(` SELECT COUNT(*) FILTER (WHERE status = 'pending') as pending_payouts, + COUNT(*) FILTER (WHERE status = 'processing') as processing_payouts, COUNT(*) FILTER (WHERE status = 'failed') as failed_payouts, + COUNT(*) FILTER (WHERE status = 'needs_review') as review_payouts, + COUNT(*) FILTER (WHERE status = 'permanently_failed') as permanently_failed_payouts, COUNT(*) FILTER (WHERE status = 'completed') as completed_payouts, COUNT(*) FILTER (WHERE status = 'completed' AND processed_at > NOW() - INTERVAL '24 hours') as payouts_24h FROM payouts `); + // Refund transfers have their own durable outbox. `processing` and `needs_review` + // are intentionally non-retryable, so they must be visible beside payout liabilities. + const refundStats = await db.query(` + SELECT + COUNT(*) FILTER (WHERE status = 'requested') as requested_refunds, + COUNT(*) FILTER (WHERE status = 'processing') as processing_refunds, + COUNT(*) FILTER (WHERE status = 'needs_review') as review_refunds, + COUNT(*) FILTER (WHERE status = 'completed') as completed_refunds + FROM payment_refunds + `); + // Get game counts for last 24h const gameStats = await db.query(` SELECT @@ -607,6 +539,7 @@ router.get('/api/admin/stats/overview', adminAuth, asyncHandler(async (req, res) const user = userStats.rows[0]; const payout = payoutStats.rows[0]; + const refund = refundStats.rows[0]; const game = gameStats.rows[0]; const cryptoType = gameModeManager.cryptoType || process.env.CRYPTO_TYPE || 'WOW'; @@ -619,8 +552,17 @@ router.get('/api/admin/stats/overview', adminAuth, asyncHandler(async (req, res) totalGamesWon: parseInt(user.total_wins) || 0, totalPayoutVolume: user.total_payout_volume || '0', pendingPayouts: parseInt(payout.pending_payouts) || 0, + processingPayouts: parseInt(payout.processing_payouts) || 0, failedPayouts: parseInt(payout.failed_payouts) || 0, + payoutsNeedsReview: parseInt(payout.review_payouts) || 0, + permanentlyFailedPayouts: parseInt(payout.permanently_failed_payouts) || 0, completedPayouts: parseInt(payout.completed_payouts) || 0, + refunds: { + requested: parseInt(refund.requested_refunds) || 0, + processing: parseInt(refund.processing_refunds) || 0, + needsReview: parseInt(refund.review_refunds) || 0, + completed: parseInt(refund.completed_refunds) || 0 + }, walletBalance: walletBalance, cryptoType, atomicDivisor, @@ -643,7 +585,10 @@ router.get('/api/admin/stats/payouts', adminAuth, asyncHandler(async (req, res) let whereClause = ''; const params = [limitNum, offsetNum]; - if (status && ['pending', 'failed', 'completed', 'permanently_failed'].includes(status)) { + if (status === 'attention') { + whereClause = 'WHERE status = ANY($3::varchar[])'; + params.push(PAYOUT_ATTENTION_STATUSES); + } else if (status && PAYOUT_STATUSES.includes(status)) { whereClause = 'WHERE status = $3'; params.push(status); } @@ -661,9 +606,12 @@ router.get('/api/admin/stats/payouts', adminAuth, asyncHandler(async (req, res) const totals = await db.query(` SELECT COUNT(*) FILTER (WHERE status = 'pending') as pending_count, + COUNT(*) FILTER (WHERE status = 'processing') as processing_count, COUNT(*) FILTER (WHERE status = 'failed') as failed_count, + COUNT(*) FILTER (WHERE status = 'needs_review') as review_count, COUNT(*) FILTER (WHERE status = 'completed') as completed_count, COUNT(*) FILTER (WHERE status = 'permanently_failed') as permanently_failed_count, + COUNT(*) FILTER (WHERE status = 'batched') as batched_count, COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) as total_volume FROM payouts `); @@ -685,14 +633,91 @@ router.get('/api/admin/stats/payouts', adminAuth, asyncHandler(async (req, res) })), totals: { pending: parseInt(totals.rows[0].pending_count) || 0, + processing: parseInt(totals.rows[0].processing_count) || 0, failed: parseInt(totals.rows[0].failed_count) || 0, + needsReview: parseInt(totals.rows[0].review_count) || 0, completed: parseInt(totals.rows[0].completed_count) || 0, permanentlyFailed: parseInt(totals.rows[0].permanently_failed_count) || 0, + batched: parseInt(totals.rows[0].batched_count) || 0, totalVolume: totals.rows[0].total_volume || '0' } }); })); +// GET /api/admin/stats/refunds - Durable payment-refund outbox details +router.get('/api/admin/stats/refunds', adminAuth, asyncHandler(async (req, res) => { + const { status, limit = 50, offset = 0 } = req.query; + const limitNum = Math.min(parseInt(limit) || 50, 200); + const offsetNum = parseInt(offset) || 0; + + let whereClause = ''; + const params = [limitNum, offsetNum]; + if (status === 'attention') { + whereClause = 'WHERE r.status = ANY($3::varchar[])'; + params.push(REFUND_ATTENTION_STATUSES); + } else if (status && REFUND_STATUSES.includes(status)) { + whereClause = 'WHERE r.status = $3'; + params.push(status); + } + + const result = await db.query(` + SELECT r.id, r.payment_id, r.user_id, r.status, r.amount, r.payout_address, + r.credits_deducted, r.purchase_progress_deducted, + r.race_entries_deducted, r.tx_hash, r.error_message, r.reason, + r.requested_at, r.processing_started_at, r.completed_at, + r.needs_review_at, r.created_at, r.updated_at, + p.status AS payment_status + FROM payment_refunds r + JOIN payments p ON p.id = r.payment_id + ${whereClause} + ORDER BY r.updated_at DESC, r.id DESC + LIMIT $1 OFFSET $2 + `, params); + + const totals = await db.query(` + SELECT + COUNT(*) FILTER (WHERE status = 'recorded') as recorded_count, + COUNT(*) FILTER (WHERE status = 'requested') as requested_count, + COUNT(*) FILTER (WHERE status = 'processing') as processing_count, + COUNT(*) FILTER (WHERE status = 'completed') as completed_count, + COUNT(*) FILTER (WHERE status = 'needs_review') as review_count, + COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) as completed_volume + FROM payment_refunds + `); + + res.json({ + refunds: result.rows.map(r => ({ + id: r.id, + paymentId: r.payment_id, + userId: r.user_id, + paymentStatus: r.payment_status, + status: r.status, + amount: r.amount, + address: r.payout_address, + creditsDeducted: r.credits_deducted, + purchaseProgressDeducted: r.purchase_progress_deducted, + raceEntriesDeducted: r.race_entries_deducted, + txHash: r.tx_hash, + error: r.error_message, + reason: r.reason, + requestedAt: r.requested_at, + processingStartedAt: r.processing_started_at, + completedAt: r.completed_at, + needsReviewAt: r.needs_review_at, + createdAt: r.created_at, + updatedAt: r.updated_at + })), + totals: { + recorded: parseInt(totals.rows[0].recorded_count) || 0, + requested: parseInt(totals.rows[0].requested_count) || 0, + processing: parseInt(totals.rows[0].processing_count) || 0, + completed: parseInt(totals.rows[0].completed_count) || 0, + needsReview: parseInt(totals.rows[0].review_count) || 0, + completedVolume: totals.rows[0].completed_volume || '0' + } + }); +})); + // GET /api/admin/stats/games - Game statistics router.get('/api/admin/stats/games', adminAuth, asyncHandler(async (req, res) => { const { period = '24h', limit = 50 } = req.query; @@ -885,55 +910,18 @@ router.get('/api/admin/users/:id', adminAuth, asyncHandler(async (req, res) => { }); })); -/** - * Scan the wallet for a recent OUTGOING transfer to this payout's address (and, - * when known, matching amount). Used before force-retrying a needs_review payout - * that has no recorded tx_hash — if the funds already left the wallet we must NOT - * re-send. Conservative: any RPC failure is treated as "not clean" so we never - * risk a silent double-pay. - * Returns { clean: boolean, txid?: string }. - */ -async function isRecentTransferClean(payout) { - try { - const resp = await walletRPCService.rpcCall('get_transfers', { - out: true, - pending: true, - pool: true, - account_index: walletRPCService.accountIndex || 0 - }); - const target = String(payout.payout_address || ''); - const amt = Number(payout.amount); - for (const bucket of ['out', 'pending', 'pool']) { - const list = resp.result?.[bucket]; - if (!Array.isArray(list)) continue; - for (const tx of list) { - const dests = Array.isArray(tx.destinations) ? tx.destinations : []; - for (const d of dests) { - if (d.address === target && (!Number.isFinite(amt) || amt <= 0 || Number(d.amount) === amt)) { - return { clean: false, txid: tx.txid }; - } - } - // Some transfers record the recipient at the top level without destinations. - if (target && tx.address === target) { - return { clean: false, txid: tx.txid }; - } - } - } - return { clean: true }; - } catch (err) { - // Can't verify -> assume NOT clean to avoid a double-pay. - return { clean: false, txid: null, error: err.message }; - } -} - // POST /api/admin/payouts/:id/retry - Manually retry a failed payout router.post('/api/admin/payouts/:id/retry', adminAuth, asyncHandler(async (req, res) => { const { id } = req.params; const confirm = req.query.confirm === 'true' || !!(req.body && req.body.confirm === true); + const payoutId = Number(id); + if (!Number.isSafeInteger(payoutId) || payoutId <= 0) { + throw new ValidationError('Payout id must be a positive integer'); + } const payoutResult = await db.query(` SELECT * FROM payouts WHERE id = $1 - `, [id]); + `, [payoutId]); if (!payoutResult.rows.length) { throw new NotFoundError('Payout not found'); @@ -941,67 +929,50 @@ router.post('/api/admin/payouts/:id/retry', adminAuth, asyncHandler(async (req, const payout = payoutResult.rows[0]; - if (payout.status === 'completed') { - throw new ValidationError('Payout already completed'); - } - - // M2: never allow a silent double-pay. Before resetting a payout to 'pending' - // (which requeues it for a fresh transfer) prove the funds have NOT already left - // the wallet. + // A transaction hash, processing claim, or needs_review state is evidence that a wallet call + // may have happened. "Not found" is not proof of non-broadcast, so these rows are deliberately + // reconciliation-only and can never be resent through this endpoint. if (payout.tx_hash) { - // A tx hash is recorded — verify it is NOT on-chain before retrying. - let txStatus; - try { - txStatus = await walletRPCService.checkTransactionStatus(payout.tx_hash); - } catch (err) { - // RPC uncertain — refuse rather than risk re-sending. - return res.status(409).json({ - success: false, - message: `Could not verify on-chain status of tx ${payout.tx_hash}: ${err.message}. Refusing to retry.` - }); - } - if (txStatus && txStatus.exists) { - // Tx is on-chain (confirmed or in mempool): the payout already went out. - return res.status(409).json({ - success: false, - message: `Payout tx ${payout.tx_hash} is already on-chain (confirmed=${txStatus.confirmed}, in_mempool=${txStatus.in_mempool}). Refusing to retry to avoid a double-pay.`, - onChain: true, - txStatus - }); - } - // Not found on-chain -> safe to reset for retry. - } else if (payout.status === 'needs_review') { - // No tx_hash but flagged for manual review: require an explicit confirm flag AND - // a clean recent-transfer check before allowing a resend. - if (!confirm) { - return res.status(400).json({ - success: false, - message: 'Payout is in needs_review with no tx_hash. Pass ?confirm=true (or {confirm:true}) to force a retry after manual review.', - requiresConfirm: true - }); - } - const clean = await isRecentTransferClean(payout); - if (!clean.clean) { - return res.status(409).json({ - success: false, - message: clean.error - ? `Refusing to retry: could not verify recent transfers (${clean.error}).` - : `Refusing to retry: found a recent outgoing transfer to ${payout.payout_address} (txid ${clean.txid}) that may already fulfill this payout.`, - onChain: !clean.error, - txid: clean.txid || undefined - }); - } + return res.status(409).json({ + success: false, + message: 'Payout has transaction evidence and requires manual reconciliation; it cannot be resent.' + }); + } + if (!['failed', 'permanently_failed'].includes(payout.status)) { + return res.status(409).json({ + success: false, + message: `Payout status ${payout.status} is not retryable. Only hashless failed rows may be requeued.` + }); + } + if (!confirm) { + return res.status(400).json({ + success: false, + message: 'Pass ?confirm=true (or {confirm:true}) after reviewing the failure before requeueing.', + requiresConfirm: true + }); } - // Reset status to pending for retry - await db.query(` - UPDATE payouts SET status = 'pending', last_error = 'Manual retry requested' WHERE id = $1 - `, [id]); + // The predicate repeats every safety condition so a concurrent worker/status change cannot be + // overwritten after the row was read. + const reset = await db.query(` + UPDATE payouts + SET status = 'pending', last_error = 'Manual retry requested after reviewed pre-broadcast failure' + WHERE id = $1 + AND status IN ('failed', 'permanently_failed') + AND tx_hash IS NULL + RETURNING id + `, [payoutId]); + if (reset.rowCount !== 1) { + return res.status(409).json({ + success: false, + message: 'Payout changed while it was being reviewed and was not requeued.' + }); + } res.json({ success: true, message: 'Payout queued for retry', - payoutId: id + payoutId }); })); diff --git a/src/routes/auth.js b/src/routes/auth.js index e097c11..0f72ce2 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -5,80 +5,195 @@ const express = require('express'); const crypto = require('crypto'); const asyncHandler = require('../middleware/asyncHandler'); -const { ValidationError, NotFoundError } = require('../utils/errors'); +const { AppError, ValidationError } = require('../utils/errors'); const { verifyNip98Event } = require('../utils/nip98'); +const AUTH_WINDOW_MS = 60 * 1000; +const AUTH_RATE_MAX_KEYS = 10000; + +function createIpRateLimiter({ max }) { + const hits = new Map(); + let lastSweep = 0; + + return (req, res, next) => { + const now = Date.now(); + if (now - lastSweep >= AUTH_WINDOW_MS) { + for (const [key, value] of hits) { + if (now - value.windowStart >= AUTH_WINDOW_MS) hits.delete(key); + } + lastSweep = now; + } + + const ip = req.ip || req.socket?.remoteAddress || 'unknown'; + let entry = hits.get(ip); + if (!entry || now - entry.windowStart >= AUTH_WINDOW_MS) { + if (!entry && hits.size >= AUTH_RATE_MAX_KEYS) { + res.setHeader('Retry-After', '60'); + return res.status(429).json({ error: 'Too many authentication attempts' }); + } + entry = { count: 0, windowStart: now }; + hits.set(ip, entry); + } + + entry.count += 1; + if (entry.count > max) { + const retryAfterSeconds = Math.max(1, Math.ceil( + (entry.windowStart + AUTH_WINDOW_MS - now) / 1000 + )); + res.setHeader('Retry-After', String(retryAfterSeconds)); + return res.status(429).json({ error: 'Too many authentication attempts' }); + } + return next(); + }; +} + module.exports = function createAuthRoutes(ctx) { - const { db } = ctx; + const { db, sessionManager } = ctx; + if (!db || typeof db.query !== 'function' || typeof db.withTransaction !== 'function') { + throw new TypeError('Smirk auth requires transactional database access'); + } + if (!sessionManager + || typeof sessionManager.generateSecureToken !== 'function' + || typeof sessionManager.disconnectUserSessions !== 'function') { + throw new TypeError('Smirk auth requires session revocation support'); + } const router = express.Router(); + const challengeRateLimit = createIpRateLimiter({ max: 10 }); + const verifyRateLimit = createIpRateLimiter({ max: 20 }); + + function unauthorized(message = 'Session ownership verification failed') { + return new AppError(message, { + statusCode: 403, + code: 'SESSION_OWNERSHIP_REQUIRED', + safeMessage: 'Session ownership verification failed.' + }); + } + + async function resolveSessionOwner(req, socketId) { + const token = req.get('X-Session-Token'); + if (!token) { + throw new AppError('Session token required', { + statusCode: 401, + code: 'SESSION_TOKEN_REQUIRED', + safeMessage: 'Session token required.' + }); + } + if (typeof token !== 'string' || token.length > 512) throw unauthorized(); + + const result = await db.query(` + SELECT id, socket_id, anon_token + FROM users + WHERE socket_id = $1 AND anon_token = $2 + LIMIT 1 + `, [socketId, token]); + if (result.rows.length !== 1) throw unauthorized(); + return { userId: result.rows[0].id, socketId, token }; + } /** - * Atomically consume a challenge nonce, single-use, bound to a socket. + * Atomically consume a challenge nonce, single-use, bound to the authenticated stable user. * A single UPDATE ... WHERE used=FALSE ... RETURNING flips FALSE->TRUE for exactly * one caller, so two concurrent requests can never both pass (no SELECT/UPDATE TOCTOU). * Returns true iff this call is the one that consumed a fresh, unexpired challenge. */ - async function consumeChallenge(challenge, socketId) { + async function consumeChallenge(challenge, session) { const consumed = await db.query(` UPDATE smirk_challenges SET used = TRUE - WHERE challenge = $1 AND socket_id = $2 AND used = FALSE AND expires_at > NOW() + WHERE challenge = $1 + AND user_id = $2 + AND socket_id = $3 + AND used = FALSE + AND expires_at > NOW() RETURNING id - `, [challenge, socketId]); + `, [challenge, session.userId, session.socketId]); return consumed.rows.length > 0; } /** - * Link a proven wallet key to the user resolved from socketId, then send the - * success response shape the client expects ({ success, linked }). Shared by the - * NIP-98 and legacy Ed25519 paths. + * Link a proven wallet key to the authenticated stable user. Wallet adoption is one + * transaction: lock/re-check the presented session, revoke the temporary session, + * rotate the wallet owner's bearer token, clear the displaced row, and assign the caller + * socket to exactly one user. Live cached sockets are disconnected after the HTTP response. */ - async function linkSmirkKey(provenKey, socketId, res) { - const userResult = await db.query(` - SELECT id FROM users WHERE socket_id = $1 - `, [socketId]); + async function linkSmirkKey(provenKey, session) { + return db.withTransaction(async (client) => { + const currentResult = await client.query(` + SELECT id + FROM users + WHERE id = $1 AND socket_id = $2 AND anon_token = $3 + FOR UPDATE + `, [session.userId, session.socketId, session.token]); + if (currentResult.rows.length !== 1) throw unauthorized(); - if (userResult.rows.length === 0) { - throw new NotFoundError('User session not found', { - safeMessage: 'No active session found. Please connect to the game first.' - }); - } + const existingLink = await client.query(` + SELECT id, socket_id, payout_address + FROM users + WHERE smirk_public_key = $1 AND id != $2 + LIMIT 1 + FOR UPDATE + `, [provenKey, session.userId]); - const userId = userResult.rows[0].id; + if (existingLink.rows.length > 0) { + const owner = existingLink.rows[0]; + const ownerToken = sessionManager.generateSecureToken(); + const revokedTemporaryToken = sessionManager.generateSecureToken(); - // Sign-in-with-wallet: if this proven wallet already owns a DIFFERENT account, that is a - // LOGIN to that account, not a hijack (ownership is cryptographically proven). Adopt it — - // re-point that user to the current socket and hand the client its session token so the - // browser re-establishes as that account. (Previously this threw "already linked to another - // account", which blocked a returning user from signing in on a fresh browser session.) - const existingLink = await db.query(` - SELECT id, anon_token, payout_address FROM users WHERE smirk_public_key = $1 AND id != $2 LIMIT 1 - `, [provenKey, userId]); + const revoked = await client.query(` + UPDATE users + SET socket_id = NULL, anon_token = $1, last_seen = NOW() + WHERE id = $2 AND socket_id = $3 AND anon_token = $4 + RETURNING id + `, [revokedTemporaryToken, session.userId, session.socketId, session.token]); + if (revoked.rows.length !== 1) throw unauthorized(); - if (existingLink.rows.length > 0) { - const owner = existingLink.rows[0]; - await db.query( - `UPDATE users SET socket_id = $1, last_seen = NOW() WHERE id = $2`, - [socketId, owner.id] - ); - return res.json({ - success: true, - linked: true, - adopted: true, - sessionToken: owner.anon_token || null, - address: owner.payout_address || null, - message: 'Signed in to your wallet-linked account.' - }); - } + const adopted = await client.query(` + UPDATE users + SET socket_id = $1, anon_token = $2, last_seen = NOW() + WHERE id = $3 AND smirk_public_key = $4 + RETURNING id, payout_address + `, [session.socketId, ownerToken, owner.id, provenKey]); + if (adopted.rows.length !== 1) { + throw new AppError('Wallet account adoption lost its ownership lock', { + statusCode: 409, + code: 'WALLET_ADOPTION_CONFLICT', + safeMessage: 'Wallet sign-in conflicted with another request. Please retry.' + }); + } - await db.query(` - UPDATE users SET smirk_public_key = $1 WHERE id = $2 - `, [provenKey, userId]); + const socketOwner = await client.query(` + SELECT id + FROM users + WHERE socket_id = $1 + FOR UPDATE + `, [session.socketId]); + if (socketOwner.rows.length !== 1 + || String(socketOwner.rows[0].id) !== String(owner.id)) { + throw new AppError('Adopted socket ownership is ambiguous', { + statusCode: 409, + code: 'SOCKET_OWNERSHIP_CONFLICT', + safeMessage: 'Wallet sign-in conflicted with another session. Please retry.' + }); + } - res.json({ - success: true, - linked: true, - message: 'Smirk wallet linked successfully' + return { + adopted: true, + currentUserId: session.userId, + ownerUserId: owner.id, + previousOwnerSocketId: owner.socket_id || null, + sessionToken: ownerToken, + address: adopted.rows[0].payout_address || null + }; + } + + const linked = await client.query(` + UPDATE users + SET smirk_public_key = $1 + WHERE id = $2 AND socket_id = $3 AND anon_token = $4 + RETURNING id + `, [provenKey, session.userId, session.socketId, session.token]); + if (linked.rows.length !== 1) throw unauthorized(); + return { adopted: false, currentUserId: session.userId }; }); } @@ -87,23 +202,30 @@ module.exports = function createAuthRoutes(ctx) { * Generate a challenge for Smirk wallet signature verification * Body: { socketId: string } */ -router.post('/api/auth/smirk/challenge', asyncHandler(async (req, res) => { +router.post('/api/auth/smirk/challenge', challengeRateLimit, asyncHandler(async (req, res) => { const { socketId } = req.body || {}; - if (!socketId || typeof socketId !== 'string') { + if (!socketId || typeof socketId !== 'string' || socketId.length > 255) { throw new ValidationError('Missing socketId', { safeMessage: 'socketId is required to generate a challenge.' }); } + const session = await resolveSessionOwner(req, socketId); + // Generate a cryptographically secure challenge const challenge = crypto.randomBytes(32).toString('hex'); - // Store challenge in database (expires in 5 minutes) - await db.query(` - INSERT INTO smirk_challenges (challenge, socket_id) - VALUES ($1, $2) - `, [challenge, socketId]); + // Store a stable user binding as well as the current socket. The user binding is what + // prevents a public Tavern/Match socket id from being used to attach an attacker's key. + const inserted = await db.query(` + INSERT INTO smirk_challenges (challenge, socket_id, user_id) + SELECT $1, $2, id + FROM users + WHERE id = $3 AND socket_id = $2 AND anon_token = $4 + RETURNING id + `, [challenge, socketId, session.userId, session.token]); + if (inserted.rows.length !== 1) throw unauthorized(); // Clean up old/expired challenges periodically await db.query(` @@ -124,15 +246,16 @@ router.post('/api/auth/smirk/challenge', asyncHandler(async (req, res) => { * - NIP-98: { socketId, event } -> nostr kind:27235 HTTP-auth event (preferred) * - Legacy: { socketId, challenge, publicKey, signature } -> Ed25519 challenge signing */ -router.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => { +router.post('/api/auth/smirk/verify', verifyRateLimit, asyncHandler(async (req, res) => { const body = req.body || {}; const { socketId } = body; - if (!socketId || typeof socketId !== 'string') { + if (!socketId || typeof socketId !== 'string' || socketId.length > 255) { throw new ValidationError('Missing socketId', { safeMessage: 'socketId is required.' }); } + const session = await resolveSessionOwner(req, socketId); // --------------------------------------------------------------------------- // NIP-98 path — a nostr event object is present. @@ -152,7 +275,7 @@ router.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => { } // Atomic single-use consume (bound to this socket) — prevents replay/double-use. - const consumed = await consumeChallenge(challenge, socketId); + const consumed = await consumeChallenge(challenge, session); if (!consumed) { throw new ValidationError('Invalid or expired challenge', { safeMessage: 'The challenge is invalid, expired, or has already been used.' @@ -176,7 +299,23 @@ router.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => { // NOTE: result.pubkey is a nostr x-only secp256k1 key (64-hex) — a DIFFERENT // key namespace than the legacy Ed25519 spend key. A NIP-98 login therefore // re-registers the account under the nostr key (acceptable per the cutover spec). - await linkSmirkKey(result.pubkey, socketId, res); + const outcome = await linkSmirkKey(result.pubkey, session); + if (outcome.adopted) { + res.json({ + success: true, + linked: true, + adopted: true, + sessionToken: outcome.sessionToken, + address: outcome.address, + message: 'Signed in to your wallet-linked account.' + }); + setImmediate(() => sessionManager.disconnectUserSessions( + [outcome.currentUserId, outcome.ownerUserId], + [socketId, outcome.previousOwnerSocketId].filter(Boolean) + )); + return; + } + res.json({ success: true, linked: true, message: 'Smirk wallet linked successfully' }); return; } @@ -192,7 +331,7 @@ router.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => { } // Consume the challenge single-use (atomic) before verifying the signature. - const consumed = await consumeChallenge(challenge, socketId); + const consumed = await consumeChallenge(challenge, session); if (!consumed) { throw new ValidationError('Invalid or expired challenge', { safeMessage: 'The challenge is invalid, expired, or has already been used.' @@ -222,13 +361,29 @@ router.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => { }); } - await linkSmirkKey(publicKey, socketId, res); + const outcome = await linkSmirkKey(publicKey, session); + if (outcome.adopted) { + res.json({ + success: true, + linked: true, + adopted: true, + sessionToken: outcome.sessionToken, + address: outcome.address, + message: 'Signed in to your wallet-linked account.' + }); + setImmediate(() => sessionManager.disconnectUserSessions( + [outcome.currentUserId, outcome.ownerUserId], + [socketId, outcome.previousOwnerSocketId].filter(Boolean) + )); + return; + } + res.json({ success: true, linked: true, message: 'Smirk wallet linked successfully' }); })); /** * GET /api/auth/smirk/status * Check if a session has a linked Smirk wallet. - * Query: socketId=string ; Auth: X-Session-Token header (fallback ?t=). + * Query: socketId=string ; Auth: X-Session-Token header. * * BOLA fix: previously this disclosed { linked, hasPayoutAddress } for ANY socketId * with no auth, letting anyone probe another player's wallet-link state. We now @@ -246,7 +401,7 @@ router.get('/api/auth/smirk/status', asyncHandler(async (req, res) => { }); } - const token = req.get('X-Session-Token') || req.query.t; + const token = req.get('X-Session-Token'); if (!token) { return res.status(401).json({ error: 'Session token required' }); } diff --git a/src/rpc/rpcService.js b/src/rpc/rpcService.js index 29037f3..57662e6 100644 --- a/src/rpc/rpcService.js +++ b/src/rpc/rpcService.js @@ -4,63 +4,250 @@ */ const axios = require('axios'); +const ChainProfile = require('../chain/chainProfile'); -const CONSOLE_LOGGING = true; // Temporarily enable for debugging +const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development'; +const KNOWN_NETWORKS = new Set(['mainnet', 'stagenet', 'testnet']); +const GENESIS_HASHES = Object.freeze({ + 'XMR:mainnet': '418015bb9ae982a1975da7d79277c2705727a56894ba0fb246adaabb1f4632e3', + 'XMR:stagenet': '76ee3cc98646292206cd3e86f74d88b4dcc1d937088645e9b0cbca84b7ce74eb', + 'XMR:testnet': '48ca7cd3c8de5b6a4d53d2861fbdaedca141553559f9be9520068053cda8430b', + 'WOW:mainnet': 'a3fd635dd5cb55700317783469ba749b5259f0eeac2420ab2c27eb3ff5ffdc5c' +}); +const HASH_PATTERN = /^[0-9a-f]{64}$/; + +function normalizeNetwork(value) { + const normalized = String(value || '').trim().toLowerCase(); + return KNOWN_NETWORKS.has(normalized) ? normalized : null; +} + +function expectedDaemonIdentity(cryptoType, network) { + const normalizedCrypto = String(cryptoType || 'WOW').trim().toUpperCase(); + const normalizedNetwork = normalizeNetwork(network) || 'mainnet'; + const effectiveNetwork = normalizedNetwork; + return { + cryptoType: normalizedCrypto, + network: effectiveNetwork, + targetSeconds: Math.round(ChainProfile.meanBlockTimeMsFor(normalizedCrypto) / 1000), + genesisHash: GENESIS_HASHES[`${normalizedCrypto}:${effectiveNetwork}`] || null + }; +} + +function daemonIdentityFromInfo(info = {}, genesisHeader = {}) { + let network = normalizeNetwork(info.nettype || info.network_type); + if (!network) { + if (info.stagenet === true) network = 'stagenet'; + else if (info.testnet === true) network = 'testnet'; + else if (info.mainnet === true) network = 'mainnet'; + } + + const rawTarget = info.target ?? info.block_target; + const targetSeconds = Number(rawTarget); + const normalizedTarget = Number.isSafeInteger(targetSeconds) && targetSeconds > 0 + ? targetSeconds + : null; + const genesisHash = String(genesisHeader?.hash || genesisHeader?.block_header?.hash || '') + .trim().toLowerCase(); + const knownGenesis = HASH_PATTERN.test(genesisHash) + ? Object.entries(GENESIS_HASHES).find(([, hash]) => hash === genesisHash) + : null; + // nettype/target are useful consistency signals, but only the pinned genesis hash + // distinguishes XMR mainnet from a Monero-derived mainnet without trusting daemon labels. + const cryptoType = knownGenesis ? knownGenesis[0].split(':')[0] : null; + + return { cryptoType, network, targetSeconds: normalizedTarget, genesisHash: genesisHash || null }; +} + +function verifyDaemonIdentity(info, genesisHeader, expected) { + const actual = daemonIdentityFromInfo(info, genesisHeader); + const height = Number(info.height ?? info.target_height); + const status = String(info.status || '').trim().toUpperCase(); + let reason = null; + const genesisStatus = String(genesisHeader?.status || '').trim().toUpperCase(); + if (status !== 'OK' || genesisStatus !== 'OK' + || !Number.isSafeInteger(height) || height <= 0 + || info.offline !== false + || info.untrusted !== false + || info.synchronized !== true + || genesisHeader?.untrusted !== false) { + reason = 'daemon_not_ready'; + } else if (!expected.genesisHash + || !actual.network || !actual.cryptoType || !actual.targetSeconds || !actual.genesisHash) { + reason = 'identity_unavailable'; + } else if (actual.genesisHash !== expected.genesisHash + || actual.cryptoType !== expected.cryptoType + || actual.targetSeconds !== expected.targetSeconds) { + reason = 'chain_mismatch'; + } else if (actual.network !== expected.network) { + reason = 'network_mismatch'; + } + return { + verified: reason === null, + expected: { ...expected }, + actual, + reason, + checkedAt: Date.now() + }; +} + +function endpointLabel(value) { + try { + const endpoint = new URL(value); + endpoint.username = ''; + endpoint.password = ''; + return endpoint.toString().replace(/\/$/, ''); + } catch (_) { + return '[invalid endpoint]'; + } +} class RPCService { - constructor() { - this.primaryEndpoint = process.env.PRIMARY_RPC_ENDPOINT || 'http://127.0.0.1:34568'; - this.fallbackEndpoint = process.env.FALLBACK_RPC_ENDPOINT || 'http://127.0.0.1:34568'; + constructor(options = {}) { + const env = options.env || process.env; + this.http = options.http || axios; + this.primaryEndpoint = options.primaryEndpoint || env.PRIMARY_RPC_ENDPOINT || 'http://127.0.0.1:34568'; + this.fallbackEndpoint = options.fallbackEndpoint || env.FALLBACK_RPC_ENDPOINT || 'http://127.0.0.1:34568'; this.currentEndpoint = this.primaryEndpoint; this.failoverActive = false; this.lastBlockHeight = 0; + this.healthy = false; + this.lastSuccessAt = 0; + this.lastFailureAt = 0; this.consecutiveFailures = 0; this.maxFailures = 3; + this.identityRequired = options.identityRequired === undefined + ? String(env.NODE_ENV || '').toLowerCase() === 'production' + : options.identityRequired === true; + this.expectedIdentity = expectedDaemonIdentity( + options.cryptoType || env.CRYPTO_TYPE || 'WOW', + options.network || env.MONERO_NETWORK || 'mainnet' + ); + this.identityMaxAgeMs = Math.max(1000, Number(options.identityMaxAgeMs) || 30000); + this.endpointIdentities = new Map(); + this.networkIdentity = { + verified: false, + expected: { ...this.expectedIdentity }, + actual: null, + reason: 'not_checked', + checkedAt: 0 + }; if (CONSOLE_LOGGING) { console.log(`🔗 RPC Service initialized`); - console.log(`Primary: ${this.primaryEndpoint}`); - console.log(`Fallback: ${this.fallbackEndpoint}`); + console.log(`Primary: ${endpointLabel(this.primaryEndpoint)}`); + console.log(`Fallback: ${endpointLabel(this.fallbackEndpoint)}`); } } + async _rawRpcCall(endpoint, method, params = {}, timeout = 10000) { + const response = await this.http.post(`${endpoint}/json_rpc`, { + jsonrpc: '2.0', + id: '0', + method, + params + }, { + headers: { 'Content-Type': 'application/json' }, + timeout + }); + const data = response && response.data; + if (!data || data.error || !Object.prototype.hasOwnProperty.call(data, 'result')) { + const message = data?.error?.message || `Invalid JSON-RPC response for ${method}`; + throw new Error(message); + } + if (method === 'getblockcount') { + const count = Number(data.result?.count); + if (!Number.isSafeInteger(count) || count <= 0) { + throw new Error('Daemon returned an invalid block height.'); + } + } + return data.result; + } + + _recordIdentity(endpoint, identity) { + this.endpointIdentities.set(endpoint, identity); + if (endpoint === this.currentEndpoint || identity.verified) { + this.networkIdentity = identity; + } + return identity; + } + + async _verifyEndpointIdentity(endpoint, { force = false } = {}) { + const cached = this.endpointIdentities.get(endpoint); + if (!force && cached?.verified && (Date.now() - cached.checkedAt) <= this.identityMaxAgeMs) { + return cached; + } + let identity; + try { + const [info, genesisHeader] = await Promise.all([ + this._rawRpcCall(endpoint, 'get_info', {}, 5000), + this._rawRpcCall(endpoint, 'get_block_header_by_height', { height: 0 }, 5000) + ]); + identity = this._recordIdentity( + endpoint, + verifyDaemonIdentity(info, genesisHeader, this.expectedIdentity) + ); + } catch (error) { + this._recordIdentity(endpoint, { + verified: false, + expected: { ...this.expectedIdentity }, + actual: null, + reason: 'rpc_unavailable', + checkedAt: Date.now() + }); + throw error; + } + if (!identity.verified) { + const error = new Error('Blockchain daemon identity does not match the configured chain/network.'); + error.code = 'DAEMON_IDENTITY_MISMATCH'; + throw error; + } + return identity; + } + + async _ensureCurrentIdentity() { + if (!this.identityRequired) return null; + const candidates = [this.currentEndpoint]; + const alternate = this.currentEndpoint === this.primaryEndpoint + ? this.fallbackEndpoint + : this.primaryEndpoint; + if (alternate && alternate !== this.currentEndpoint) candidates.push(alternate); + + let lastError = null; + for (const endpoint of candidates) { + try { + const identity = await this._verifyEndpointIdentity(endpoint); + if (endpoint !== this.currentEndpoint) { + this.currentEndpoint = endpoint; + this.failoverActive = endpoint !== this.primaryEndpoint; + } + return identity; + } catch (error) { + lastError = error; + } + } + this.healthy = false; + throw lastError || new Error('Blockchain daemon identity could not be verified.'); + } + /** * Make RPC call with automatic failover */ async makeRPCCall(method, params = {}) { - const request = { - jsonrpc: '2.0', - id: '0', - method: method, - params: params - }; - try { - const response = await axios.post(`${this.currentEndpoint}/json_rpc`, request, { - headers: { 'Content-Type': 'application/json' }, - timeout: 10000 - }); - - if (response.data.error) { - throw new Error(`RPC Error: ${response.data.error.message}`); - } + await this._ensureCurrentIdentity(); + const result = await this._rawRpcCall(this.currentEndpoint, method, params, 10000); // Reset failure counter on success this.consecutiveFailures = 0; + this.healthy = true; + this.lastSuccessAt = Date.now(); - // Switch back to primary if we were using fallback - if (this.failoverActive && this.currentEndpoint !== this.primaryEndpoint) { - if (CONSOLE_LOGGING) { - console.log('🔄 Switching back to primary RPC endpoint'); - } - this.currentEndpoint = this.primaryEndpoint; - this.failoverActive = false; - } - - return response.data.result; + return result; } catch (error) { this.consecutiveFailures++; + this.healthy = false; + this.lastFailureAt = Date.now(); if (CONSOLE_LOGGING) { console.error(`❌ RPC call failed (${this.consecutiveFailures}/${this.maxFailures}):`, error.message); @@ -92,29 +279,23 @@ class RPCService { this.consecutiveFailures = 0; try { - const request = { - jsonrpc: '2.0', - id: '0', - method: method, - params: params - }; - - const response = await axios.post(`${this.currentEndpoint}/json_rpc`, request, { - headers: { 'Content-Type': 'application/json' }, - timeout: 10000 - }); - - if (response.data.error) { - throw new Error(`RPC Error: ${response.data.error.message}`); + if (this.identityRequired) { + await this._verifyEndpointIdentity(this.currentEndpoint, { force: true }); } + const result = await this._rawRpcCall(this.currentEndpoint, method, params, 10000); if (CONSOLE_LOGGING) { console.log('✅ Failover successful'); } - return response.data.result; + this.healthy = true; + this.lastSuccessAt = Date.now(); + + return result; } catch (error) { + this.healthy = false; + this.lastFailureAt = Date.now(); if (CONSOLE_LOGGING) { console.error('❌ Failover also failed:', error.message); } @@ -123,21 +304,32 @@ class RPCService { } /** - * Get current block height + * Get a fresh daemon block count or throw. Financial fairness callers must never substitute + * lastBlockHeight because a stale count cannot prove that a committed future block did not + * exist yet. + */ + async getBlockCountStrict() { + const result = await this.makeRPCCall('getblockcount'); + const blockCount = Number(result?.count); + if (!Number.isSafeInteger(blockCount) || blockCount < 1) { + throw new Error('Daemon returned an invalid block count'); + } + if (blockCount !== this.lastBlockHeight) { + this.lastBlockHeight = blockCount; + if (CONSOLE_LOGGING) { + console.log(`📊 Block height updated: ${blockCount}`); + } + } + return blockCount; + } + + /** + * Get current block count, retaining the historical cached fallback for non-financial UI and + * polling callers. Paid match fairness uses getBlockCountStrict() directly. */ async getBlockHeight() { try { - const result = await this.makeRPCCall('getblockcount'); - const blockHeight = result.count; - - if (blockHeight !== this.lastBlockHeight) { - this.lastBlockHeight = blockHeight; - if (CONSOLE_LOGGING) { - console.log(`📊 Block height updated: ${blockHeight}`); - } - } - - return blockHeight; + return await this.getBlockCountStrict(); } catch (error) { if (CONSOLE_LOGGING) { console.error('❌ Failed to get block height:', error.message); @@ -173,8 +365,8 @@ class RPCService { return { blockHeight: blockCount.count, difficulty: networkInfo.difficulty, - hashRate: networkInfo.difficulty / 120, // Approximate hash rate - networkType: networkInfo.mainnet ? 'mainnet' : 'testnet', + hashRate: networkInfo.difficulty / this.expectedIdentity.targetSeconds, + networkType: daemonIdentityFromInfo(networkInfo).network, status: networkInfo.status }; } catch (error) { @@ -190,46 +382,67 @@ class RPCService { */ async healthCheck() { const checks = { - primary: { endpoint: this.primaryEndpoint, status: 'unknown', responseTime: 0 }, - fallback: { endpoint: this.fallbackEndpoint, status: 'unknown', responseTime: 0 }, - current: this.currentEndpoint, + primary: { endpoint: endpointLabel(this.primaryEndpoint), status: 'unknown', responseTime: 0 }, + fallback: { endpoint: endpointLabel(this.fallbackEndpoint), status: 'unknown', responseTime: 0 }, + current: endpointLabel(this.currentEndpoint), failoverActive: this.failoverActive, consecutiveFailures: this.consecutiveFailures }; - // Test primary endpoint - try { + const probe = async (endpoint) => { const start = Date.now(); - await axios.post(`${this.primaryEndpoint}/json_rpc`, { - jsonrpc: '2.0', - id: '0', - method: 'getblockcount' - }, { timeout: 5000 }); - checks.primary.status = 'healthy'; - checks.primary.responseTime = Date.now() - start; - } catch (error) { - checks.primary.status = 'unhealthy'; - checks.primary.error = error.message; + try { + const count = await this._rawRpcCall(endpoint, 'getblockcount', {}, 5000); + if (!Number.isSafeInteger(Number(count?.count)) || Number(count.count) <= 0) { + throw new Error('Daemon returned an invalid block height.'); + } + let identity = null; + try { + identity = await this._verifyEndpointIdentity(endpoint, { force: true }); + } catch (error) { + identity = this.endpointIdentities.get(endpoint) || null; + if (this.identityRequired) throw error; + } + return { + endpoint: endpointLabel(endpoint), + status: 'healthy', + responseTime: Date.now() - start, + identityVerified: identity?.verified === true, + network: identity?.actual?.network || null, + cryptoType: identity?.actual?.cryptoType || null + }; + } catch (_) { + return { + endpoint: endpointLabel(endpoint), + status: 'unhealthy', + responseTime: Date.now() - start, + identityVerified: false + }; + } + }; + + checks.primary = await probe(this.primaryEndpoint); + if (this.fallbackEndpoint !== this.primaryEndpoint) { + checks.fallback = await probe(this.fallbackEndpoint); + } else { + checks.fallback = { ...checks.primary }; } - // Test fallback endpoint (if different) - if (this.fallbackEndpoint !== this.primaryEndpoint) { - try { - const start = Date.now(); - await axios.post(`${this.fallbackEndpoint}/json_rpc`, { - jsonrpc: '2.0', - id: '0', - method: 'getblockcount' - }, { timeout: 5000 }); - checks.fallback.status = 'healthy'; - checks.fallback.responseTime = Date.now() - start; - } catch (error) { - checks.fallback.status = 'unhealthy'; - checks.fallback.error = error.message; - } - } else { - checks.fallback = checks.primary; // Same endpoint + checks.healthy = checks.primary.status === 'healthy' || checks.fallback.status === 'healthy'; + if (checks.healthy) { + const selectedEndpoint = checks.primary.status === 'healthy' + ? this.primaryEndpoint + : this.fallbackEndpoint; + this.currentEndpoint = selectedEndpoint; + this.failoverActive = selectedEndpoint !== this.primaryEndpoint; + this.networkIdentity = this.endpointIdentities.get(selectedEndpoint) || this.networkIdentity; } + checks.current = endpointLabel(this.currentEndpoint); + checks.failoverActive = this.failoverActive; + checks.identity = this.getIdentityStatus(); + this.healthy = checks.healthy; + if (checks.healthy) this.lastSuccessAt = Date.now(); + else this.lastFailureAt = Date.now(); return checks; } @@ -239,10 +452,34 @@ class RPCService { */ getStatus() { return { - currentEndpoint: this.currentEndpoint, + currentEndpoint: endpointLabel(this.currentEndpoint), failoverActive: this.failoverActive, consecutiveFailures: this.consecutiveFailures, - lastBlockHeight: this.lastBlockHeight + lastBlockHeight: this.lastBlockHeight, + healthy: this.healthy, + lastSuccessAt: this.lastSuccessAt, + lastFailureAt: this.lastFailureAt, + identity: this.getIdentityStatus() + }; + } + + getIdentityStatus() { + const identity = this.networkIdentity || {}; + const verified = identity.verified === true + && (Date.now() - Number(identity.checkedAt || 0)) <= this.identityMaxAgeMs; + return { + required: this.identityRequired, + verified, + expected: { + cryptoType: this.expectedIdentity.cryptoType, + network: this.expectedIdentity.network + }, + actual: identity.actual ? { + cryptoType: identity.actual.cryptoType, + network: identity.actual.network + } : null, + reason: identity.reason || 'not_checked', + checkedAt: Number(identity.checkedAt || 0) }; } @@ -264,3 +501,6 @@ class RPCService { } module.exports = RPCService; +module.exports.endpointLabel = endpointLabel; +module.exports.GENESIS_HASHES = GENESIS_HASHES; +module.exports.verifyDaemonIdentity = verifyDaemonIdentity; diff --git a/src/scripts/migrate-disposable-clone.js b/src/scripts/migrate-disposable-clone.js new file mode 100644 index 0000000..66fb401 --- /dev/null +++ b/src/scripts/migrate-disposable-clone.js @@ -0,0 +1,255 @@ +#!/usr/bin/env node +'use strict'; + +/* + * Apply the application's normal migrations to an already-created disposable database clone. + * + * This command intentionally cannot create, drop, restore, audit, or activate anything. Its + * target is supplied only through the inherited environment; accepting command-line options + * would make it too easy to place database credentials in argv or shell history. + */ + +const fs = require('fs'); +const path = require('path'); +const { Pool } = require('pg'); +const DatabaseManager = require('../db/databaseManager'); + +const CONFIRM_PREFIX = 'MIGRATE_DISPOSABLE_CLONE:'; +const MINIMUM_RELEASE_MIGRATION = 40; +const MIGRATIONS_PATH = path.join(__dirname, '..', 'migrations'); +const DATABASE_NAME = /^[a-z][a-z0-9_]{2,62}$/; +const DATABASE_USER = /^[a-z_][a-z0-9_]{0,62}$/; +const MIGRATION_FILE = /^(\d{3})_[a-z0-9][a-z0-9_-]*\.sql$/; +const ALLOWED_PRODUCT_PREFIXES = new Set(['monerogue', 'wownerogue', 'wowngeon']); +const DISPOSABLE_TOKENS = new Set(['clone', 'restore', 'canary']); +const LIVE_TOKENS = new Set([ + 'active', 'current', 'live', 'mainnet', 'master', 'primary', 'prd', 'prod', 'production' +]); +const LOCAL_TCP_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']); +const LOCAL_SOCKET_DIRS = new Set(['/run/postgresql', '/var/run/postgresql']); + +class CloneMigrationRefusal extends Error { + constructor(message) { + super(message); + this.name = 'CloneMigrationRefusal'; + } +} + +class CloneMigrationVerificationError extends Error { + constructor(message) { + super(message); + this.name = 'CloneMigrationVerificationError'; + } +} + +function invariant(condition, message, ErrorType = CloneMigrationRefusal) { + if (!condition) throw new ErrorType(message); +} + +function cloneMigrationConfirmation(databaseName) { + return `${CONFIRM_PREFIX}${databaseName}`; +} + +function isLocalDatabaseHost(host) { + const value = String(host || '').trim(); + if (LOCAL_TCP_HOSTS.has(value.toLowerCase())) return true; + return LOCAL_SOCKET_DIRS.has(value); +} + +function isDisposableCloneDatabaseName(databaseName) { + const name = String(databaseName || ''); + if (!DATABASE_NAME.test(name)) return false; + + const tokens = name.split('_'); + if (!ALLOWED_PRODUCT_PREFIXES.has(tokens[0])) return false; + if (tokens.some(token => [...LIVE_TOKENS].some(live => token.startsWith(live)))) return false; + return tokens.some(token => DISPOSABLE_TOKENS.has(token)); +} + +function validateCloneMigrationEnvironment(env = process.env, argv = []) { + invariant(Array.isArray(argv) && argv.length === 0, + 'command-line arguments are forbidden; use a protected inherited environment'); + invariant(!String(env.DATABASE_URL || '').trim(), + 'DATABASE_URL is forbidden because it is ambiguous and may contain credentials'); + + const database = String(env.DB_NAME || '').trim(); + const expectedDatabase = String(env.CLONE_MIGRATION_EXPECT_DATABASE || '').trim(); + invariant(isDisposableCloneDatabaseName(database), + 'DB_NAME must be a Wowngeon clone, restore, or canary name and must not look live'); + invariant(expectedDatabase === database, + 'CLONE_MIGRATION_EXPECT_DATABASE must exactly equal DB_NAME'); + invariant(env.CLONE_MIGRATION_CONFIRM === cloneMigrationConfirmation(database), + `CLONE_MIGRATION_CONFIRM must exactly bind the disposable confirmation to ${database}`); + + const host = String(env.DB_HOST || '').trim(); + invariant(isLocalDatabaseHost(host), + 'DB_HOST must be loopback or a standard local PostgreSQL socket directory'); + + const portText = String(env.DB_PORT || '').trim(); + const port = Number(portText); + invariant(/^\d{1,5}$/.test(portText) && Number.isInteger(port) && port >= 1 && port <= 65535, + 'DB_PORT must be an explicit TCP port from 1 through 65535'); + + const user = String(env.DB_USER || '').trim(); + invariant(DATABASE_USER.test(user), 'DB_USER must be an explicit simple PostgreSQL role name'); + + return Object.freeze({ database, expectedDatabase, host, port, user }); +} + +function discoverMigrationManifest(migrationsPath = MIGRATIONS_PATH) { + const entries = fs.readdirSync(migrationsPath, { withFileTypes: true }); + const files = entries + .filter(entry => entry.name.endsWith('.sql')) + .map(entry => { + invariant(entry.isFile(), `migration entry is not a regular file: ${entry.name}`, + CloneMigrationVerificationError); + const match = MIGRATION_FILE.exec(entry.name); + invariant(match, `migration filename is not canonical: ${entry.name}`, + CloneMigrationVerificationError); + return { filename: entry.name, ordinal: Number(match[1]) }; + }) + .sort((left, right) => left.filename < right.filename ? -1 : left.filename > right.filename ? 1 : 0); + + invariant(files.length > 0, 'no repository migrations were found', + CloneMigrationVerificationError); + files.forEach((file, index) => { + invariant(file.ordinal === index + 1, + `repository migration sequence is not contiguous at ${file.filename}`, + CloneMigrationVerificationError); + }); + + const latest = files[files.length - 1]; + invariant(latest.ordinal >= MINIMUM_RELEASE_MIGRATION, + `repository schema is older than required migration ${MINIMUM_RELEASE_MIGRATION}`, + CloneMigrationVerificationError); + + return Object.freeze({ + files: Object.freeze(files.map(file => file.filename)), + latestFilename: latest.filename, + latestOrdinal: latest.ordinal + }); +} + +function verifyExactMigrationLedger(rows, manifest) { + invariant(Array.isArray(rows), 'schema_migrations query did not return rows', + CloneMigrationVerificationError); + const actual = rows.map(row => String(row?.filename || '')); + invariant(actual.length === manifest.files.length, + `migration ledger count ${actual.length} does not match repository count ${manifest.files.length}`, + CloneMigrationVerificationError); + manifest.files.forEach((filename, index) => { + invariant(actual[index] === filename, + `migration ledger differs from the repository at ${filename}`, + CloneMigrationVerificationError); + }); + invariant(actual[actual.length - 1] === manifest.latestFilename, + `migration ledger does not end at ${manifest.latestFilename}`, + CloneMigrationVerificationError); + return true; +} + +function poolConfiguration(target, env) { + const config = { + host: target.host, + port: target.port, + database: target.database, + user: target.user, + max: 1, + idleTimeoutMillis: 5000, + connectionTimeoutMillis: 5000, + application_name: 'wowngeon-disposable-clone-migrations' + }; + // A protected PGPASSFILE/PGPASSWORD can be used without this application-specific variable. + // If the protected service environment already supplies DB_PASSWORD, keep it in memory only. + if (typeof env.DB_PASSWORD === 'string' && env.DB_PASSWORD.length > 0) { + config.password = env.DB_PASSWORD; + } + return config; +} + +async function readExactDatabaseName(pool) { + const client = await pool.connect(); + try { + const result = await client.query('SELECT current_database() AS database_name'); + invariant(result?.rows?.length === 1, + 'PostgreSQL returned an ambiguous database identity', + CloneMigrationVerificationError); + return String(result.rows[0].database_name || ''); + } finally { + client.release(); + } +} + +async function runCloneMigrations({ + env = process.env, + argv = process.argv.slice(2), + poolFactory = config => new Pool(config), + DatabaseManagerClass = DatabaseManager, + migrationsPath = MIGRATIONS_PATH, + logger = console +} = {}) { + // Every filesystem and connection action occurs after all static target gates pass. + const target = validateCloneMigrationEnvironment(env, argv); + const manifest = discoverMigrationManifest(migrationsPath); + const pool = poolFactory(poolConfiguration(target, env)); + + try { + const actualDatabase = await readExactDatabaseName(pool); + invariant(actualDatabase === target.expectedDatabase, + 'connected PostgreSQL database does not match the exact expected clone name', + CloneMigrationVerificationError); + + // This is deliberately the same implementation used during normal application startup: + // lexically ordered files, schema_migrations ledger, one transaction per new file. + const manager = new DatabaseManagerClass(); + manager.pool = pool; + await manager.runMigrations(); + + const ledger = await pool.query( + 'SELECT filename FROM schema_migrations ORDER BY filename ASC' + ); + verifyExactMigrationLedger(ledger?.rows, manifest); + + logger.info('clone_migration_status=ok'); + logger.info(`database=${target.database}`); + logger.info(`migration_count=${manifest.files.length}`); + logger.info(`latest_migration=${manifest.latestFilename}`); + return Object.freeze({ ...target, ...manifest }); + } finally { + await pool.end().catch(() => {}); + } +} + +async function main() { + try { + await runCloneMigrations(); + } catch (error) { + if (error instanceof CloneMigrationRefusal + || error instanceof CloneMigrationVerificationError) { + console.error(`REFUSED: ${error.message}`); + } else { + // Do not echo connection objects, URLs, environment values, or server diagnostics. + console.error('FAILED: disposable clone migration did not complete; no credentials were printed'); + } + process.exitCode = 1; + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + CloneMigrationRefusal, + CloneMigrationVerificationError, + MINIMUM_RELEASE_MIGRATION, + cloneMigrationConfirmation, + discoverMigrationManifest, + isDisposableCloneDatabaseName, + isLocalDatabaseHost, + poolConfiguration, + readExactDatabaseName, + runCloneMigrations, + validateCloneMigrationEnvironment, + verifyExactMigrationLedger +}; diff --git a/src/scripts/preflight.js b/src/scripts/preflight.js new file mode 100644 index 0000000..41bdb87 --- /dev/null +++ b/src/scripts/preflight.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Non-mutating production configuration preflight. + * + * This deliberately does not connect to PostgreSQL or either RPC. It catches dangerous operator + * intent before a deploy; the server's normal startup then verifies live dependencies. + */ + +require('dotenv').config({ path: process.env.DOTENV_CONFIG_PATH || undefined }); + +const PaymentConfigManager = require('../config/paymentConfig'); +const EnvironmentValidator = require('../config/environmentValidator'); + +try { + const paymentConfig = new PaymentConfigManager({ logger: console }); + const result = new EnvironmentValidator({ logger: console }).assertValid(paymentConfig.getConfig()); + const summary = paymentConfig.summarize(); + + console.log('\n✅ Production configuration preflight passed'); + console.log(` chain: ${process.env.CRYPTO_TYPE || 'WOW'} / ${process.env.MONERO_NETWORK || 'mainnet'}`); + console.log(` free play: ${process.env.FREE_PLAY_ENABLED === 'true' || !summary.paymentsEnabled ? 'enabled' : 'disabled'}`); + console.log(` paid modes: ${[summary.directEnabled && 'direct', summary.creditsEnabled && 'credits'].filter(Boolean).join(', ') || 'none'}`); + console.log(` payout dispatch: ${result.money.payoutsEnabled ? 'enabled' : 'disabled'}`); + process.exitCode = 0; +} catch (error) { + console.error(`\n❌ Production configuration preflight failed: ${error.message}`); + process.exitCode = 1; +} diff --git a/src/scripts/pvp-capture.js b/src/scripts/pvp-capture.js new file mode 100644 index 0000000..4cf3b4e --- /dev/null +++ b/src/scripts/pvp-capture.js @@ -0,0 +1,1014 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Deterministic, free-only PvP capture harness. + * + * This is deliberately a separate loopback-only process. It does not import the application, + * database, queues, wallet, or payment services, and it cannot attach to an existing deployment. + * The real MatchRoom/MatchEngine and browser render kit are reused so captures exercise the same + * multiplayer simulation and visuals without creating any financial state. + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const { Server: SocketIOServer } = require('socket.io'); +const { io: createSocketClient } = require('socket.io-client'); + +const MatchRoom = require('../multiplayer/MatchRoom'); +const MatchEngine = require('../multiplayer/MatchEngine'); +const { defineRuleset, resolveMatchRuleset } = require('../game/rulesets'); + +const LOOPBACK_HOST = '127.0.0.1'; +const SAFETY_CONFIRMATION = '--confirm-local-free-only'; +// Curated deterministic seed: with the default four bots, bot-1 takes the treasure route and +// escapes on tick 44. That produces a concise beginning/middle/win arc for a short promo clip. +const DEFAULT_SEED = 'beb270f3806a97e9ef73c8f83a6eae19a92f90ab38af9ad8b365cb74c41b2702'; +const CAPTURE_RULESET_IDS = Object.freeze(['race', 'last-alive', 'score-attack', 'coop-escape']); +// MatchRoom's difficulty stack intentionally accepts operator overrides. A marketing replay must +// not: otherwise the same seed could silently produce different maps, monsters, or treasure when +// invoked from two shells. Refuse every gameplay-affecting inherited override instead of trying +// to guess which production profile the caller intended. +const CAPTURE_STATE_ENV_KEYS = Object.freeze([ + 'CRYPTO_TYPE', + 'DIFFICULTY_PRESET', + 'GAME_MODE', + 'NETWORK_TUNING_DISABLED', + 'DUNGEON_WIDTH', + 'DUNGEON_HEIGHT', + 'DUNGEON_DUG_PERCENTAGE', + 'DUNGEON_ROOM_WIDTH_MIN', + 'DUNGEON_ROOM_WIDTH_MAX', + 'DUNGEON_ROOM_HEIGHT_MIN', + 'DUNGEON_ROOM_HEIGHT_MAX', + 'DUNGEON_CORRIDOR_MIN', + 'DUNGEON_CORRIDOR_MAX', + 'DUNGEON_LEVELS', + 'MONSTER_SPEED', + 'MONSTER_CHASE', + 'MONSTER_VISION', + 'MONSTER_DISTANCE', + 'TREASURE_ROOM_POSITION', + 'TREASURE_EXIT_DISTANCE' +]); +const BOT_NAMES = ['Rook', 'Nyx', 'Moss', 'Ember', 'Rune', 'Vale', 'Kite', 'Ash']; +const BOT_AVATARS = [ + 'char-ranger', 'char-rogue', 'char-barbarian', 'char-wizard', + 'char-monk', 'char-bard', 'char-villager', 'char-goblin' +]; +const DIRECTIONS = Object.freeze([ + Object.freeze({ dx: 1, dy: 0 }), + Object.freeze({ dx: 0, dy: 1 }), + Object.freeze({ dx: -1, dy: 0 }), + Object.freeze({ dx: 0, dy: -1 }) +]); +const HTML_ROOT = path.resolve(__dirname, '../../html'); + +function helpText() { + return `Wowngeon deterministic PvP ad-video harness + +Usage: + NODE_ENV=development node scripts/pvp-capture.js ${SAFETY_CONFIRMATION} [options] + +Safety: + Runs a separate server bound only to 127.0.0.1, creates only an in-memory FREE match, + and refuses production or any environment with payment, wallet, payout, or crypto switches. + It never connects to a deployed app, database, daemon, wallet, or payment provider. + +Options: + --help Show this help without starting anything + ${SAFETY_CONFIRMATION} Required explicit safety acknowledgement + --seed HEX64 Deterministic 64-character hex seed + --players N Bot count, 2-8 (default: 4) + --ticks N Hard capture limit, 1-2000 (default: 180) + --tick-ms N Real-time frame step, 50-1000 (default: 180) + --ruleset ID race, last-alive, score-attack, or coop-escape (default: race) + --mode MODE tiles, ascii, or iso (default: tiles) + --focus BOT_ID Follow-camera anchor player (default: bot-1) + --camera MODE action or focus (default: action) + --viewport WIDTHxHEIGHT Video viewport (default: 1280x720) + --output FILE.webm Video destination (default: /tmp/wowngeon-pvp-capture.webm) + --trace FILE.json Deterministic input/state trace destination + --screenshot FILE.png Optional final-frame screenshot + --headed Show the capture browser while recording + --no-video Run deterministic Socket.IO bot control and write only the trace + +Examples: + NODE_ENV=development npm run capture:pvp -- ${SAFETY_CONFIRMATION} + NODE_ENV=test node scripts/pvp-capture.js ${SAFETY_CONFIRMATION} --ruleset last-alive --no-video --ticks 80 +`; +} + +function needValue(argv, index, flag) { + const value = argv[index + 1]; + if (value == null || value.startsWith('--')) throw new Error(`${flag} requires a value`); + return value; +} + +function parseViewport(value) { + const match = /^(\d{3,4})x(\d{3,4})$/i.exec(String(value || '')); + if (!match) throw new Error('--viewport must look like 1280x720'); + const width = Number(match[1]); + const height = Number(match[2]); + if (width < 640 || width > 3840 || height < 360 || height > 2160) { + throw new Error('--viewport must be between 640x360 and 3840x2160'); + } + return { width, height }; +} + +function parseArgs(argv = []) { + const options = { + confirmed: false, + help: false, + seed: DEFAULT_SEED, + players: 4, + ticks: 180, + tickMs: 180, + ruleset: 'race', + mode: 'tiles', + focus: 'bot-1', + camera: 'action', + viewport: { width: 1280, height: 720 }, + output: path.join(os.tmpdir(), 'wowngeon-pvp-capture.webm'), + trace: null, + screenshot: null, + headed: false, + noVideo: false + }; + + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + if (flag === '--help' || flag === '-h') options.help = true; + else if (flag === SAFETY_CONFIRMATION) options.confirmed = true; + else if (flag === '--headed') options.headed = true; + else if (flag === '--no-video') options.noVideo = true; + else if (flag === '--seed') options.seed = needValue(argv, i++, flag).toLowerCase(); + else if (flag === '--players') options.players = Number(needValue(argv, i++, flag)); + else if (flag === '--ticks') options.ticks = Number(needValue(argv, i++, flag)); + else if (flag === '--tick-ms') options.tickMs = Number(needValue(argv, i++, flag)); + else if (flag === '--ruleset') options.ruleset = needValue(argv, i++, flag).toLowerCase(); + else if (flag === '--mode') options.mode = needValue(argv, i++, flag).toLowerCase(); + else if (flag === '--focus') options.focus = needValue(argv, i++, flag); + else if (flag === '--camera') options.camera = needValue(argv, i++, flag).toLowerCase(); + else if (flag === '--viewport') options.viewport = parseViewport(needValue(argv, i++, flag)); + else if (flag === '--output') options.output = path.resolve(needValue(argv, i++, flag)); + else if (flag === '--trace') options.trace = path.resolve(needValue(argv, i++, flag)); + else if (flag === '--screenshot') options.screenshot = path.resolve(needValue(argv, i++, flag)); + else throw new Error(`Unknown option: ${flag}`); + } + + options.trace = options.trace || options.output.replace(/\.webm$/i, '') + '.json'; + return options; +} + +function validateOptions(options) { + if (!/^[0-9a-f]{64}$/i.test(String(options.seed || ''))) { + throw new Error('--seed must be exactly 64 hexadecimal characters'); + } + for (const [key, min, max] of [['players', 2, 8], ['ticks', 1, 2000], ['tickMs', 50, 1000]]) { + if (!Number.isInteger(options[key]) || options[key] < min || options[key] > max) { + throw new Error(`--${key === 'tickMs' ? 'tick-ms' : key} must be an integer from ${min} through ${max}`); + } + } + if (!['tiles', 'ascii', 'iso'].includes(options.mode)) { + throw new Error('--mode must be tiles, ascii, or iso; network-loaded renderers are forbidden'); + } + if (!CAPTURE_RULESET_IDS.includes(options.ruleset)) { + throw new Error(`--ruleset must be one of: ${CAPTURE_RULESET_IDS.join(', ')}`); + } + const ruleset = resolveMatchRuleset(options.ruleset); + const minimumPlayers = Math.max(2, ruleset.players.min); + if (options.players < minimumPlayers || options.players > ruleset.players.max) { + throw new Error(`--players must satisfy ${options.ruleset}'s ${minimumPlayers}-${ruleset.players.max} player limit`); + } + if (!['action', 'focus'].includes(options.camera)) { + throw new Error('--camera must be action or focus'); + } + if (!/^bot-[1-8]$/.test(options.focus) || Number(options.focus.slice(4)) > options.players) { + throw new Error('--focus must identify one of the configured bots (for example bot-1)'); + } + if (!options.noVideo && !/\.webm$/i.test(options.output)) { + throw new Error('--output must end in .webm'); + } + if (!/\.json$/i.test(options.trace)) throw new Error('--trace must end in .json'); + if (options.screenshot && !/\.png$/i.test(options.screenshot)) { + throw new Error('--screenshot must end in .png'); + } + return options; +} + +function enabled(value) { + return /^(1|true|yes|on)$/i.test(String(value || '').trim()); +} + +function assertCaptureStateEnvironment(env = process.env) { + const configured = CAPTURE_STATE_ENV_KEYS.filter(key => String(env?.[key] || '').trim()); + if (configured.length) { + throw new Error( + `PvP capture refuses inherited gameplay tuning: ${configured.join(', ')}` + ); + } + return true; +} + +function assertCaptureSafety(env, options) { + if (!options.confirmed) { + throw new Error(`Refusing to start without ${SAFETY_CONFIRMATION}`); + } + const nodeEnv = String(env.NODE_ENV || '').trim().toLowerCase(); + if (!['development', 'test'].includes(nodeEnv)) { + throw new Error('PvP capture is allowed only with NODE_ENV=development or NODE_ENV=test'); + } + const dangerous = [ + 'PAYMENTS_ENABLED', 'DIRECT_PAYMENT_ENABLED', 'CREDITS_ENABLED', + 'PAYOUTS_ENABLED', 'DIRECT_PAYOUTS_ENABLED', 'CREDITS_PAYOUTS_ENABLED', 'CREDITS_PAYOUT_ENABLED', + 'MATCH_PAYOUTS_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', 'ALLOW_MAINNET_PAYOUTS' + ].filter(key => enabled(env[key])); + if (dangerous.length) { + throw new Error(`PvP capture refuses money-enabled environments: ${dangerous.join(', ')}`); + } + const walletConfiguration = [ + 'PRIMARY_WALLET_ENDPOINT', 'WALLET_RPC_URL', 'MONERO_WALLET_RPC_URL', 'WOW_WALLET_RPC_URL' + ].filter(key => String(env[key] || '').trim()); + if (walletConfiguration.length) { + throw new Error(`PvP capture refuses wallet-configured environments: ${walletConfiguration.join(', ')}`); + } + assertCaptureStateEnvironment(env); + return true; +} + +function captureRuleset(rulesetId = 'race') { + if (!CAPTURE_RULESET_IDS.includes(rulesetId)) { + throw new Error(`Unsupported capture ruleset: ${rulesetId}`); + } + const base = resolveMatchRuleset(rulesetId); + return defineRuleset({ + ...base, + economy: { ...base.economy, model: 'free', houseFeePercent: 0 }, + metadata: { + ...base.metadata, + captureFreeOnly: true + } + }); +} + +function capturePresentation(rulesetOrId = 'race') { + const id = typeof rulesetOrId === 'string' ? rulesetOrId : rulesetOrId?.id; + const presentations = { + race: { + id: 'race', title: 'ESCAPE RACE', eyebrow: 'LIVE DUNGEON SPORT', + participantNoun: 'RACERS', activeNoun: 'RACING', action: 'RACING TO THE EXIT', rosterAction: 'RACING', + footer: 'DETERMINISTIC SERVER-AUTHORITATIVE RACE' + }, + 'last-alive': { + id: 'last-alive', title: 'LAST ALIVE', eyebrow: 'LIVE DUNGEON COMBAT', + participantNoun: 'RIVALS', activeNoun: 'STANDING', action: 'HUNTING RIVALS', rosterAction: 'HUNTING', + footer: 'DETERMINISTIC SERVER-AUTHORITATIVE COMBAT' + }, + 'score-attack': { + id: 'score-attack', title: 'SCORE ATTACK', eyebrow: 'LIVE DUNGEON CHALLENGE', + participantNoun: 'CHALLENGERS', activeNoun: 'ACTIVE', action: 'BUILDING SCORE', rosterAction: 'SCORING', + footer: 'DETERMINISTIC SERVER-AUTHORITATIVE SCORE ATTACK' + }, + 'coop-escape': { + id: 'coop-escape', title: 'CO-OP ESCAPE', eyebrow: 'LIVE TEAM DUNGEON', + participantNoun: 'HEROES', activeNoun: 'IN DUNGEON', action: 'ESCAPING TOGETHER', rosterAction: 'ESCAPING', + footer: 'DETERMINISTIC SERVER-AUTHORITATIVE CO-OP' + } + }; + return presentations[id] || presentations.race; +} + +function captureEntrants(playerCount) { + const entrants = {}; + for (let index = 0; index < playerCount; index++) { + const id = `bot-${index + 1}`; + entrants[id] = { + userId: index + 1, + name: BOT_NAMES[index], + avatar: BOT_AVATARS[index], + appearance: { avatar: BOT_AVATARS[index] } + }; + } + return entrants; +} + +function createCaptureRoom(options) { + // Protect direct library callers too; runCapture validates its supplied environment, while + // the engine itself reads process.env dynamically. + assertCaptureStateEnvironment(process.env); + const room = new MatchRoom({ + id: `capture-${options.seed.slice(0, 16)}`, + economy: 'free', + ruleset: captureRuleset(options.ruleset), + maxPlayers: options.players, + entrants: captureEntrants(options.players), + seed: options.seed, + startBlockHeight: 1, + cryptoType: 'WOW', + entryFeeAtomic: 0, + potAtomic: 0, + houseFeeAtomic: 0, + houseFeePercent: 0 + }); + assertFreeRoom(room); + return room; +} + +function assertFreeRoom(room) { + if (!room || room.economy !== 'free') throw new Error('Capture invariant failed: room is not free'); + if (room.ruleset?.economy?.model !== 'free') { + throw new Error('Capture invariant failed: ruleset economy is not free'); + } + for (const field of ['entryFeeAtomic', 'potAtomic', 'houseFeeAtomic', 'houseFeePercent']) { + if (Number(room[field] || 0) !== 0) { + throw new Error(`Capture invariant failed: ${field} is non-zero`); + } + } + return true; +} + +function pointKey(x, y) { + return `${x},${y}`; +} + +function orderedDirections(botIndex) { + const start = botIndex % DIRECTIONS.length; + return DIRECTIONS.slice(start).concat(DIRECTIONS.slice(0, start)); +} + +function firstPathStep(grid, start, target, { botIndex = 0, blocked = new Set() } = {}) { + if (!start || !target || !Array.isArray(grid) || !grid.length) return null; + if (start.x === target.x && start.y === target.y) return null; + const queue = [start]; + const seen = new Set([pointKey(start.x, start.y)]); + const previous = new Map(); + const directions = orderedDirections(botIndex); + let found = false; + + while (queue.length && !found) { + const current = queue.shift(); + for (const direction of directions) { + const next = { x: current.x + direction.dx, y: current.y + direction.dy }; + const key = pointKey(next.x, next.y); + if (seen.has(key)) continue; + const tile = grid[next.y] && grid[next.y][next.x]; + const passable = tile === "'1" || tile === "'2" || tile === '>' || tile === '$M' || tile === 0; + if (!passable) continue; + if (blocked.has(key) && !(next.x === target.x && next.y === target.y)) continue; + seen.add(key); + previous.set(key, current); + if (next.x === target.x && next.y === target.y) { + found = true; + break; + } + queue.push(next); + } + } + + if (!found) return null; + let cursor = target; + let parent = previous.get(pointKey(cursor.x, cursor.y)); + while (parent && !(parent.x === start.x && parent.y === start.y)) { + cursor = parent; + parent = previous.get(pointKey(cursor.x, cursor.y)); + } + return { dx: Math.sign(cursor.x - start.x), dy: Math.sign(cursor.y - start.y) }; +} + +function planBotMove(botId, state) { + const players = Array.isArray(state?.players) ? state.players : []; + const self = players.find(player => player && player.id === botId); + if (!self || self.alive === false || self.finished) return { dx: 0, dy: 0 }; + const botIndex = Math.max(0, Number(botId.slice(4)) - 1); + const tick = Number(state.tick) || 0; + const rulesetId = state?.ruleset?.id || 'race'; + + // Bots move at distinct cadences. Besides keeping a crowded field readable on video, this + // prevents deterministic head-on PvP swaps from leaving two adjacent rivals bouncing forever. + if (botIndex > 0 && tick % (botIndex + 3) !== 0) return { dx: 0, dy: 0 }; + + let target = null; + let targetPlayerId = null; + if (rulesetId === 'last-alive') { + const rivals = players.filter(player => player && player.id !== botId && + player.alive !== false && !player.finished); + rivals.sort((a, b) => { + const aDistance = Math.abs(a.x - self.x) + Math.abs(a.y - self.y); + const bDistance = Math.abs(b.x - self.x) + Math.abs(b.y - self.y); + return aDistance - bDistance || a.id.localeCompare(b.id); + }); + if (rivals[0]) { + targetPlayerId = rivals[0].id; + target = { x: rivals[0].x, y: rivals[0].y }; + } + } else { + // Race and score-attack give the lead bot a cinematic treasure route. Co-op sends every + // hero directly toward extraction so the footage tells a clear team objective story. + const treasure = state.treasure; + if (rulesetId !== 'coop-escape' && botIndex === 0 && treasure && + treasure.carrierId == null && !self.hasTreasure) { + target = { x: treasure.x, y: treasure.y }; + } + if (!target && Array.isArray(state.exit)) target = { x: state.exit[0], y: state.exit[1] }; + } + if (!target) return { dx: 0, dy: 0 }; + + const blocked = new Set(); + for (const player of players) { + if (!player || player.id === botId || player.alive === false || player.finished) continue; + if (player.id === targetPlayerId) continue; + blocked.add(pointKey(player.x, player.y)); + } + if (state.monster) blocked.add(pointKey(state.monster.x, state.monster.y)); + + const start = { x: self.x, y: self.y }; + return firstPathStep(state.visibleTiles, start, target, { botIndex, blocked }) + || firstPathStep(state.visibleTiles, start, target, { botIndex, blocked: new Set() }) + || { dx: 0, dy: 0 }; +} + +function captureStateHash(state) { + return crypto.createHash('sha256').update(JSON.stringify(state)).digest('hex'); +} + +function capturePlayerSnapshot(room) { + return room.toGameState().players.map(player => { + const authoritative = room.playerStates.get(player.id) || {}; + return { + ...player, + score: Number(authoritative.score) || 0, + moves: Number(authoritative.moves) || 0, + killedBy: authoritative.killedBy || null + }; + }); +} + +function captureResultSummary(room, players = capturePlayerSnapshot(room)) { + const winner = players.find(player => player.id === room.winnerId) || null; + const winnerName = String(winner?.name || winner?.id || 'NO ONE').toUpperCase(); + const ticks = Number(room.tickCount) || 0; + const detail = `FREE EXHIBITION • ${ticks} TICK${ticks === 1 ? '' : 'S'}`; + + switch (room.ruleset?.id) { + case 'last-alive': { + const standing = players.filter(player => player.alive !== false && !player.finished); + const decisive = winner && standing.length === 1 && standing[0].id === winner.id; + const allOut = standing.length === 0; + return { + headline: decisive ? `${winnerName} IS LAST ALIVE` + : (winner ? (allOut ? `${winnerName} SURVIVED LONGEST` : `${winnerName} LEADS AT THE BELL`) + : 'NO ONE LEFT STANDING'), + detail, + cooperative: false, + winnerStatus: decisive ? 'WINNER' : (winner ? (allOut ? 'LAST OUT' : 'LEADER') : null) + }; + } + case 'score-attack': + return { + headline: winner ? `${winnerName} TOPS THE BOARD` : 'SCORE ATTACK COMPLETE', + detail: winner ? `${Number(winner.score) || 0} POINTS • ${detail}` : detail, + cooperative: false, + winnerStatus: winner ? 'TOP SCORE' : null + }; + case 'coop-escape': { + const escaped = players.filter(player => player.escaped).length; + const complete = escaped === players.length && players.length > 0; + return { + headline: complete ? 'TEAM ESCAPED TOGETHER' : `${escaped} OF ${players.length} ESCAPED`, + detail, + cooperative: true, + winnerStatus: null + }; + } + default: { + const decisive = room.endReason === 'escaped'; + return { + headline: winner ? (decisive ? `${winnerName} WINS THE RACE` : `${winnerName} LEADS AT THE BELL`) + : 'THE DUNGEON WINS', + detail, + cooperative: false, + winnerStatus: winner ? (decisive ? 'WINNER' : 'LEADER') : null + }; + } + } +} + +function mimeType(filename) { + const ext = path.extname(filename).toLowerCase(); + return ({ + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.json': 'application/json; charset=utf-8', + '.ttf': 'font/ttf', + '.woff': 'font/woff', + '.woff2': 'font/woff2' + })[ext] || 'application/octet-stream'; +} + +function capturePageHtml() { + return ` + + + +Wownerogue — Deterministic PvP Capture + + + + + + + + + + +
LIVE DUNGEON SPORT
WOWNEROGUE PVP
FREE EXHIBITIONTICK 000CONNECTING
+
+
DETERMINISTIC SERVER-AUTHORITATIVE PVP
+ +`; +} + +function safeStaticPath(urlPath) { + let decoded; + try { decoded = decodeURIComponent(urlPath); } catch (_) { return null; } + const relative = decoded.replace(/^\/+/, ''); + const resolved = path.resolve(HTML_ROOT, relative); + if (resolved !== HTML_ROOT && !resolved.startsWith(`${HTML_ROOT}${path.sep}`)) return null; + return resolved; +} + +async function createCaptureServer(room, token) { + const bots = new Map(); + const server = http.createServer((req, res) => { + const host = String(req.headers.host || '').split(':')[0].replace(/^\[|\]$/g, ''); + if (!['127.0.0.1', 'localhost', '::1'].includes(host)) { + res.writeHead(421, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Loopback host required'); + return; + } + const pathname = new URL(req.url, 'http://127.0.0.1').pathname; + if (pathname === '/' || pathname === '/capture') { + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff' + }); + res.end(capturePageHtml()); + return; + } + if (pathname === '/runtime-config.js') { + res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end('window.WOWNGEON_RUNTIME={rendererCdnEnabled:false};'); + return; + } + const filename = safeStaticPath(pathname); + if (!filename || !fs.existsSync(filename) || !fs.statSync(filename).isFile()) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + return; + } + res.writeHead(200, { 'Content-Type': mimeType(filename), 'Cache-Control': 'no-store' }); + fs.createReadStream(filename).pipe(res); + }); + const io = new SocketIOServer(server, { serveClient: true, transports: ['websocket'] }); + + io.use((socket, next) => { + const auth = socket.handshake.auth || {}; + if (auth.token !== token) return next(new Error('capture authentication failed')); + if (auth.role === 'spectator') return next(); + if (auth.role === 'bot' && room.occupants.has(auth.botId)) return next(); + return next(new Error('invalid capture role')); + }); + io.on('connection', socket => { + const auth = socket.handshake.auth || {}; + if (auth.role === 'bot') { + bots.set(auth.botId, socket); + socket.on('disconnect', () => { + if (bots.get(auth.botId) === socket) bots.delete(auth.botId); + }); + } else { + socket.join('capture-spectators'); + socket.on('capture_spectate', () => { + socket.emit('capture_state', { + state: room.toGameState(), + seedHash: room.seedHash, + presentation: capturePresentation(room.ruleset) + }); + socket.emit('capture_ready'); + }); + } + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, LOOPBACK_HOST, resolve); + }); + const address = server.address(); + return { + server, + io, + bots, + port: address.port, + url: `http://${LOOPBACK_HOST}:${address.port}`, + close: async () => { + await new Promise(resolve => io.close(resolve)); + if (server.listening) await new Promise(resolve => server.close(resolve)); + } + }; +} + +function connectBot(baseUrl, token, botId) { + const socket = createSocketClient(baseUrl, { + auth: { role: 'bot', botId, token }, + transports: ['websocket'], + reconnection: false, + forceNew: true + }); + socket.on('capture_input_request', payload => { + const move = planBotMove(botId, payload.state); + socket.emit('capture_input', { tick: payload.tick, dx: move.dx, dy: move.dy }); + }); + return socket; +} + +function waitForSocket(socket, timeoutMs = 5000) { + if (socket.connected) return Promise.resolve(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Socket.IO connection timed out')), timeoutMs); + socket.once('connect', () => { clearTimeout(timeout); resolve(); }); + socket.once('connect_error', error => { clearTimeout(timeout); reject(error); }); + }); +} + +function requestBotInput(socket, tick, state, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.off('capture_input', onInput); + reject(new Error(`Bot ${socket.handshake?.auth?.botId || socket.id} missed tick ${tick}`)); + }, timeoutMs); + function onInput(payload) { + if (!payload || payload.tick !== tick) return; + clearTimeout(timeout); + socket.off('capture_input', onInput); + const dx = Math.sign(Number(payload.dx) || 0); + const dy = Math.sign(Number(payload.dy) || 0); + if (Math.abs(dx) + Math.abs(dy) > 1) return reject(new Error(`Invalid bot input at tick ${tick}`)); + resolve({ dx, dy }); + } + socket.on('capture_input', onInput); + socket.emit('capture_input_request', { tick, state }); + }); +} + +async function launchRecorder(serverUrl, token, options) { + let playwright; + try { playwright = require('playwright-core'); } + catch (_) { + throw new Error('playwright-core is not installed; use --no-video for the control/trace harness'); + } + const executable = playwright.chromium.executablePath(); + if (!executable || !fs.existsSync(executable)) { + throw new Error('The installed Playwright Chromium executable is missing; use --no-video (no downloads are attempted)'); + } + const videoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wowngeon-pvp-video-')); + let browser = null; + let context = null; + try { + browser = await playwright.chromium.launch({ headless: !options.headed }); + context = await browser.newContext({ + viewport: options.viewport, + recordVideo: { dir: videoDir, size: options.viewport } + }); + const page = await context.newPage(); + const pageErrors = []; + page.on('pageerror', error => pageErrors.push(error)); + const captureUrl = `${serverUrl}/capture?token=${encodeURIComponent(token)}` + + `&mode=${encodeURIComponent(options.mode)}&focus=${encodeURIComponent(options.focus)}` + + `&camera=${encodeURIComponent(options.camera)}&ruleset=${encodeURIComponent(options.ruleset)}`; + await page.goto(captureUrl, { waitUntil: 'load' }); + await page.waitForFunction(() => window.__PVP_CAPTURE_READY__ === true || !!window.__PVP_CAPTURE_ERROR__, null, { timeout: 10000 }); + const captureError = await page.evaluate(() => window.__PVP_CAPTURE_ERROR__ || null); + if (captureError) throw new Error(`Capture page failed: ${captureError}`); + await page.waitForTimeout(600); + + const cleanup = () => { + // videoDir is an exact directory created by mkdtemp above; never accept a caller path. + fs.rmSync(videoDir, { recursive: true, force: true }); + }; + return { + page, + finish: async () => { + if (options.screenshot) { + fs.mkdirSync(path.dirname(options.screenshot), { recursive: true }); + await page.screenshot({ path: options.screenshot }); + } + const video = page.video(); + await page.close(); + await context.close(); + fs.mkdirSync(path.dirname(options.output), { recursive: true }); + await video.saveAs(options.output); + await browser.close(); + cleanup(); + if (pageErrors.length) throw pageErrors[0]; + return options.output; + }, + abort: async () => { + await context.close().catch(() => {}); + await browser.close().catch(() => {}); + cleanup(); + } + }; + } catch (error) { + if (context) await context.close().catch(() => {}); + if (browser) await browser.close().catch(() => {}); + fs.rmSync(videoDir, { recursive: true, force: true }); + throw error; + } +} + +function sleep(ms) { + return ms > 0 ? new Promise(resolve => setTimeout(resolve, ms)) : Promise.resolve(); +} + +function captureTraceHeader(room, presentation, options) { + return { + schemaVersion: 2, + harness: 'wowngeon-pvp-capture', + safety: { isolatedLoopback: true, economy: 'free', moneyServicesImported: false }, + determinism: { + gameplayEnvironmentOverrides: 'refused', + cryptoType: 'WOW', + seedControlsAuthoritativeSimulation: true + }, + seed: room.seed, + seedHash: room.seedHash, + roomId: room.id, + ruleset: room._rulesetSummary(), + presentation, + players: Array.from(room.occupants.values()).map(occupant => ({ + id: occupant.id, name: occupant.name, avatar: occupant.avatar + })), + tickMs: options.tickMs, + maxTicks: options.ticks, + camera: { + mode: options.camera, + focus: options.focus, + renderer: options.mode, + viewport: { ...options.viewport } + }, + ticks: [] + }; +} + +async function runCapture(options, env = process.env) { + validateOptions(options); + assertCaptureSafety(env, options); + if (env !== process.env) assertCaptureStateEnvironment(process.env); + const room = createCaptureRoom(options); + const token = crypto.randomBytes(24).toString('hex'); + const captureServer = await createCaptureServer(room, token); + const presentation = capturePresentation(room.ruleset); + const botClients = []; + let recorder = null; + const trace = captureTraceHeader(room, presentation, options); + + try { + for (const botId of room.occupants.keys()) { + const client = connectBot(captureServer.url, token, botId); + botClients.push(client); + } + await Promise.all(botClients.map(socket => waitForSocket(socket))); + if (captureServer.bots.size !== options.players) throw new Error('Not every deterministic bot connected'); + + if (!options.noVideo) recorder = await launchRecorder(captureServer.url, token, options); + room.start(); + assertFreeRoom(room); + captureServer.io.to('capture-spectators').emit('capture_state', { + state: room.toGameState(), seedHash: room.seedHash, presentation, events: [] + }); + + const engine = new MatchEngine({ room, tickMs: options.tickMs }); + for (let sequence = 1; sequence <= options.ticks && room.status === 'active'; sequence++) { + const state = room.toGameState(); + const botIds = Array.from(room.occupants.keys()); + const moves = await Promise.all(botIds.map(botId => { + const socket = captureServer.bots.get(botId); + if (!socket) throw new Error(`Bot disconnected: ${botId}`); + return requestBotInput(socket, sequence, state); + })); + const inputRecord = {}; + for (let index = 0; index < botIds.length; index++) { + const botId = botIds[index]; + const move = moves[index]; + inputRecord[botId] = move; + if (Math.abs(move.dx) + Math.abs(move.dy) === 1) room.queueMove(botId, move.dx, move.dy); + } + const result = engine.tick(); + const nextState = room.toGameState(); + trace.ticks.push({ + tick: result.tick, + inputs: inputRecord, + events: result.events, + stateHash: captureStateHash(nextState) + }); + captureServer.io.to('capture-spectators').emit('capture_state', { + state: nextState, seedHash: room.seedHash, presentation, events: result.events + }); + // High-score normally ends at its deadline. Once every entrant is dead or has banked + // an exit there are no legal inputs left, so this isolated exhibition may resolve its + // local deadline immediately instead of recording a long, motionless tail. + if (room.status === 'active' && room.ruleset.id === 'score-attack' && + room.activePlayerCount === 0) { + engine.expire('capture_resolved'); + } + await sleep(options.noVideo ? 0 : options.tickMs); + } + + if (room.status === 'active') { + engine.expire('capture_limit'); + } else { + room.finalize(); + } + assertFreeRoom(room); + const finalState = room.toGameState(); + const players = capturePlayerSnapshot(room); + const outcome = captureResultSummary(room, players); + trace.result = { + status: room.status, + reason: room.endReason, + winnerId: room.winnerId, + ticks: room.tickCount, + finalStateHash: captureStateHash(finalState), + outcome, + players: players.map(player => ({ + id: player.id, + placement: player.placement, + score: player.score, + moves: player.moves, + escaped: player.escaped, + hasTreasure: player.hasTreasure, + alive: player.alive, + killedBy: player.killedBy + })) + }; + captureServer.io.to('capture-spectators').emit('capture_state', { + state: finalState, seedHash: room.seedHash, presentation, events: [] + }); + captureServer.io.to('capture-spectators').emit('capture_end', { + winnerId: room.winnerId, + players, + presentation, + outcome + }); + await sleep(options.noVideo ? 0 : 1400); + + fs.mkdirSync(path.dirname(options.trace), { recursive: true }); + fs.writeFileSync(options.trace, `${JSON.stringify(trace, null, 2)}\n`, { mode: 0o600 }); + if (recorder) await recorder.finish(); + return { room, trace, output: options.noVideo ? null : options.output, tracePath: options.trace }; + } catch (error) { + if (recorder) await recorder.abort(); + throw error; + } finally { + for (const socket of botClients) socket.close(); + await captureServer.close(); + } +} + +async function main() { + let options; + try { + options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write(helpText()); + return; + } + const result = await runCapture(options); + process.stdout.write(`PvP capture complete: ruleset=${result.room.ruleset.id} winner=${result.room.winnerId || 'none'} ticks=${result.room.tickCount}\n`); + if (result.output) process.stdout.write(`Video: ${result.output}\n`); + process.stdout.write(`Trace: ${result.tracePath}\n`); + } catch (error) { + process.stderr.write(`PvP capture refused/failed: ${error.message}\n\n${helpText()}`); + process.exitCode = 1; + } +} + +if (require.main === module) main(); + +module.exports = { + CAPTURE_RULESET_IDS, + CAPTURE_STATE_ENV_KEYS, + DEFAULT_SEED, + LOOPBACK_HOST, + SAFETY_CONFIRMATION, + assertCaptureStateEnvironment, + assertCaptureSafety, + assertFreeRoom, + capturePageHtml, + capturePlayerSnapshot, + capturePresentation, + captureResultSummary, + captureRuleset, + captureStateHash, + captureTraceHeader, + createCaptureRoom, + firstPathStep, + helpText, + parseArgs, + parseViewport, + planBotMove, + runCapture, + validateOptions +}; diff --git a/src/scripts/stagenet-financial-canary.js b/src/scripts/stagenet-financial-canary.js new file mode 100644 index 0000000..e4a13ef --- /dev/null +++ b/src/scripts/stagenet-financial-canary.js @@ -0,0 +1,1568 @@ +#!/usr/bin/env node +'use strict'; + +/* + * Repository-owned Monerogue XMR stagenet financial canary. + * + * This is deliberately a one-transfer, one-game harness. It refuses public targets, + * mainnet, reused databases, self-payment, ambiguous live intent, and transfer retries. + * It never prints wallet addresses, session tokens, transaction hashes, or proof seeds. + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { createRequire } = require('module'); +const { + CANARY_HANDSHAKE_PROTOCOL, + computeProof, + equalProof, + normalizeIdentity, + readDatabaseIdentity, + readNonceFile +} = require('../services/canaryDatabaseIdentity'); + +const APP_ROOT = path.resolve(__dirname, '..'); +const FLOOR = new Set(["'1", "'2"]); +const STEP_VECTORS = Object.freeze([[1, 0], [-1, 0], [0, 1], [0, -1]]); +const LIVE_CONFIRM = 'I_UNDERSTAND_THIS_BROADCASTS_ONE_XMR_STAGENET_TRANSFER'; +const SAFE_PROFILE_CONFIRM = 'EASY_STATIC_MONSTER_ONE_LEVEL'; +const REQUIRED_MIGRATIONS = Object.freeze([ + '035_payment_entitlements_and_entry_evidence.sql', + '037_payment_receipt_evidence.sql', + '038_economic_identity_immutability.sql', + '039_unresolved_solo_payout_review.sql', + '040_paid_match_entropy_precommit.sql' +]); + +const SCENARIOS = Object.freeze({ + 'direct-2x': Object.freeze({ + id: 'direct-2x', + databaseTag: 'direct', + paymentType: 'single_game', + gameMode: 'PAID_SINGLE', + collectTreasure: false, + multiplier: 2, + payoutReason: 'escape', + scenarioConfirm: 'DIRECT_2X_ESCAPE' + }), + 'credits-3x': Object.freeze({ + id: 'credits-3x', + databaseTag: 'credits', + paymentType: 'credits_package', + gameMode: 'PAID_CREDITS', + collectTreasure: true, + multiplier: 3, + payoutReason: 'escape_with_treasure', + scenarioConfirm: 'CREDITS_PACKAGE_THEN_3X_TREASURE_ESCAPE' + }) +}); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function phase(message) { + process.stdout.write(`[stagenet-canary] ${message}\n`); +} + +function safeMessage(value) { + return String(value || 'unknown error') + .replace(/\b[1-9A-HJ-NP-Za-km-z]{90,120}\b/g, '[redacted-address]') + .replace(/\b[0-9a-f]{64}\b/gi, '[redacted-64-byte-value]') + .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+(?::[^\s/@]*)?@/gi, '$1[redacted]@') + .replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [redacted]') + .replace(/\b(password|token|authorization|seed|tx_?hash)\b["']?\s*[:=]\s*["']?[^\s,;}]+/gi, + '$1=[redacted]'); +} + +function isLoopback(hostname) { + const value = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase(); + return value === '127.0.0.1' || value === 'localhost' || value === '::1'; +} + +function assertLocalUrl(url, label, expectedPort = null) { + assert(url instanceof URL, `${label} must be a URL`); + assert(['http:', 'https:'].includes(url.protocol), `${label} must use HTTP(S)`); + assert(isLoopback(url.hostname), `${label} must name localhost explicitly`); + assert(!url.username && !url.password, `${label} must not embed credentials`); + assert(!url.search && !url.hash, `${label} must not contain a query or fragment`); + if (expectedPort !== null) { + assert(url.port === String(expectedPort), `${label} must use dedicated canary port ${expectedPort}`); + } +} + +function boundedPositiveInt(env, name, fallback, maximum) { + const value = Number(env[name] || fallback); + assert(Number.isSafeInteger(value) && value > 0, `${name} must be a positive integer`); + assert(value <= maximum, `${name} exceeds its safety bound of ${maximum}`); + return value; +} + +function optionalPositiveAtomic(env, name) { + const raw = String(env[name] || '').trim().replace(/_/g, ''); + if (!raw) return null; + assert(/^\d+$/.test(raw), `${name} must be a positive atomic-unit integer`); + const value = BigInt(raw); + assert(value > 0n && value <= BigInt(Number.MAX_SAFE_INTEGER), + `${name} must be positive and exactly representable by wallet RPC`); + return value; +} + +function readConfiguration(env = process.env) { + const mode = String(env.E2E_MODE || 'preflight').trim(); + assert(['preflight', 'database-preflight', 'live-stagenet'].includes(mode), + 'E2E_MODE must be preflight, database-preflight, or live-stagenet'); + + const scenarioName = String(env.E2E_SCENARIO || '').trim(); + const scenario = scenarioName ? SCENARIOS[scenarioName] : null; + if (scenarioName) assert(scenario, 'E2E_SCENARIO must be direct-2x or credits-3x'); + if (mode !== 'preflight') { + assert(scenario, 'E2E_SCENARIO is required for database and live modes'); + } + + let target; + try { + target = new URL(env.E2E_TARGET || 'http://127.0.0.1:3102'); + } catch (_) { + throw new Error('E2E_TARGET must be a valid URL'); + } + assertLocalUrl(target, 'E2E_TARGET', 3102); + assert(target.pathname === '/' || target.pathname === '', 'E2E_TARGET must not contain a path'); + + const packageId = String(env.E2E_CREDITS_PACKAGE_ID || 'small').trim(); + assert(/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(packageId), + 'E2E_CREDITS_PACKAGE_ID must be a simple product id'); + + return Object.freeze({ + mode, + scenario, + target, + packageId, + paymentTimeoutMs: boundedPositiveInt(env, 'E2E_PAYMENT_TIMEOUT_MS', 30 * 60 * 1000, 45 * 60 * 1000), + payoutTimeoutMs: boundedPositiveInt(env, 'E2E_PAYOUT_TIMEOUT_MS', 15 * 60 * 1000, 30 * 60 * 1000), + rpcPollMs: boundedPositiveInt(env, 'E2E_RPC_POLL_MS', 2000, 10000), + botMoveDelayMs: boundedPositiveInt(env, 'E2E_BOT_MOVE_DELAY_MS', 140, 1000), + botMaxMoves: boundedPositiveInt(env, 'E2E_BOT_MAX_MOVES', 3000, 3000), + botTimeoutMs: boundedPositiveInt(env, 'E2E_BOT_TIMEOUT_MS', 8 * 60 * 1000, 8 * 60 * 1000), + databaseNonceFile: String(env.E2E_DATABASE_NONCE_FILE || '').trim(), + maxTransferAtomic: optionalPositiveAtomic(env, 'E2E_MAX_TRANSFER_ATOMIC'), + feeCushionAtomic: optionalPositiveAtomic(env, 'E2E_FEE_CUSHION_ATOMIC') || 1000000000n + }); +} + +function assertLiveSafety(config, env = process.env) { + assert(config.mode === 'live-stagenet', 'live safety checks apply only to live-stagenet mode'); + assert(config.scenario, 'an explicit live scenario is required'); + assert(env.E2E_CONFIRM === LIVE_CONFIRM, + `live run requires E2E_CONFIRM=${LIVE_CONFIRM}`); + assert(env.E2E_SCENARIO_CONFIRM === config.scenario.scenarioConfirm, + `live run requires E2E_SCENARIO_CONFIRM=${config.scenario.scenarioConfirm}`); + assert(env.E2E_CANARY_PROFILE === SAFE_PROFILE_CONFIRM, + `live run requires E2E_CANARY_PROFILE=${SAFE_PROFILE_CONFIRM}`); + assert(config.maxTransferAtomic !== null, + 'live run requires an explicit E2E_MAX_TRANSFER_ATOMIC ceiling'); + assert(config.databaseNonceFile, + 'live run requires E2E_DATABASE_NONCE_FILE for the exact database handshake'); +} + +function canonicalAcknowledgement(disclosure) { + assert(disclosure && typeof disclosure === 'object', 'commerce disclosure is missing'); + assert(disclosure.paidAcknowledgementRequired === true, + 'canary requires paid-action acknowledgement enforcement'); + assert(typeof disclosure.policyVersion === 'string' + && disclosure.policyVersion.length >= 1 + && disclosure.policyVersion.length <= 64, + 'commerce disclosure has no valid policy version'); + assert(disclosure.service?.cryptoType === 'XMR' + && disclosure.service?.network === 'stagenet' + && disclosure.service?.isTestNetwork === true, + 'commerce disclosure is not canonical XMR stagenet policy'); + assert(disclosure.service?.paymentsEnabled === true + && disclosure.service?.directPaidEntryEnabled === true + && disclosure.service?.paidCreditsEnabled === true + && disclosure.service?.soloPayoutsEnabled === true + && disclosure.service?.anyPayoutsEnabled === true + && disclosure.service?.paidPrestigeOnly === false, + 'commerce disclosure does not describe both payout-enabled paid solo modes'); + return Object.freeze({ + policyVersion: disclosure.policyVersion, + ageEligible: true, + termsRead: true, + riskAccepted: true, + testnetUnderstood: true + }); +} + +async function jsonRequest(target, relative, options = {}) { + const response = await fetch(new URL(relative, target), { + ...options, + signal: AbortSignal.timeout(options.timeout || 15000) + }); + let body = null; + try { body = await response.json(); } catch (_) { /* handled by callers */ } + return { response, body }; +} + +async function fetchCanonicalAcknowledgement(config) { + const result = await jsonRequest(config.target, '/api/disclosures'); + assert(result.response.status === 200, 'commerce disclosure endpoint is not HTTP 200'); + const cacheControl = String(result.response.headers.get('cache-control') || '').toLowerCase(); + assert(cacheControl.includes('no-store'), 'commerce disclosure endpoint is not no-store'); + const acknowledgement = canonicalAcknowledgement(result.body); + assert(result.body?.links?.terms === '/terms' + && result.body?.links?.privacy === '/privacy' + && result.body?.links?.responsiblePlay === '/responsible-play', + 'commerce disclosure links are incomplete'); + return { disclosure: result.body, acknowledgement }; +} + +async function publicPreflight(config) { + const ready = await jsonRequest(config.target, '/health/ready'); + assert(ready.response.status === 200 && ready.body?.ready === true, + 'dedicated canary is not ready'); + assert(ready.body?.chain?.network === 'stagenet' + && ready.body?.chain?.source !== 'simulated', + 'dedicated canary is not using a real XMR stagenet daemon'); + assert(ready.body?.checks?.database === 'up' + && ready.body?.checks?.chain === 'up' + && ready.body?.checks?.wallet === 'up', + 'database, chain, or house wallet readiness failed'); + assert(ready.body?.money?.paymentsEnabled === true + && ready.body?.money?.payoutsEnabled === true, + 'payment intake and payout dispatch must both be enabled'); + + const modes = await jsonRequest(config.target, '/api/game-modes'); + assert(modes.response.status === 200, 'game-mode endpoint is not HTTP 200'); + assert(modes.body?.PAID_SINGLE?.enabled === true + && modes.body?.PAID_CREDITS?.enabled === true, + 'both direct and credits paid solo modes must be enabled'); + for (const mode of ['PAID_SINGLE', 'PAID_CREDITS']) { + assert(Number(modes.body?.[mode]?.payoutMultiplier?.escape) === 2, + `${mode} escape payout is not exactly 2x`); + assert(Number(modes.body?.[mode]?.payoutMultiplier?.escapeWithTreasure) === 3, + `${mode} treasure payout is not exactly 3x`); + } + + const stats = await jsonRequest(config.target, '/api/stats'); + assert(stats.response.status === 200 + && stats.body?.currencyLabel === 'sXMR' + && stats.body?.payoutsEnabled === true, + 'public stats do not identify payout-enabled sXMR'); + + const legal = await fetchCanonicalAcknowledgement(config); + phase('localhost, stagenet, money-mode, and current-disclosure preflight passed'); + return legal; +} + +function assertPrivateSecretFile(fileName, label) { + const stat = fs.lstatSync(fileName); + assert(stat.isFile() && !stat.isSymbolicLink(), `${label} must be a regular non-symlink file`); + assert((stat.mode & 0o077) === 0, `${label} must not be group/world accessible`); +} + +function secretFromEnv(name, env = process.env) { + const direct = String(env[name] || '').trim(); + const fileName = String(env[`${name}_FILE`] || '').trim(); + assert(!(direct && fileName), `${name} and ${name}_FILE are mutually exclusive`); + if (!fileName) return direct; + assertPrivateSecretFile(fileName, `${name}_FILE`); + return fs.readFileSync(fileName, 'utf8').trim(); +} + +function loadAppModules() { + const manifest = path.join(APP_ROOT, 'package.json'); + assert(fs.existsSync(manifest), 'repository package.json is missing'); + const appRequire = createRequire(manifest); + return { + io: appRequire('socket.io-client'), + axios: appRequire('axios'), + pg: appRequire('pg'), + dotenv: appRequire('dotenv'), + DigestAuthClient: appRequire('./payments/digestAuth').DigestAuthClient + }; +} + +function createFundingRpc(modules, env = process.env) { + let endpoint; + try { + endpoint = new URL(env.E2E_FUNDING_RPC_URL || ''); + } catch (_) { + throw new Error('E2E_FUNDING_RPC_URL must be a valid localhost URL'); + } + assertLocalUrl(endpoint, 'E2E_FUNDING_RPC_URL'); + assert(endpoint.pathname === '/' || endpoint.pathname === '', + 'E2E_FUNDING_RPC_URL must name the RPC origin only'); + + const username = secretFromEnv('E2E_FUNDING_RPC_USER', env); + const password = secretFromEnv('E2E_FUNDING_RPC_PASSWORD', env); + const unauth = env.E2E_ALLOW_UNAUTH_FUNDING_RPC === 'I_ACCEPT_LOCAL_UNAUTH_RPC'; + assert((username && password) || unauth, + 'funding RPC needs digest credentials or the explicit localhost unauth guard'); + assert((username && password) || (!username && !password), + 'funding RPC digest credentials are incomplete'); + + const base = modules.axios.create({ timeout: 15000 }); + const http = username && password + ? new modules.DigestAuthClient(base, { username, password }) + : base; + const rpcUrl = new URL('/json_rpc', endpoint).toString(); + + async function raw(method, params = {}) { + let response; + try { + response = await http.post(rpcUrl, { + jsonrpc: '2.0', id: 'financial-canary', method, params + }, { headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + throw new Error(`funding wallet RPC transport failed during ${method}: ${safeMessage(error.message)}`); + } + return response.data || {}; + } + + async function rpc(method, params = {}) { + const data = await raw(method, params); + if (data.error) { + throw new Error(`funding wallet RPC rejected ${method}: ${safeMessage(data.error.message)}`); + } + return data.result || {}; + } + + return { raw, rpc }; +} + +async function fundingWalletPreflight(funding) { + await funding.rpc('get_version'); + const height = await funding.rpc('get_height'); + assert(Number(height.height) > 0, 'funding wallet has no synchronized height'); + const addressResult = await funding.rpc('get_address', { + account_index: 0, address_index: [0] + }); + const address = String(addressResult.address || ''); + assert(/^[57][1-9A-HJ-NP-Za-km-z]{90,110}$/.test(address), + 'funding wallet primary address is not an XMR stagenet address'); + const validation = await funding.rpc('validate_address', { address, any_net_type: true }); + assert(validation.valid === true && validation.nettype === 'stagenet', + 'funding wallet did not validate its own address as stagenet'); + phase('separate funding wallet is synchronized and identifies as stagenet'); + return Object.freeze({ address }); +} + +function validateExpectedDatabaseName(name, scenario) { + assert(/^[a-z0-9_]+$/i.test(name), 'E2E_EXPECT_DATABASE must be a simple database name'); + assert(/canary/i.test(name) && /e2e/i.test(name), + 'E2E_EXPECT_DATABASE must contain both canary and e2e'); + assert(scenario && name.toLowerCase().includes(scenario.databaseTag), + 'E2E_EXPECT_DATABASE must contain the selected scenario tag'); +} + +async function openReadOnlyDatabase(modules, config, env = process.env) { + const connectionString = secretFromEnv('E2E_DATABASE_URL', env); + const dbEnvFile = String(env.E2E_DATABASE_ENV_FILE || '').trim(); + const expected = String(env.E2E_EXPECT_DATABASE || '').trim(); + validateExpectedDatabaseName(expected, config.scenario); + assert(Boolean(connectionString) !== Boolean(dbEnvFile), + 'set exactly one of E2E_DATABASE_URL[_FILE] or E2E_DATABASE_ENV_FILE'); + + let clientConfig; + if (connectionString) { + let parsedUrl; + try { parsedUrl = new URL(connectionString); } catch (_) { + throw new Error('E2E_DATABASE_URL is not a valid PostgreSQL URL'); + } + assert(['postgres:', 'postgresql:'].includes(parsedUrl.protocol), + 'E2E_DATABASE_URL must use PostgreSQL'); + assert(isLoopback(parsedUrl.hostname), 'E2E_DATABASE_URL must name localhost explicitly'); + assert(decodeURIComponent(parsedUrl.pathname.replace(/^\//, '')) === expected, + 'E2E_DATABASE_URL names a different database'); + clientConfig = { connectionString }; + } else { + assertPrivateSecretFile(dbEnvFile, 'E2E_DATABASE_ENV_FILE'); + const parsed = modules.dotenv.parse(fs.readFileSync(dbEnvFile)); + assert(parsed.DB_NAME === expected, + 'protected database environment names a different database'); + assert(isLoopback(parsed.DB_HOST || 'localhost'), + 'protected database environment must name localhost explicitly'); + const dbPort = Number(parsed.DB_PORT || 5432); + assert(Number.isInteger(dbPort) && dbPort > 0 && dbPort <= 65535, + 'protected database environment has an invalid DB_PORT'); + clientConfig = { + host: parsed.DB_HOST || 'localhost', + port: dbPort, + database: parsed.DB_NAME, + user: parsed.DB_USER, + password: parsed.DB_PASSWORD + }; + } + + const client = new modules.pg.Client(clientConfig); + await client.connect(); + await client.query('SET default_transaction_read_only = on'); + const identity = await client.query(` + SELECT current_database() AS name, + current_setting('default_transaction_read_only') AS read_only + `); + assert(identity.rows[0]?.name === expected && identity.rows[0]?.read_only === 'on', + 'database identity or read-only guard failed'); + return client; +} + +async function verifyCanaryDatabaseHandshake(config, db, env = process.env, + request = jsonRequest) { + assert(config.mode === 'live-stagenet', + 'database identity handshake applies only to live-stagenet mode'); + const expectedDatabase = String(env.E2E_EXPECT_DATABASE || '').trim(); + validateExpectedDatabaseName(expectedDatabase, config.scenario); + const nonce = readNonceFile(config.databaseNonceFile, 'E2E_DATABASE_NONCE_FILE'); + const directIdentity = await readDatabaseIdentity(db); + assert(directIdentity.databaseName === expectedDatabase, + 'read-only canary connection has the wrong database identity'); + + const challenge = crypto.randomBytes(32).toString('hex'); + const result = await request(config.target, '/api/canary/database-identity', { + headers: { 'X-Canary-Database-Challenge': challenge } + }); + assert(result.response.status === 200, + 'canary database identity endpoint is unavailable'); + const cacheControl = String(result.response.headers.get('cache-control') || '').toLowerCase(); + assert(cacheControl.includes('no-store'), + 'canary database identity endpoint is not no-store'); + assert(result.body?.protocol === CANARY_HANDSHAKE_PROTOCOL + && result.body?.challenge === challenge, + 'canary database identity protocol or challenge mismatch'); + + const appIdentity = normalizeIdentity(result.body?.database); + assert(appIdentity.clusterId === directIdentity.clusterId + && appIdentity.databaseOid === directIdentity.databaseOid + && appIdentity.databaseName === directIdentity.databaseName, + 'application and read-only harness are connected to different databases'); + const expectedProof = computeProof(nonce, directIdentity, challenge); + assert(equalProof(result.body?.proof, expectedProof), + 'canary database identity proof is invalid'); + phase('application process and read-only harness share the exact canary database identity'); +} + +const EMPTY_FINANCIAL_KEYS = Object.freeze([ + 'payments', 'games', 'payouts', 'receipts', 'refunds', 'late_reviews', + 'credit_transactions', 'entitlement_grants', 'pack_entitlements', + 'matches', 'match_entrants', 'match_events', 'match_queue_entries', + 'race_entry_transactions', 'race_entry_lots' +]); + +async function assertEmptyFinancialDatabase(db) { + const result = await db.query(` + SELECT + (SELECT COUNT(*)::int FROM users) AS users, + (SELECT COUNT(*)::int FROM users + WHERE socket_id = 'admin' AND username = 'admin' AND payout_address IS NULL) AS seed_admins, + (SELECT COUNT(*)::int FROM payments) AS payments, + (SELECT COUNT(*)::int FROM games) AS games, + (SELECT COUNT(*)::int FROM payouts) AS payouts, + (SELECT COUNT(*)::int FROM payment_receipts) AS receipts, + (SELECT COUNT(*)::int FROM payment_refunds) AS refunds, + (SELECT COUNT(*)::int FROM payment_late_reviews) AS late_reviews, + (SELECT COUNT(*)::int FROM credit_transactions) AS credit_transactions, + (SELECT COUNT(*)::int FROM payment_entitlement_grants) AS entitlement_grants, + (SELECT COUNT(*)::int FROM user_pack_entitlements) AS pack_entitlements, + (SELECT COUNT(*)::int FROM matches) AS matches, + (SELECT COUNT(*)::int FROM match_entrants) AS match_entrants, + (SELECT COUNT(*)::int FROM match_events) AS match_events, + (SELECT COUNT(*)::int FROM match_queue_entries) AS match_queue_entries, + (SELECT COUNT(*)::int FROM race_entry_transactions) AS race_entry_transactions, + (SELECT COUNT(*)::int FROM race_entry_lots) AS race_entry_lots, + (SELECT COUNT(*)::int FROM schema_migrations + WHERE filename = ANY($1::text[])) AS required_migrations + `, [REQUIRED_MIGRATIONS]); + const row = result.rows[0] || {}; + assert(Number(row.users) === 1 && Number(row.seed_admins) === 1, + 'canary database does not contain exactly the migration-seeded admin'); + assert(Number(row.required_migrations) === REQUIRED_MIGRATIONS.length, + 'canary database is missing required financial migrations'); + for (const key of EMPTY_FINANCIAL_KEYS) { + assert(Number(row[key]) === 0, `canary database is not financially empty (${key})`); + } + phase('fresh migrated scenario database has no prior gameplay or financial evidence'); +} + +class EventJournal { + constructor(socket) { + this.seq = 0; + this.events = []; + this.waiters = new Set(); + socket.onAny((name, ...args) => this.record(name, args[0])); + socket.on('disconnect', reason => this.record('$disconnect', { reason })); + socket.on('connect_error', error => this.record('$connect_error', { + message: safeMessage(error.message) + })); + } + + record(name, data) { + const item = { seq: ++this.seq, name, data }; + this.events.push(item); + if (this.events.length > 6000) this.events.shift(); + for (const waiter of Array.from(this.waiters)) { + if (waiter.after < item.seq + && waiter.specs.some(spec => spec.name === name && spec.test(data))) { + clearTimeout(waiter.timer); + this.waiters.delete(waiter); + waiter.resolve(item); + } + } + } + + latest(name, test = () => true) { + for (let index = this.events.length - 1; index >= 0; index -= 1) { + const item = this.events[index]; + if (item.name === name && test(item.data)) return item; + } + return null; + } + + waitAny(specs, timeoutMs, after = 0) { + const normalized = specs.map(spec => ({ + name: spec.name, + test: spec.test || (() => true) + })); + const existing = this.events.find(item => item.seq > after + && normalized.some(spec => spec.name === item.name && spec.test(item.data))); + if (existing) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + const waiter = { specs: normalized, after, resolve, timer: null }; + waiter.timer = setTimeout(() => { + this.waiters.delete(waiter); + reject(new Error(`timed out waiting for ${normalized.map(spec => spec.name).join(' or ')}`)); + }, timeoutMs); + this.waiters.add(waiter); + }); + } +} + +function waitForConnect(socket) { + if (socket.connected) return Promise.resolve(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Socket.IO connection timed out')), 15000); + socket.once('connect', () => { clearTimeout(timer); resolve(); }); + socket.once('connect_error', error => { + clearTimeout(timer); + reject(new Error(`Socket.IO connection failed: ${safeMessage(error.message)}`)); + }); + }); +} + +function validFairnessOffer(data) { + return Boolean(data) + && typeof data.offerId === 'string' + && /^[0-9a-f]{64}$/i.test(String(data.commitment || '')); +} + +async function requestFreshFairness(socket, journal) { + const after = journal.seq; + socket.emit('fairness_offer_request'); + const event = await journal.waitAny([ + { name: 'fairness_offer', test: validFairnessOffer }, + { name: '$disconnect' } + ], 10000, after); + assert(event.name === 'fairness_offer', 'fresh fairness offer was not received'); + return Object.freeze({ ...event.data }); +} + +async function savePayoutAddress(socket, journal, payoutAddress) { + const after = journal.seq; + socket.emit('address:update', { address: payoutAddress }); + const outcome = await journal.waitAny([ + { name: 'address_confirmed', test: data => data?.cancelled !== true }, + { name: 'address_update_error' }, + { name: '$disconnect' } + ], 30000, after); + assert(outcome.name === 'address_confirmed', + `payout address was not accepted (${outcome.name})`); + assert(outcome.data?.address === payoutAddress, + 'server confirmed a different payout address'); + phase('anonymous canary identity saved the separate funding wallet as payout destination'); +} + +function parseInvoiceAmount(invoice, config) { + const raw = String(invoice?.amount ?? ''); + assert(/^\d+$/.test(raw), 'invoice amount is not an atomic-unit integer'); + const amount = BigInt(raw); + assert(amount > 0n && amount <= BigInt(Number.MAX_SAFE_INTEGER), + 'invoice amount is not a safe positive wallet-RPC value'); + assert(config.maxTransferAtomic !== null && amount <= config.maxTransferAtomic, + 'invoice exceeds E2E_MAX_TRANSFER_ATOMIC; no transfer was attempted'); + return amount; +} + +function assertCommonInvoice(invoice, config, paymentType) { + assert(invoice.paymentType === paymentType, 'server returned the wrong invoice type'); + assert(invoice.cryptoType === 'XMR' || invoice.currency === 'XMR', + 'invoice currency is not XMR'); + assert(/^[57][1-9A-HJ-NP-Za-km-z]{90,110}$/.test(String(invoice.address || '')), + 'invoice destination is not an XMR stagenet address'); + assert(Number.isSafeInteger(Number(invoice.paymentId)) && Number(invoice.paymentId) > 0, + 'invoice has no durable payment id'); + assert(invoice.reused !== true, 'fresh database unexpectedly reused an invoice'); + return parseInvoiceAmount(invoice, config); +} + +async function createDirectInvoice(socket, journal, acknowledgement, config, modeInfo) { + const offer = await requestFreshFairness(socket, journal); + const clientSeed = crypto.randomBytes(32).toString('hex'); + const after = journal.seq; + socket.emit('request_payment', { + type: 'single_game', + fairnessOfferId: offer.offerId, + clientSeed, + legalAcknowledgement: acknowledgement + }); + const outcome = await journal.waitAny([ + { name: 'payment_created' }, + { name: 'payment_error' }, + { name: 'balance_critical' }, + { name: 'payment_review_required' }, + { name: '$disconnect' } + ], 30000, after); + assert(outcome.name === 'payment_created', `direct invoice creation failed (${outcome.name})`); + const invoice = outcome.data || {}; + const amount = assertCommonInvoice(invoice, config, 'single_game'); + assert(amount === BigInt(String(modeInfo.singleGamePrice)), + 'direct invoice differs from advertised atomic entry price'); + assert(invoice.fairness?.offerId === offer.offerId + && invoice.fairness?.commitment === offer.commitment + && invoice.fairness?.clientSeed === clientSeed + && Number(invoice.fairness?.proofVersion) === 2, + 'direct invoice did not persist the consumed fairness proof v2'); + phase('direct entry invoice is durably bound to a fresh fairness offer'); + return { invoice, amount, offer, clientSeed }; +} + +async function createCreditsInvoice(socket, journal, acknowledgement, config, packageInfo) { + const after = journal.seq; + socket.emit('request_payment', { + type: 'credits_package', + packageId: config.packageId, + legalAcknowledgement: acknowledgement + }); + const outcome = await journal.waitAny([ + { name: 'payment_created' }, + { name: 'payment_error' }, + { name: 'balance_critical' }, + { name: 'payment_review_required' }, + { name: '$disconnect' } + ], 30000, after); + assert(outcome.name === 'payment_created', `credits invoice creation failed (${outcome.name})`); + const invoice = outcome.data || {}; + const amount = assertCommonInvoice(invoice, config, 'credits_package'); + assert(invoice.productId === config.packageId && invoice.package?.id === config.packageId, + 'credits invoice returned a different product'); + assert(amount === BigInt(String(packageInfo.price)), + 'credits invoice differs from advertised package price'); + assert(Number(invoice.grants?.credits) === packageInfo.grantedCredits, + 'credits invoice grant differs from advertised package grant'); + assert(invoice.fairness == null, 'credits purchase must not bind a game fairness offer'); + phase('pure credits package invoice matches its advertised product and grant'); + return { + invoice, + amount, + grantedCredits: packageInfo.grantedCredits, + creditsPerGame: packageInfo.creditsPerGame, + payoutBase: packageInfo.payoutBase + }; +} + +function assertInvoiceNotOwned(ownership) { + assert(ownership && ownership.error, + 'invoice belongs to the funding wallet; self-payment is forbidden'); + const notOwned = /not.*belong|doesn.*belong|invalid.*address/i.test( + String(ownership.error.message || '') + ); + assert(notOwned, 'could not prove invoice and funding wallets are separate'); +} + +async function assertSeparateFundingWallet(funding, invoiceAddress) { + const validation = await funding.rpc('validate_address', { + address: invoiceAddress, any_net_type: true + }); + assert(validation.valid === true && validation.nettype === 'stagenet', + 'funding wallet did not validate the invoice as stagenet'); + const ownership = await funding.raw('get_address_index', { address: invoiceAddress }); + assertInvoiceNotOwned(ownership); + phase('invoice is valid stagenet and provably not owned by the funding wallet'); +} + +async function sendExactEntry(funding, invoiceAddress, amount, config, transferGate) { + assert(transferGate && transferGate.attempted === false, + 'transfer retry is forbidden in one-shot canary execution'); + assert(amount <= config.maxTransferAtomic, 'transfer amount exceeds explicit ceiling'); + const balance = await funding.rpc('get_balance', { account_index: 0 }); + const unlocked = BigInt(String(balance.unlocked_balance || 0)); + assert(unlocked >= amount + config.feeCushionAtomic, + 'funding wallet lacks unlocked balance plus the configured fee cushion'); + + // Mark the one-shot gate before the RPC call: an ambiguous transport failure must never retry. + transferGate.attempted = true; + const result = await funding.rpc('transfer', { + destinations: [{ amount: Number(amount), address: invoiceAddress }], + account_index: 0, + priority: 1, + get_tx_key: false, + do_not_relay: false + }); + const txHash = String(result.tx_hash || ''); + assert(/^[0-9a-f]{64}$/i.test(txHash), + 'funding transfer returned no valid transaction hash'); + phase('one exact, non-retriable XMR stagenet transfer was broadcast'); +} + +function pointKey(x, y) { return `${x},${y}`; } +function parsePointKey(value) { return value.split(',').map(Number); } + +function addVisible(known, state) { + for (const [yRaw, row] of Object.entries(state?.visibleTiles || {})) { + for (const [xRaw, tile] of Object.entries(row || {})) { + known.set(pointKey(Number(xRaw), Number(yRaw)), tile); + } + } +} + +function blockedKeys(state, scenario) { + const blocked = new Set(); + if (Number.isInteger(state?.monster?.x) && Number.isInteger(state?.monster?.y)) { + blocked.add(pointKey(state.monster.x, state.monster.y)); + } + const hasTreasure = state?.player?.hasTreasure === true; + if (scenario.collectTreasure && !hasTreasure && Array.isArray(state?.exit)) { + blocked.add(pointKey(Number(state.exit[0]), Number(state.exit[1]))); + } + if (!scenario.collectTreasure && Array.isArray(state?.treasure)) { + blocked.add(pointKey(Number(state.treasure[0]), Number(state.treasure[1]))); + } + return blocked; +} + +function bfs(known, state, scenario) { + const start = pointKey(state.player.x, state.player.y); + const blocked = blockedKeys(state, scenario); + blocked.delete(start); + const queue = [start]; + const parent = new Map([[start, null]]); + for (let index = 0; index < queue.length; index += 1) { + const current = queue[index]; + const [x, y] = parsePointKey(current); + for (const [dx, dy] of STEP_VECTORS) { + const next = pointKey(x + dx, y + dy); + if (parent.has(next) || blocked.has(next) || !FLOOR.has(known.get(next))) continue; + parent.set(next, current); + queue.push(next); + } + } + return parent; +} + +function pathTo(parent, target) { + if (!parent.has(target)) return null; + const result = []; + for (let cursor = target; cursor !== null; cursor = parent.get(cursor)) result.push(cursor); + result.reverse(); + return result; +} + +function objectivePoint(state, scenario) { + const wantsTreasure = scenario.collectTreasure && state?.player?.hasTreasure !== true; + const point = wantsTreasure ? state?.treasure : state?.exit; + assert(Array.isArray(point) && point.length === 2 + && Number.isInteger(Number(point[0])) && Number.isInteger(Number(point[1])), + wantsTreasure ? 'treasure coordinate is unavailable' : 'exit coordinate is unavailable'); + return [Number(point[0]), Number(point[1])]; +} + +function chooseMove(known, state, bot, scenario) { + assert(state?.player && Number.isInteger(state.player.x) && Number.isInteger(state.player.y), + 'bot received no valid player coordinate'); + const start = pointKey(state.player.x, state.player.y); + const [targetX, targetY] = objectivePoint(state, scenario); + const target = pointKey(targetX, targetY); + if (bot.objective !== target) { + bot.objective = target; + bot.goal = null; + bot.frontiersTried.clear(); + } + + const parent = bfs(known, state, scenario); + let selectedPath = FLOOR.has(known.get(target)) ? pathTo(parent, target) : null; + if (!selectedPath || selectedPath.length < 2) { + if (bot.goal === start) { + bot.frontiersTried.add(bot.goal); + bot.goal = null; + } + if (bot.goal) { + selectedPath = pathTo(parent, bot.goal); + if (!selectedPath || selectedPath.length < 2) bot.goal = null; + } + if (!bot.goal) { + const candidates = []; + for (const candidate of parent.keys()) { + if (candidate === start || bot.frontiersTried.has(candidate)) continue; + const [x, y] = parsePointKey(candidate); + const frontier = STEP_VECTORS.some(([dx, dy]) => !known.has(pointKey(x + dx, y + dy))); + if (!frontier) continue; + const candidatePath = pathTo(parent, candidate); + candidates.push({ + candidate, + path: candidatePath, + score: Math.abs(x - targetX) * 10 + + Math.abs(y - targetY) * 10 + + candidatePath.length + }); + } + candidates.sort((left, right) => left.score - right.score + || left.candidate.localeCompare(right.candidate)); + assert(candidates.length > 0, + 'bounded bot exhausted safe exploration before reaching its objective'); + bot.goal = candidates[0].candidate; + selectedPath = candidates[0].path; + } + } + + assert(selectedPath && selectedPath.length >= 2, + 'bounded bot could not choose a movement step'); + const [nextX, nextY] = parsePointKey(selectedPath[1]); + const dx = nextX - state.player.x; + const dy = nextY - state.player.y; + if (dx === 1 && dy === 0) return 'right'; + if (dx === -1 && dy === 0) return 'left'; + if (dx === 0 && dy === 1) return 'down'; + if (dx === 0 && dy === -1) return 'up'; + throw new Error('bounded bot generated a non-adjacent movement'); +} + +const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); + +async function runBoundedBot(socket, journal, startEvent, scenario, config) { + let state = startEvent.data; + assert(Number(state?.maxDepth) === 1, + 'financial canary requires exactly one dungeon depth'); + assert(Array.isArray(state?.exit) && state.exit.length === 2, + 'game did not disclose an exit coordinate'); + if (scenario.collectTreasure) { + assert(Array.isArray(state?.treasure) && state.treasure.length === 2, + '3x scenario did not disclose a treasure coordinate'); + } + const bot = { + known: new Map(), frontiersTried: new Set(), goal: null, objective: null, + depth: Number(state.depth || 1) + }; + const deadline = Date.now() + config.botTimeoutMs; + + for (let moves = 0; moves < config.botMaxMoves && Date.now() < deadline; moves += 1) { + if (state?.gameState && state.gameState !== 'active') { + const terminal = await journal.waitAny([{ name: 'game_over' }, { name: '$disconnect' }], + 30000, startEvent.seq); + assert(terminal.name === 'game_over', 'socket disconnected before game completion'); + return terminal.data; + } + assert(Number(state.depth || 1) === bot.depth, 'unexpected multi-depth transition'); + addVisible(bot.known, state); + const direction = chooseMove(bot.known, state, bot, scenario); + await delay(config.botMoveDelayMs); + const after = journal.seq; + socket.emit('player_move', { direction }); + const outcome = await journal.waitAny([ + { name: 'game_update' }, + { name: 'game_over' }, + { name: '$disconnect' } + ], 8000, after); + assert(outcome.name !== '$disconnect', 'socket disconnected during the canary game'); + if (outcome.name === 'game_over') return outcome.data; + state = outcome.data; + } + throw new Error('bounded bot exceeded its move/time limit; no retry was attempted'); +} + +function verifyFairnessReveal(proof, startState, offer, clientSeed, treasureFound) { + assert(proof?.gameId && proof.gameId === startState?.proof?.gameId, + 'game proof identity changed'); + assert(proof.offerId === offer.offerId + && proof.commitment === offer.commitment + && proof.clientSeed === clientSeed, + 'game reveal differs from the consumed fairness offer'); + assert(crypto.createHash('sha256').update(String(proof.serverSeed)).digest('hex') + === proof.commitment, + 'revealed server seed does not match the pre-game commitment'); + const effective = crypto.createHmac('sha256', String(proof.serverSeed)) + .update(String(proof.clientSeed || '')).digest('hex'); + assert(effective === proof.effectiveSeed, + 'revealed effective seed does not include the client seed'); + assert(proof.gameResult?.won === true + && proof.gameResult?.treasureFound === treasureFound, + 'fairness reveal carries incorrect outcome metadata'); +} + +function verifyGameOver(gameOver, context, startState) { + const scenario = context.scenario; + assert(gameOver?.status === 'won' && gameOver?.reason === 'escaped', + 'canary game did not end in an escape'); + assert(gameOver?.treasure === scenario.collectTreasure, + 'canary game has the wrong treasure outcome'); + const expected = context.payoutBase * BigInt(scenario.multiplier); + const payout = gameOver?.payout?.payout; + assert(gameOver?.payout?.success === true + && gameOver?.payout?.mode === scenario.gameMode, + 'game completion did not commit the selected paid mode'); + assert(payout && ['queued', 'completed'].includes(payout.status), + 'game completion did not queue a payout'); + assert(BigInt(String(payout.amount)) === expected + && Number(payout.multiplier) === scenario.multiplier, + 'committed payout does not match the exact scenario multiplier'); + verifyFairnessReveal(gameOver.proof, startState, context.offer, + context.clientSeed, scenario.collectTreasure); + phase('game outcome, fairness reveal, and exact payout liability verified'); + return { + expected, + payoutId: payout.payoutId, + gameId: gameOver.proof.gameId + }; +} + +async function ownedHistory(config, socketId, token, type) { + const result = await jsonRequest(config.target, + `/api/user/${encodeURIComponent(socketId)}/${type}?limit=20`, { + headers: { 'X-Session-Token': token } + }); + assert(result.response.status === 200, `${type} history is not HTTP 200`); + return result.body; +} + +async function waitForCompletedPayout(config, socketId, token, context) { + const deadline = Date.now() + config.payoutTimeoutMs; + while (Date.now() < deadline) { + const history = await ownedHistory(config, socketId, token, 'payouts'); + assert(Number(history.total) <= 1, 'canary identity has more than one payout'); + const row = (history.payouts || []).find(item => String(item.id) === String(context.payoutId)); + if (row) { + assert(BigInt(String(row.amount)) === context.expected + && Number(row.multiplier) === config.scenario.multiplier, + 'payout history changed the committed payout identity'); + assert(row.reason === config.scenario.payoutReason && row.gameOutcome === 'escaped', + 'payout history has the wrong reason or game outcome'); + assert(row.status !== 'needs_review' && row.status !== 'permanently_failed', + `payout entered terminal failure state ${row.status}`); + if (row.status === 'completed') { + assert(/^[0-9a-f]{64}$/i.test(String(row.txHash || '')), + 'completed payout has no transaction evidence'); + assert(Number(history.total) === 1 + && BigInt(String(history.totalReceived)) === context.expected, + 'completed payout aggregate is not exact'); + phase('application reports exactly one completed payout'); + return row; + } + } + await delay(config.rpcPollMs); + } + throw new Error('payout did not reach completed before the bounded timeout'); +} + +async function waitForIncomingPayout(funding, txHash, expected, config) { + const deadline = Date.now() + config.payoutTimeoutMs; + while (Date.now() < deadline) { + try { + const result = await funding.rpc('get_transfer_by_txid', { + txid: txHash, account_index: 0 + }); + const transfers = Array.isArray(result.transfers) && result.transfers.length + ? result.transfers + : (result.transfer ? [result.transfer] : []); + const incoming = transfers.find(item => String(item.txid || item.tx_hash || '') === txHash + && !['out', 'failed'].includes(String(item.type || '').toLowerCase())); + if (incoming) { + assert(BigInt(String(incoming.amount || 0)) === expected, + 'funding wallet observed a different incoming payout amount'); + phase('separate funding wallet observed the exact incoming payout'); + return; + } + } catch (_) { + // Wallet indexing can trail payout broadcast. The payout is never resent here. + } + await delay(config.rpcPollMs); + } + throw new Error('separate funding wallet did not observe the payout before timeout'); +} + +async function assertExactDatabaseCounts(db, scenario) { + const result = await db.query(` + SELECT + (SELECT COUNT(*)::int FROM users) AS users, + (SELECT COUNT(*)::int FROM payments) AS payments, + (SELECT COUNT(*)::int FROM games) AS games, + (SELECT COUNT(*)::int FROM payouts) AS payouts, + (SELECT COUNT(*)::int FROM payment_receipts) AS receipts, + (SELECT COUNT(*)::int FROM payment_refunds) AS refunds, + (SELECT COUNT(*)::int FROM payment_late_reviews) AS late_reviews, + (SELECT COUNT(*)::int FROM credit_transactions) AS credit_transactions, + (SELECT COUNT(*)::int FROM payment_entitlement_grants) AS entitlement_grants, + (SELECT COUNT(*)::int FROM user_pack_entitlements) AS pack_entitlements, + (SELECT COUNT(*)::int FROM matches) AS matches, + (SELECT COUNT(*)::int FROM match_entrants) AS match_entrants, + (SELECT COUNT(*)::int FROM match_events) AS match_events, + (SELECT COUNT(*)::int FROM match_queue_entries) AS match_queue_entries, + (SELECT COUNT(*)::int FROM race_entry_transactions) AS race_entry_transactions, + (SELECT COUNT(*)::int FROM race_entry_lots) AS race_entry_lots + `); + const row = result.rows[0] || {}; + const expected = { + users: 2, + payments: 1, + games: 1, + payouts: 1, + receipts: 1, + refunds: 0, + late_reviews: 0, + credit_transactions: 2, + entitlement_grants: scenario.id === 'credits-3x' ? 1 : 0, + pack_entitlements: 0, + matches: 0, + match_entrants: 0, + match_events: 0, + match_queue_entries: 0, + race_entry_transactions: 0, + race_entry_lots: 0 + }; + for (const [key, value] of Object.entries(expected)) { + assert(Number(row[key]) === value, `unexpected durable row count for ${key}`); + } +} + +async function assertPaymentAndReceipt(db, context) { + const payment = await db.query(` + SELECT id, user_id, payment_type, expected_amount, received_amount, status, + provider_id, product_id, product_grants, credits_purchased, + address_index, + (subaddress = $2) AS invoice_destination_matches, + (provider_invoice_id = $2) AS provider_invoice_matches, + fairness_proof_version, fairness_offer_id, fairness_commitment, + fairness_client_seed, + fairness_bound_at IS NOT NULL AS bound, + fairness_consumed_at IS NOT NULL AS consumed, + confirmed_at IS NOT NULL AS confirmed_at_set, + confirmation_evidence_at IS NOT NULL AS evidence_at, + (tx_hash ~ '^[0-9a-f]{64}$') AS valid_tx_hash + FROM payments WHERE id = $1 + `, [context.paymentId, context.invoiceAddress]); + const row = payment.rows[0]; + assert(row && row.payment_type === context.scenario.paymentType + && row.status === 'confirmed' && row.provider_id === 'native-monero', + 'payment row has the wrong type, status, or provider'); + assert(BigInt(row.expected_amount) === context.entryAmount + && BigInt(row.received_amount) === context.entryAmount, + 'payment row is not covered by the exact transferred amount'); + assert(row.confirmed_at_set && row.evidence_at && row.valid_tx_hash, + 'payment row lacks confirmation evidence'); + assert(row.invoice_destination_matches && row.provider_invoice_matches + && Number.isInteger(Number(row.address_index)) && Number(row.address_index) >= 0, + 'payment row lacks its immutable invoice destination identity'); + + if (context.scenario.id === 'direct-2x') { + assert(row.product_id === 'single_game' && Number(row.credits_purchased) === 0 + && Number(row.product_grants?.credits || 0) === 0 + && Number(row.product_grants?.raceEntries || 0) === 0 + && Array.isArray(row.product_grants?.packs) && row.product_grants.packs.length === 0 + && row.product_grants?.premiumLevel == null, + 'direct payment product identity changed'); + assert(Number(row.fairness_proof_version) === 2 && row.bound && row.consumed, + 'direct payment fairness binding was not consumed exactly once'); + assert(row.fairness_offer_id === context.offer.offerId + && row.fairness_commitment === context.offer.commitment + && row.fairness_client_seed === context.clientSeed, + 'direct payment fairness identity changed'); + } else { + assert(row.product_id === context.packageId + && Number(row.credits_purchased) === context.grantedCredits + && Number(row.product_grants?.credits) === context.grantedCredits + && Number(row.product_grants?.raceEntries || 0) === 0 + && Array.isArray(row.product_grants?.packs) && row.product_grants.packs.length === 0 + && row.product_grants?.premiumLevel == null, + 'credits payment product or durable grant identity changed'); + assert(row.fairness_proof_version == null + && row.fairness_offer_id == null + && !row.bound && !row.consumed, + 'credits package was incorrectly bound to a game fairness offer'); + } + + const receipt = await db.query(` + SELECT COUNT(*)::int AS count, + COALESCE(SUM(r.amount), 0)::text AS amount, + COUNT(DISTINCT r.provider_id || ':' || r.evidence_id)::int AS unique_count, + BOOL_AND(r.confirmed) AS all_confirmed, + BOOL_AND(r.evidence_type = 'chain_output') AS all_chain_outputs, + BOOL_AND(r.provider_id = 'native-monero') AS all_native, + BOOL_AND(r.tx_hash ~ '^[0-9a-f]{64}$') AS valid_hashes, + BOOL_AND(r.output_id ~ '^[0-9a-f]{64}$' OR r.output_id ~ '^global:(0|[1-9][0-9]*)$') + AS valid_outputs, + BOOL_AND(r.address_index >= 0 AND r.address_index = p.address_index) + AS valid_address_indexes, + BOOL_AND(r.evidence_id = r.tx_hash || ':' || r.output_id) AS valid_evidence_ids + FROM payment_receipts r + JOIN payments p ON p.id = r.payment_id + WHERE r.payment_id = $1 + `, [context.paymentId]); + const evidence = receipt.rows[0]; + assert(Number(evidence.count) === 1 && Number(evidence.unique_count) === 1, + 'payment does not have exactly one unique receipt output'); + assert(BigInt(evidence.amount) === context.entryAmount + && evidence.all_confirmed && evidence.all_chain_outputs && evidence.all_native + && evidence.valid_hashes && evidence.valid_outputs + && evidence.valid_address_indexes && evidence.valid_evidence_ids, + 'receipt evidence does not exactly authorize the invoice'); + return row.user_id; +} + +async function assertGameAndPayout(db, context) { + const result = await db.query(` + SELECT g.user_id, g.dungeon_seed, g.game_mode, + g.status AS game_status, g.outcome, g.treasure_found, g.payment_id, + g.payout_eligible, + g.completed_at IS NOT NULL AS game_completed, + g.proof_revealed_at IS NOT NULL AS proof_revealed, + g.moves_made, + g.payout_committed_at IS NOT NULL AS committed, + g.entry_consumed_at IS NOT NULL AS entry_consumed, + g.entry_credits_spent, + g.payout_escape_amount, g.payout_treasure_amount, + g.payout_escape_mult, g.payout_treasure_mult, + g.payout_terms->>'mode' AS terms_mode, + g.payout_terms->>'escapeAmount' AS terms_escape_amount, + g.payout_terms->>'treasureAmount' AS terms_treasure_amount, + g.proof_version, g.fairness_offer_id, g.proof_commitment, g.client_seed, + (g.payout_address = $2) AS game_address_matches, + p.id AS payout_id, p.amount, p.multiplier, p.reason, + p.status AS payout_status, + p.processed_at IS NOT NULL AS payout_processed, + (p.fee IS NULL OR p.fee >= 0) AS valid_payout_fee, + (p.tx_hash ~ '^[0-9a-f]{64}$') AS valid_payout_tx_hash, + (p.payout_address = $2) AS payout_address_matches + FROM games g JOIN payouts p ON p.game_id = g.id + WHERE g.dungeon_seed = $1 + `, [context.gameId, context.payoutAddress]); + const row = result.rows[0]; + const escapeAmount = context.payoutBase * 2n; + const treasureAmount = context.payoutBase * 3n; + assert(row && row.game_mode === context.scenario.gameMode + && row.game_status === 'won' && row.outcome === 'escaped' + && row.treasure_found === context.scenario.collectTreasure, + 'durable game result does not match the selected scenario'); + assert(row.payout_eligible && row.game_completed && row.proof_revealed + && Number(row.moves_made) > 0 + && row.committed && row.entry_consumed + && row.game_address_matches, + 'game lacks committed entry/payout identity'); + assert(BigInt(row.payout_escape_amount) === escapeAmount + && BigInt(row.payout_treasure_amount) === treasureAmount + && Number(row.payout_escape_mult) === 2 + && Number(row.payout_treasure_mult) === 3, + 'game payout snapshot is not exact 2x/3x'); + assert(row.terms_mode === context.scenario.gameMode + && BigInt(row.terms_escape_amount) === escapeAmount + && BigInt(row.terms_treasure_amount) === treasureAmount, + 'immutable payout terms differ from snapshot columns'); + assert(Number(row.proof_version) === 2 + && row.fairness_offer_id === context.offer.offerId + && row.proof_commitment === context.offer.commitment + && row.client_seed === context.clientSeed, + 'durable game fairness identity changed'); + + if (context.scenario.id === 'direct-2x') { + assert(String(row.payment_id) === String(context.paymentId) + && row.entry_credits_spent == null, + 'direct game did not consume exactly its bound payment'); + } else { + assert(row.payment_id == null + && Number(row.entry_credits_spent) === context.creditsPerGame, + 'credits game did not consume exactly its credit entry'); + } + + assert(String(row.payout_id) === String(context.payoutId) + && BigInt(row.amount) === context.payoutAmount + && Number(row.multiplier) === context.scenario.multiplier + && row.reason === context.scenario.payoutReason, + 'durable payout identity differs from the committed scenario'); + assert(row.payout_status === 'completed' && row.payout_processed + && row.valid_payout_fee && row.valid_payout_tx_hash + && row.payout_address_matches, + 'durable payout is not completed with transaction evidence'); + return row.user_id; +} + +async function assertCreditLedger(db, context, userId) { + const userResult = await db.query(` + SELECT credits, total_credits_purchased, total_games_played, + (payout_address IS NOT NULL AND payout_address <> '') AS has_address, + (socket_id = 'admin' OR username = 'admin') AS is_seed_admin + FROM users WHERE id = $1 + `, [userId]); + const user = userResult.rows[0]; + assert(user && user.has_address && !user.is_seed_admin + && Number(user.total_games_played) === 1, + 'canary user identity or game counter is wrong'); + const ledger = await db.query(` + SELECT amount, reason, balance_after, transaction_type, payment_id + FROM credit_transactions WHERE user_id = $1 ORDER BY id + `, [userId]); + assert(ledger.rows.length === 2, 'canary user does not have exactly two credit-ledger rows'); + + if (context.scenario.id === 'direct-2x') { + assert(Number(user.credits) === 0 && Number(user.total_credits_purchased) === 1, + 'direct entry did not net to zero and advance purchase progress once'); + const [purchase, spend] = ledger.rows; + assert(Number(purchase.amount) === 1 && purchase.reason === 'direct_entry' + && purchase.transaction_type === 'purchase' && Number(purchase.balance_after) === 0 + && purchase.payment_id == null, + 'direct-entry purchase ledger row is not exact'); + assert(Number(spend.amount) === -1 && spend.reason === 'game_entry' + && spend.transaction_type === 'spend' && Number(spend.balance_after) === 0 + && spend.payment_id == null, + 'direct-entry spend ledger row is not exact'); + return; + } + + const finalBalance = context.grantedCredits - context.creditsPerGame; + assert(Number(user.credits) === finalBalance + && Number(user.total_credits_purchased) === context.grantedCredits, + 'credits package grant or post-entry balance is wrong'); + const [purchase, spend] = ledger.rows; + assert(Number(purchase.amount) === context.grantedCredits + && purchase.reason === 'package_purchase' + && purchase.transaction_type === 'purchase' + && Number(purchase.balance_after) === context.grantedCredits + && String(purchase.payment_id) === String(context.paymentId), + 'package-purchase ledger row is not exact'); + assert(Number(spend.amount) === -context.creditsPerGame + && spend.reason === 'game_entry' + && spend.transaction_type === 'spend' + && Number(spend.balance_after) === finalBalance + && spend.payment_id == null, + 'credits game-entry ledger row is not exact'); + + const grantResult = await db.query(` + SELECT source, credits_granted, purchase_progress_granted, + race_entries_granted, packs_granted, premium_level_granted, + status, credits_reversed, purchase_progress_reversed, + race_entries_reversed + FROM payment_entitlement_grants WHERE payment_id = $1 AND user_id = $2 + `, [context.paymentId, userId]); + const grant = grantResult.rows[0]; + assert(grantResult.rows.length === 1 && grant.source === 'product_confirmation' + && grant.status === 'active' + && Number(grant.credits_granted) === context.grantedCredits + && Number(grant.purchase_progress_granted) === context.grantedCredits + && Number(grant.race_entries_granted) === 0 + && Array.isArray(grant.packs_granted) && grant.packs_granted.length === 0 + && grant.premium_level_granted == null + && Number(grant.credits_reversed) === 0 + && Number(grant.purchase_progress_reversed) === 0 + && Number(grant.race_entries_reversed) === 0, + 'payment-scoped credits entitlement marker is not exact'); +} + +async function assertDatabaseSettlement(db, context) { + await assertExactDatabaseCounts(db, context.scenario); + const paymentUserId = await assertPaymentAndReceipt(db, context); + const gameUserId = await assertGameAndPayout(db, context); + assert(String(paymentUserId) === String(gameUserId), + 'payment and game resolved to different stable users'); + await assertCreditLedger(db, context, paymentUserId); + phase('exact receipt, entitlement, credit, game, liability, and payout rows verified'); +} + +function assertModeInfo(info, config) { + assert(info?.cryptoType === 'XMR' + && info.network === 'stagenet' + && info.isTestNetwork === true + && info.currencyLabel === 'sXMR' + && info.gameName === 'Monerogue', + 'Socket.IO product identity is not Monerogue XMR stagenet'); + assert(info.paymentsEnabled && info.payoutsEnabled + && info.directModeEnabled && info.creditsModeEnabled + && info.directPayoutsEnabled && info.creditsPayoutsEnabled, + 'mixed direct/credits mode and both payout paths must be enabled'); + assert(info.smirkEnabled === false, 'Smirk must be disabled on stagenet'); + for (const key of ['direct', 'credits']) { + assert(Number(info.payoutMultipliers?.[key]?.escape) === 2 + && Number(info.payoutMultipliers?.[key]?.escapeWithTreasure) === 3, + `${key} Socket.IO payout multipliers are not exact 2x/3x`); + } + assert(/^\d+$/.test(String(info.singleGamePrice)) + && BigInt(String(info.singleGamePrice)) > 0n + && BigInt(String(info.singleGamePrice)) * 3n <= BigInt(Number.MAX_SAFE_INTEGER), + 'Socket.IO direct price or its 3x result is not a safe positive atomic value'); + + const selected = (info.creditPackages || []).find(item => item.id === config.packageId); + assert(selected, 'selected credits package is not advertised'); + const grantedCredits = Number(selected.grants?.credits); + const creditsPerGame = Number(info.creditsPerGame); + assert(Number.isSafeInteger(grantedCredits) && Number.isSafeInteger(creditsPerGame) + && grantedCredits >= creditsPerGame && creditsPerGame > 0, + 'selected package cannot fund exactly one credits game'); + assert(Number(selected.grants?.raceEntries || 0) === 0 + && Array.isArray(selected.grants?.packs) && selected.grants.packs.length === 0 + && selected.grants?.premiumLevel == null, + 'financial canary requires a pure credits-only package'); + assert(/^\d+$/.test(String(selected.price)) && BigInt(String(selected.price)) > 0n, + 'selected package has no positive atomic price'); + assert(/^\d+$/.test(String(info.creditsPayoutBaseValue)) + && BigInt(String(info.creditsPayoutBaseValue)) > 0n + && BigInt(String(info.creditsPayoutBaseValue)) * 3n <= BigInt(Number.MAX_SAFE_INTEGER), + 'credits payout base or its 3x result is not a safe positive atomic value'); + return Object.freeze({ + ...selected, + grantedCredits, + creditsPerGame, + payoutBase: BigInt(String(info.creditsPayoutBaseValue)) + }); +} + +async function waitForPaymentConfirmation(journal, paymentId, timeoutMs) { + const outcome = await journal.waitAny([ + { name: 'payment_confirmed', test: data => String(data?.paymentId) === String(paymentId) }, + { name: 'payment_review_required' }, + { name: 'payment_underpaid' }, + { name: '$disconnect' } + ], timeoutMs); + assert(outcome.name === 'payment_confirmed', `payment was not confirmed (${outcome.name})`); + phase('exact transfer became a receipt-backed confirmed payment'); + return outcome.data || {}; +} + +async function startCreditsGame(socket, journal, acknowledgement) { + const offer = await requestFreshFairness(socket, journal); + const clientSeed = crypto.randomBytes(32).toString('hex'); + const after = journal.seq; + socket.emit('auto_start', { + fairnessOfferId: offer.offerId, + clientSeed, + legalAcknowledgement: acknowledgement + }); + const outcome = await journal.waitAny([ + { name: 'game_start' }, + { name: 'fairness_error' }, + { name: 'status_update', test: data => data?.type === 'error' || data?.status === 'error' }, + { name: '$disconnect' } + ], 60000, after); + assert(outcome.name === 'game_start', `credits game did not start (${outcome.name})`); + assert(outcome.data?.proof?.offerId === offer.offerId + && outcome.data?.proof?.commitment === offer.commitment + && outcome.data?.proof?.clientSeed === clientSeed, + 'credits game did not consume the fresh fairness offer'); + return { gameStart: outcome, offer, clientSeed }; +} + +async function runLive(config, modules, db, legal, env = process.env) { + assertLiveSafety(config, env); + const funding = createFundingRpc(modules, env); + const fundingIdentity = await fundingWalletPreflight(funding); + const transferGate = { attempted: false }; + const socket = modules.io(config.target.origin, { + autoConnect: false, + transports: ['websocket', 'polling'], + timeout: 12000, + reconnection: false + }); + const journal = new EventJournal(socket); + + try { + socket.connect(); + await waitForConnect(socket); + socket.emit('register_client', { + clientId: socket.id, + userAgent: 'monerogue-stagenet-financial-canary/1' + }); + const sessionEvent = await journal.waitAny([{ name: 'session_token' }], 15000); + const token = String(sessionEvent.data?.token || ''); + assert(token.length >= 32, 'anonymous canary session token was not issued'); + const modeEvent = await journal.waitAny([{ name: 'game_mode_info' }], 15000); + const modeInfo = modeEvent.data || {}; + const packageInfo = assertModeInfo(modeInfo, config); + + // Refetch immediately before the value-bearing action so the echoed version is current. + const currentLegal = await fetchCanonicalAcknowledgement(config); + assert(currentLegal.disclosure.policyVersion === legal.disclosure.policyVersion, + 'commerce policy changed during preflight; no invoice was created'); + const acknowledgement = currentLegal.acknowledgement; + await savePayoutAddress(socket, journal, fundingIdentity.address); + + let context; + if (config.scenario.id === 'direct-2x') { + const direct = await createDirectInvoice(socket, journal, acknowledgement, config, modeInfo); + context = { + scenario: config.scenario, + paymentId: direct.invoice.paymentId, + invoiceAddress: direct.invoice.address, + payoutAddress: fundingIdentity.address, + entryAmount: direct.amount, + payoutBase: direct.amount, + offer: direct.offer, + clientSeed: direct.clientSeed + }; + await assertSeparateFundingWallet(funding, direct.invoice.address); + await sendExactEntry(funding, direct.invoice.address, direct.amount, config, transferGate); + await waitForPaymentConfirmation(journal, direct.invoice.paymentId, config.paymentTimeoutMs); + const gameStart = await journal.waitAny([ + { name: 'game_start' }, { name: '$disconnect' } + ], 60000); + assert(gameStart.name === 'game_start', 'direct paid game did not start'); + assert(gameStart.data?.proof?.offerId === direct.offer.offerId + && gameStart.data?.proof?.commitment === direct.offer.commitment + && gameStart.data?.proof?.clientSeed === direct.clientSeed, + 'started direct dungeon is not bound to its invoice fairness proof'); + context.gameStart = gameStart; + } else { + const purchase = await createCreditsInvoice(socket, journal, acknowledgement, + config, packageInfo); + context = { + scenario: config.scenario, + paymentId: purchase.invoice.paymentId, + invoiceAddress: purchase.invoice.address, + payoutAddress: fundingIdentity.address, + entryAmount: purchase.amount, + payoutBase: purchase.payoutBase, + packageId: config.packageId, + grantedCredits: purchase.grantedCredits, + creditsPerGame: purchase.creditsPerGame + }; + await assertSeparateFundingWallet(funding, purchase.invoice.address); + await sendExactEntry(funding, purchase.invoice.address, purchase.amount, config, transferGate); + const confirmation = await waitForPaymentConfirmation(journal, + purchase.invoice.paymentId, config.paymentTimeoutMs); + assert(Number(confirmation.creditsAdded) === purchase.grantedCredits + && Number(confirmation.newBalance) === purchase.grantedCredits, + 'credits confirmation did not grant the exact advertised balance'); + const creditUpdate = journal.latest('credits_update', data => + Number(data?.balance) === purchase.grantedCredits); + assert(creditUpdate, 'credits balance update was not observed after package confirmation'); + const started = await startCreditsGame(socket, journal, acknowledgement); + context.offer = started.offer; + context.clientSeed = started.clientSeed; + context.gameStart = started.gameStart; + } + + const gameOver = await runBoundedBot(socket, journal, context.gameStart, + config.scenario, config); + const completion = verifyGameOver(gameOver, context, context.gameStart.data); + context.payoutId = completion.payoutId; + context.payoutAmount = completion.expected; + context.gameId = completion.gameId; + + const paymentHistory = await ownedHistory(config, socket.id, token, 'payments'); + assert(Number(paymentHistory.total) === 1 && paymentHistory.currency === 'XMR', + 'canary identity does not have exactly one XMR payment'); + const paymentRow = (paymentHistory.payments || []).find(row => + String(row.id) === String(context.paymentId)); + assert(paymentRow?.status === 'confirmed' + && paymentRow.type === config.scenario.paymentType + && BigInt(String(paymentRow.amount)) === context.entryAmount, + 'owned payment history differs from the confirmed invoice'); + if (config.scenario.id === 'credits-3x') { + assert(Number(paymentRow.creditsReceived) === context.grantedCredits, + 'owned payment history has the wrong credit grant'); + } + + const payoutRow = await waitForCompletedPayout(config, socket.id, token, completion); + await waitForIncomingPayout(funding, payoutRow.txHash, completion.expected, config); + + const credits = await jsonRequest(config.target, + `/api/user/${encodeURIComponent(socket.id)}/credits`, { + headers: { 'X-Session-Token': token } + }); + assert(credits.response.status === 200, 'owned credits endpoint is not HTTP 200'); + const expectedCredits = config.scenario.id === 'direct-2x' + ? 0 + : context.grantedCredits - context.creditsPerGame; + const expectedProgress = config.scenario.id === 'direct-2x' + ? 1 + : context.grantedCredits; + assert(Number(credits.body?.credits) === expectedCredits + && Number(credits.body?.totalCreditsPurchased) === expectedProgress, + 'owned credits endpoint differs from the exact scenario ledger'); + + await assertDatabaseSettlement(db, context); + phase(`PASS: ${config.scenario.id} completed with one payment, one game, and one payout`); + } finally { + try { socket.close(); } catch (_) { /* best effort */ } + } +} + +function printHelp() { + process.stdout.write(`Monerogue XMR stagenet financial canary\n\n` + + `Modes: preflight | database-preflight | live-stagenet\n` + + `Scenarios: direct-2x | credits-3x\n\n` + + `This harness is localhost-only, stagenet-only, fresh-database-only, and one-transfer-only.\n` + + `Read ../docs/STAGENET_FINANCIAL_CANARY.md before live use.\n`); +} + +async function main(env = process.env, argv = process.argv.slice(2)) { + if (argv.includes('--help') || argv.includes('-h')) { + printHelp(); + return; + } + assert(argv.length === 0, 'only --help is accepted; configure the canary with E2E_* environment variables'); + const config = readConfiguration(env); + if (config.mode === 'live-stagenet') assertLiveSafety(config, env); + if (config.mode === 'database-preflight') { + const modules = loadAppModules(); + const db = await openReadOnlyDatabase(modules, config, env); + try { + await assertEmptyFinancialDatabase(db); + phase('database-preflight made no app session, invoice, or wallet RPC call'); + } finally { + await db.end().catch(() => {}); + } + return; + } + + const legal = await publicPreflight(config); + if (config.mode === 'preflight') { + phase('preflight made no session, invoice, database mutation, or wallet RPC call'); + return; + } + + const modules = loadAppModules(); + const db = await openReadOnlyDatabase(modules, config, env); + try { + await assertEmptyFinancialDatabase(db); + // This is the final gate before runLive can touch the funding wallet, create an invoice, + // or reach the harness's sole non-retriable transfer call. + await verifyCanaryDatabaseHandshake(config, db, env); + await runLive(config, modules, db, legal, env); + } finally { + await db.end().catch(() => {}); + } +} + +if (require.main === module) { + main().catch(error => { + process.stderr.write(`[stagenet-canary] FAIL: ${safeMessage(error.message)}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + EMPTY_FINANCIAL_KEYS, + LIVE_CONFIRM, + REQUIRED_MIGRATIONS, + SAFE_PROFILE_CONFIRM, + SCENARIOS, + EventJournal, + addVisible, + assertEmptyFinancialDatabase, + assertInvoiceNotOwned, + assertLiveSafety, + assertLocalUrl, + blockedKeys, + canonicalAcknowledgement, + chooseMove, + main, + objectivePoint, + readConfiguration, + safeMessage, + validateExpectedDatabaseName, + verifyCanaryDatabaseHandshake, + verifyGameOver +}; diff --git a/src/services/alertService.js b/src/services/alertService.js index 1e4c503..b26ce3d 100644 --- a/src/services/alertService.js +++ b/src/services/alertService.js @@ -6,8 +6,37 @@ * - Failed payouts * - Wallet disconnection * - High pending payout queue + * - Manual-review and stale financial outbox rows */ +const money = require('../money/atomic'); + +function positiveAtomic(value, fallback) { + try { + const input = value == null || value === '' ? fallback : String(value).replace(/_/g, ''); + const exact = money.toBig(input); + return exact > 0n ? exact : money.toBig(fallback); + } catch (_) { + return money.toBig(fallback); + } +} + +function formatAtomicFixed(value, decimals, digits) { + const exact = money.toBig(value); + const negative = exact < 0n; + const abs = negative ? -exact : exact; + const scale = 10n ** BigInt(decimals); + const whole = abs / scale; + const fraction = (abs % scale).toString().padStart(decimals, '0') + .slice(0, digits).padEnd(digits, '0'); + return `${negative ? '-' : ''}${whole}${digits > 0 ? `.${fraction}` : ''}`; +} + +function positiveInteger(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; +} + class AlertService { constructor({ walletService, db, debugManager }) { this.walletService = walletService; @@ -27,9 +56,16 @@ class AlertService { // Balance thresholds (atomic units) // BALANCE_WARN: sends email alert // BALANCE_CRITICAL: sends email AND halts new games - this.balanceWarnThreshold = parseInt(process.env.BALANCE_WARN) || parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR default - this.balanceCriticalThreshold = parseInt(process.env.BALANCE_CRITICAL) || 10000000000; // 0.01 XMR default + this.balanceWarnThreshold = positiveAtomic( + process.env.BALANCE_WARN || process.env.LOW_BALANCE_THRESHOLD, + 100000000000n + ); // 0.1 XMR default + this.balanceCriticalThreshold = positiveAtomic(process.env.BALANCE_CRITICAL, 10000000000n); // 0.01 XMR default this.highPendingThreshold = parseInt(process.env.HIGH_PENDING_THRESHOLD) || 10; + this.financialReviewStaleMs = positiveInteger( + process.env.FINANCIAL_REVIEW_STALE_MS, + 15 * 60 * 1000 + ); // Track critical balance state for game halt this.isBalanceCritical = false; @@ -140,14 +176,12 @@ class AlertService { try { const balance = await this.walletService.getBalance(); - const unlockedBalance = balance.unlocked_balance || 0; + const unlockedBalance = money.toBig(balance.unlocked_balance ?? 0); this.lastBalanceCheck = Date.now(); const cryptoType = process.env.CRYPTO_TYPE || 'XMR'; const decimals = cryptoType === 'WOW' ? 11 : 12; - const divisor = Math.pow(10, decimals); - - const balanceFormatted = (unlockedBalance / divisor).toFixed(6); + const balanceFormatted = formatAtomicFixed(unlockedBalance, decimals, 6); // Check CRITICAL threshold first (more severe) if (unlockedBalance < this.balanceCriticalThreshold) { @@ -157,7 +191,7 @@ class AlertService { console.warn(`🚨 CRITICAL: Wallet balance is critically low (${balanceFormatted} ${cryptoType}). New games are HALTED.`); } - const thresholdFormatted = (this.balanceCriticalThreshold / divisor).toFixed(6); + const thresholdFormatted = formatAtomicFixed(this.balanceCriticalThreshold, decimals, 6); await this.sendAlert('balance_critical', { subject: `🚨 CRITICAL: Wallet Balance Depleted - ${balanceFormatted} ${cryptoType}`, @@ -188,7 +222,7 @@ class AlertService { console.log(`✅ Wallet balance restored above critical threshold. Games can resume.`); } - const thresholdFormatted = (this.balanceWarnThreshold / divisor).toFixed(6); + const thresholdFormatted = formatAtomicFixed(this.balanceWarnThreshold, decimals, 6); await this.sendAlert('balance_warn', { subject: `⚠️ Wallet Balance Low - ${balanceFormatted} ${cryptoType}`, @@ -198,7 +232,7 @@ class AlertService {
  • Current Balance: ${balanceFormatted} ${cryptoType}
  • Warning Threshold: ${thresholdFormatted} ${cryptoType}
  • -
  • Critical Threshold: ${(this.balanceCriticalThreshold / divisor).toFixed(6)} ${cryptoType}
  • +
  • Critical Threshold: ${formatAtomicFixed(this.balanceCriticalThreshold, decimals, 6)} ${cryptoType}

Please top up the wallet soon to avoid service interruption.

If balance drops below the critical threshold, new games will be automatically halted.

@@ -219,6 +253,9 @@ class AlertService { this.resolveAlert('balance_warn'); } } catch (error) { + // Unknown balance is not evidence of sufficient reserves. Admission remains + // closed until a later exact wallet reading proves the reserve is healthy. + this.isBalanceCritical = true; console.error('Failed to check wallet balance for alerts:', error.message); } } @@ -242,13 +279,13 @@ class AlertService { try { const balance = await this.walletService.getBalance(); - const unlockedBalance = balance.unlocked_balance || 0; + const unlockedBalance = money.toBig(balance.unlocked_balance ?? 0); if (unlockedBalance < this.balanceCriticalThreshold) { this.isBalanceCritical = true; const cryptoType = process.env.CRYPTO_TYPE || 'XMR'; const decimals = cryptoType === 'WOW' ? 11 : 12; - const balanceFormatted = (unlockedBalance / Math.pow(10, decimals)).toFixed(4); + const balanceFormatted = formatAtomicFixed(unlockedBalance, decimals, 4); return { halted: true, reason: `Sorry, the house balance is too low to initiate new games (${balanceFormatted} ${cryptoType}). Please try again later.` @@ -259,8 +296,8 @@ class AlertService { return { halted: false }; } catch (error) { console.error('Failed to check balance for game start:', error.message); - // On error, don't halt games - let them proceed - return { halted: false }; + this.isBalanceCritical = true; + return { halted: true, reason: 'Wallet balance could not be verified. Please try again later.' }; } } @@ -348,13 +385,88 @@ class AlertService { } } + /** + * Alert on ambiguous transfer outcomes and financial outbox rows that have stopped moving. + * These are visibility alerts only: neither state is changed and no transfer is retried. + */ + async checkFinancialReviewRows() { + if (!this.db) return; + + try { + const result = await this.db.query(` + SELECT + (SELECT COUNT(*) FROM payouts + WHERE status = 'needs_review') AS payout_review_count, + (SELECT COUNT(*) FROM payment_refunds + WHERE status = 'needs_review') AS refund_review_count, + (SELECT COUNT(*) FROM payouts + WHERE status = 'processing' + AND COALESCE(last_retry_at, created_at) + < NOW() - ($1::bigint * INTERVAL '1 millisecond')) + AS stale_payout_processing_count, + (SELECT COUNT(*) FROM payment_refunds + WHERE status IN ('requested', 'processing') + AND COALESCE(processing_started_at, requested_at, updated_at, created_at) + < NOW() - ($1::bigint * INTERVAL '1 millisecond')) + AS stale_refund_nonterminal_count + `, [this.financialReviewStaleMs]); + + const row = result.rows[0] || {}; + const payoutReviewCount = parseInt(row.payout_review_count) || 0; + const refundReviewCount = parseInt(row.refund_review_count) || 0; + const stalePayoutCount = parseInt(row.stale_payout_processing_count) || 0; + const staleRefundCount = parseInt(row.stale_refund_nonterminal_count) || 0; + const reviewCount = payoutReviewCount + refundReviewCount; + const staleCount = stalePayoutCount + staleRefundCount; + + if (reviewCount > 0) { + await this.sendAlert('financial_needs_review', { + subject: `🚨 Financial transfers need review: ${reviewCount}`, + level: 'crit', + body: `${payoutReviewCount} payout(s) and ${refundReviewCount} refund(s) are in needs_review. Do not retry them; reconcile against wallet history and transaction evidence.`, + html: ` +

Financial transfers require manual review

+
    +
  • Payouts: ${payoutReviewCount}
  • +
  • Refunds: ${refundReviewCount}
  • +
+

Do not resend these transfers. Reconcile each row against wallet history and transaction evidence.

+ ` + }); + } else { + await this.resolveAlert('financial_needs_review'); + } + + if (staleCount > 0) { + const staleMinutes = Math.ceil(this.financialReviewStaleMs / 60000); + await this.sendAlert('financial_nonterminal_stale', { + subject: `🚨 Stale financial transfers: ${staleCount}`, + level: 'crit', + body: `${stalePayoutCount} payout processing row(s) and ${staleRefundCount} refund requested/processing row(s) have not moved for at least ${staleMinutes} minutes. Treat processing as an ambiguous wallet outcome.`, + html: ` +

Financial transfers are stale

+
    +
  • Processing payouts: ${stalePayoutCount}
  • +
  • Requested/processing refunds: ${staleRefundCount}
  • +
  • Age threshold: ${staleMinutes} minutes
  • +
+

A processing row may represent a broadcast transfer. Do not retry it until wallet reconciliation proves no transfer occurred.

+ ` + }); + } else { + await this.resolveAlert('financial_nonterminal_stale'); + } + } catch (error) { + console.error('Failed to check financial review rows for alerts:', error.message); + } + } + /** * Alert when a payout permanently fails */ async alertPayoutFailed(payout) { const cryptoType = process.env.CRYPTO_TYPE || 'XMR'; - const divisor = cryptoType === 'WOW' ? 1e11 : 1e12; - const amountFormatted = (payout.amount / divisor).toFixed(6); + const amountFormatted = formatAtomicFixed(payout.amount, cryptoType === 'WOW' ? 11 : 12, 6); await this.sendAlert('payout_failed', { subject: `💀 Payout Permanently Failed - ${amountFormatted} ${cryptoType}`, @@ -386,6 +498,7 @@ class AlertService { await this.checkWalletBalance(); await this.checkWalletConnection(); await this.checkPendingPayouts(); + await this.checkFinancialReviewRows(); } /** diff --git a/src/services/canaryDatabaseIdentity.js b/src/services/canaryDatabaseIdentity.js new file mode 100644 index 0000000..228d011 --- /dev/null +++ b/src/services/canaryDatabaseIdentity.js @@ -0,0 +1,177 @@ +'use strict'; + +/* + * Database/process identity handshake for the isolated XMR stagenet canary. + * + * The endpoint is absent in every normal deployment. When explicitly enabled, it reads the + * PostgreSQL cluster id, database OID, and database name through the application's own pool and + * signs that exact tuple plus a caller challenge with a one-run nonce. The financial canary reads + * the same tuple through its independent read-only connection and verifies the signature before it + * is allowed to create an invoice or ask the funding wallet to broadcast. + */ + +const crypto = require('crypto'); +const fs = require('fs'); + +const CANARY_HANDSHAKE_CONFIRM = 'I_AM_AN_ISOLATED_XMR_STAGENET_CANARY'; +const CANARY_HANDSHAKE_PROTOCOL = 'wowngeon-canary-database-identity-v1'; +const HEX_256 = /^[0-9a-f]{64}$/; +const SIMPLE_DATABASE = /^[a-z0-9_]+$/i; + +function invariant(condition, message) { + if (!condition) throw new Error(message); +} + +function isCanaryDatabaseName(name) { + const value = String(name || ''); + return SIMPLE_DATABASE.test(value) + && /canary/i.test(value) + && /e2e/i.test(value) + && (/(?:^|_)direct(?:_|$)/i.test(value) || /(?:^|_)credits(?:_|$)/i.test(value)); +} + +function isLoopbackAddress(address) { + const value = String(address || '').toLowerCase(); + return value === '127.0.0.1' + || value === '::1' + || value === '::ffff:127.0.0.1'; +} + +function readNonceFile(fileName, label = 'canary database nonce file') { + invariant(typeof fileName === 'string' && fileName.startsWith('/'), + `${label} must be an absolute path`); + const descriptor = fs.openSync(fileName, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const stat = fs.fstatSync(descriptor); + invariant(stat.isFile(), `${label} must be a regular non-symlink file`); + invariant((stat.mode & 0o077) === 0, `${label} must not be group/world accessible`); + const nonce = fs.readFileSync(descriptor, 'utf8').trim(); + invariant(HEX_256.test(nonce), `${label} must contain exactly 256 bits of lowercase hex`); + return nonce; + } finally { + fs.closeSync(descriptor); + } +} + +function normalizeIdentity(value) { + const identity = { + clusterId: String(value?.cluster_id ?? value?.clusterId ?? ''), + databaseOid: String(value?.database_oid ?? value?.databaseOid ?? ''), + databaseName: String(value?.database_name ?? value?.databaseName ?? '') + }; + invariant(/^\d+$/.test(identity.clusterId), 'database cluster identity is unavailable'); + invariant(/^\d+$/.test(identity.databaseOid), 'database OID is unavailable'); + invariant(SIMPLE_DATABASE.test(identity.databaseName), 'database name is invalid'); + return Object.freeze(identity); +} + +async function readDatabaseIdentity(db) { + invariant(db && typeof db.query === 'function', 'database query interface is unavailable'); + const result = await db.query(` + SELECT controls.system_identifier::text AS cluster_id, + databases.oid::text AS database_oid, + current_database() AS database_name + FROM pg_catalog.pg_database AS databases + CROSS JOIN pg_catalog.pg_control_system() AS controls + WHERE databases.datname = current_database() + `); + invariant(result?.rows?.length === 1, 'database identity query returned an ambiguous result'); + return normalizeIdentity(result.rows[0]); +} + +function proofPayload(identity, challenge) { + const normalized = normalizeIdentity(identity); + invariant(HEX_256.test(String(challenge || '')), 'canary challenge must be 256-bit lowercase hex'); + return [ + CANARY_HANDSHAKE_PROTOCOL, + normalized.clusterId, + normalized.databaseOid, + normalized.databaseName, + challenge + ].join('\0'); +} + +function computeProof(nonce, identity, challenge) { + invariant(HEX_256.test(String(nonce || '')), 'canary nonce is invalid'); + return crypto.createHmac('sha256', Buffer.from(nonce, 'hex')) + .update(proofPayload(identity, challenge), 'utf8') + .digest('hex'); +} + +function equalProof(actual, expected) { + if (!HEX_256.test(String(actual || '')) || !HEX_256.test(String(expected || ''))) { + return false; + } + return crypto.timingSafeEqual(Buffer.from(actual, 'hex'), Buffer.from(expected, 'hex')); +} + +function configuredCanaryHandshake(db, env = process.env) { + const relevant = [ + env.CANARY_DATABASE_HANDSHAKE, + env.CANARY_DATABASE_NONCE_FILE, + env.CANARY_EXPECT_DATABASE + ].some(value => String(value || '').trim() !== ''); + if (!relevant) return null; + + invariant(env.CANARY_DATABASE_HANDSHAKE === CANARY_HANDSHAKE_CONFIRM, + `CANARY_DATABASE_HANDSHAKE must equal ${CANARY_HANDSHAKE_CONFIRM}`); + invariant(String(env.NODE_ENV || '').toLowerCase() === 'production', + 'canary database handshake requires NODE_ENV=production'); + invariant(String(env.CRYPTO_TYPE || '').toUpperCase() === 'XMR' + && String(env.MONERO_NETWORK || '').toLowerCase() === 'stagenet', + 'canary database handshake requires XMR stagenet'); + invariant(String(env.PORT || '') === '3102', + 'canary database handshake requires the dedicated application port 3102'); + + const expectedDatabase = String(env.CANARY_EXPECT_DATABASE || '').trim(); + invariant(isCanaryDatabaseName(expectedDatabase), + 'CANARY_EXPECT_DATABASE must be a scenario-named canary E2E database'); + invariant(String(env.DB_NAME || '').trim() === expectedDatabase, + 'application DB_NAME differs from CANARY_EXPECT_DATABASE'); + const nonce = readNonceFile(String(env.CANARY_DATABASE_NONCE_FILE || '').trim()); + + return Object.freeze({ db, expectedDatabase, nonce }); +} + +function createCanaryDatabaseIdentityHandler({ db, env = process.env } = {}) { + const configured = configuredCanaryHandshake(db, env); + if (!configured) return null; + + return async function canaryDatabaseIdentity(req, res) { + // Use the TCP peer, never proxy-derived req.ip. Even an accidentally routed canary must + // not disclose its PostgreSQL identity to a non-loopback caller. + if (!isLoopbackAddress(req.socket?.remoteAddress)) { + return res.status(404).json({ error: 'Not found.' }); + } + const challenge = String(req.get('X-Canary-Database-Challenge') || ''); + if (!HEX_256.test(challenge)) { + return res.status(400).json({ error: 'A valid canary database challenge is required.' }); + } + + const identity = await readDatabaseIdentity(configured.db); + if (identity.databaseName !== configured.expectedDatabase) { + return res.status(503).json({ error: 'Canary database identity mismatch.' }); + } + + res.json({ + protocol: CANARY_HANDSHAKE_PROTOCOL, + challenge, + database: identity, + proof: computeProof(configured.nonce, identity, challenge) + }); + }; +} + +module.exports = { + CANARY_HANDSHAKE_CONFIRM, + CANARY_HANDSHAKE_PROTOCOL, + computeProof, + configuredCanaryHandshake, + createCanaryDatabaseIdentityHandler, + equalProof, + isCanaryDatabaseName, + isLoopbackAddress, + normalizeIdentity, + readDatabaseIdentity, + readNonceFile +}; diff --git a/src/services/healthService.js b/src/services/healthService.js new file mode 100644 index 0000000..ab500c5 --- /dev/null +++ b/src/services/healthService.js @@ -0,0 +1,89 @@ +/** Build the public, non-sensitive health payload used by probes and the status UI. */ +function buildPublicHealth(input = {}) { + const sanitizeIdentity = (identity, required) => { + const pair = (value) => value && typeof value === 'object' ? { + cryptoType: typeof value.cryptoType === 'string' ? value.cryptoType : null, + network: typeof value.network === 'string' ? value.network : null + } : null; + return { + required: Boolean(required), + verified: identity?.verified === true, + expected: pair(identity?.expected), + actual: pair(identity?.actual) + }; + }; + const databaseReady = Boolean(input.databaseReady); + const hasBlock = Number(input.blockHeight) > 0; + const paymentsEnabled = Boolean(input.paymentsEnabled); + const payoutsEnabled = Boolean(input.payoutsEnabled); + const walletRequired = input.walletRequired === undefined + ? (paymentsEnabled || payoutsEnabled) + : Boolean(input.walletRequired); + const identityRequired = Boolean(input.identityRequired); + const daemonIdentity = input.daemonIdentity || input.chainIdentity || null; + const daemonIdentityRequired = identityRequired && !Boolean(input.simulatedBlocks); + const walletIdentityRequired = identityRequired && walletRequired; + const chainIdentityReady = !daemonIdentityRequired || daemonIdentity?.verified === true; + const walletIdentityReady = !walletIdentityRequired || input.walletIdentity?.verified === true; + const chainReady = Boolean(input.simulatedBlocks) + || (hasBlock && input.chainHealthy !== false && chainIdentityReady); + const walletReady = !walletRequired + || (Boolean(input.walletHealthy) && walletIdentityReady); + // The composition root supplies false until every startup financial reconciliation pass + // has committed or proved there was nothing to do. Default true keeps this helper usable by + // callers that do not own durable financial state. + const financialRecoveryReady = input.financialRecoveryReady !== false; + const ready = databaseReady && chainReady && walletReady && financialRecoveryReady; + + const blockHeight = Number(input.blockHeight || 0); + const network = daemonIdentity?.verified === true && daemonIdentity?.actual?.network + ? daemonIdentity.actual.network + : (input.network || 'mainnet'); + const gameMode = input.gameMode || 'FREE'; + + return { + status: ready ? 'ok' : 'degraded', + ready, + timestamp: new Date(input.now || Date.now()).toISOString(), + uptime: Number(input.uptime || 0), + checks: { + database: databaseReady ? 'up' : 'down', + chain: chainReady ? 'up' : 'down', + wallet: walletRequired ? (walletReady ? 'up' : 'down') : 'not_required', + financialRecovery: financialRecoveryReady ? 'reconciled' : 'pending', + daemonIdentity: daemonIdentityRequired + ? (chainIdentityReady ? 'verified' : 'unverified') + : 'not_required', + walletIdentity: walletIdentityRequired + ? (walletIdentityReady ? 'verified' : 'unverified') + : 'not_required' + }, + chain: { + height: blockHeight, + network, + source: input.blockSource || 'daemon' + }, + games: { + active: Number(input.activeGames || 0), + queued: Number(input.queuedGames || 0), + connected: Number(input.connectedPlayers || 0), + mode: gameMode + }, + money: { + paymentsEnabled, + payoutsEnabled + }, + identities: { + daemon: sanitizeIdentity(daemonIdentity, daemonIdentityRequired), + wallet: sanitizeIdentity(input.walletIdentity, walletIdentityRequired) + }, + // Compatibility aliases used by existing smoke probes and lightweight status clients. + blockHeight, + network, + gameMode, + paymentsEnabled, + walletHealthy: walletRequired ? walletReady : Boolean(input.walletHealthy) + }; +} + +module.exports = { buildPublicHealth }; diff --git a/src/services/latePaymentReconciler.js b/src/services/latePaymentReconciler.js new file mode 100644 index 0000000..947b522 --- /dev/null +++ b/src/services/latePaymentReconciler.js @@ -0,0 +1,124 @@ +const money = require('../money/atomic'); + +function boundedInteger(value, fallback, min, max) { + const parsed = Number.parseInt(String(value ?? ''), 10); + return Number.isInteger(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback; +} + +/** + * Finds recently expired invoices whose transfer may have mined after the browser watcher + * stopped. It never confirms a payment or applies an entitlement: valid durable receipts are + * attached to the expired payment and surfaced through payment_late_reviews for manual handling. + */ +class LatePaymentReconciler { + constructor({ db, gameModeManager, walletService, logger = console, + lookbackHours = 72, batchSize = 100 } = {}) { + this.db = db; + this.gameModeManager = gameModeManager; + this.walletService = walletService; + this.logger = logger; + this.lookbackHours = boundedInteger(lookbackHours, 72, 1, 168); + this.batchSize = boundedInteger(batchSize, 100, 1, 500); + this.running = false; + } + + async _nativeStatus(payment) { + if (!this.walletService || typeof this.walletService.checkPaymentStatus !== 'function') { + throw new Error('Native wallet receipt checker is unavailable'); + } + const address = payment.subaddress; + const priorUser = this.walletService.addressToUser?.get(address); + const priorSocket = this.walletService.addressToSocket?.get(address); + this.walletService.addressToUser?.set(address, { + userId: String(payment.user_id), + socketId: `late-reconcile:${payment.id}`, + amount: money.toSafe(money.toBig(payment.expected_amount)), + addressIndex: payment.address_index, + accountIndex: this.walletService.accountIndex ?? 0, + detected: false, + confirmed: false, + status: 'expired' + }); + try { + return await this.walletService.checkPaymentStatus(address); + } finally { + if (priorUser) this.walletService.addressToUser?.set(address, priorUser); + else this.walletService.addressToUser?.delete(address); + if (priorSocket !== undefined) this.walletService.addressToSocket?.set(address, priorSocket); + else this.walletService.addressToSocket?.delete(address); + } + } + + async _status(payment) { + const providerId = payment.provider_id || 'native-monero'; + if (providerId === 'native-monero') return this._nativeStatus(payment); + const provider = this.gameModeManager?.paymentProviders?.get?.(providerId); + if (!provider || typeof provider.getWalletStatus !== 'function') { + throw new Error(`Payment provider ${providerId} cannot reconcile invoice status`); + } + return provider.getWalletStatus({ + invoiceId: payment.provider_invoice_id, + address: payment.subaddress + }); + } + + _stopExpiredWatch(payment) { + const providerId = payment.provider_id || 'native-monero'; + const provider = this.gameModeManager?.paymentProviders?.get?.(providerId); + const ref = providerId === 'native-monero' + ? payment.subaddress + : (payment.provider_invoice_id || payment.subaddress); + try { provider?.stopWatch?.(ref); } catch (_) { /* best effort */ } + try { this.walletService?.stopPaymentMonitoring?.(payment.subaddress); } catch (_) { /* best effort */ } + this.walletService?.addressToUser?.delete(payment.subaddress); + this.walletService?.addressToSocket?.delete(payment.subaddress); + } + + async runOnce() { + if (this.running) return { skipped: true, checked: 0, reviews: 0, errors: 0 }; + this.running = true; + try { + const candidates = await this.db.query(` + SELECT id, user_id, status, subaddress, address_index, expected_amount, + provider_id, provider_invoice_id, expires_at + FROM payments + WHERE status IN ('pending', 'expired') + AND expires_at <= NOW() + AND expires_at >= NOW() - ($1::integer * INTERVAL '1 hour') + AND (late_receipt_checked_at IS NULL + OR late_receipt_checked_at < NOW() - INTERVAL '5 minutes') + ORDER BY late_receipt_checked_at ASC NULLS FIRST, expires_at ASC + LIMIT $2 + `, [this.lookbackHours, this.batchSize]); + + let checked = 0; + let reviews = 0; + let errors = 0; + for (const payment of candidates.rows) { + try { + const status = await this._status(payment); + const result = await this.gameModeManager.reconcileLatePaymentForReview( + payment.id, + Array.isArray(status?.receipts) ? status.receipts : [] + ); + if (result?.checked) { + checked += 1; + this._stopExpiredWatch(payment); + } + if (result?.needsReview) { + reviews += 1; + this.logger.warn?.(`[LatePayment] Payment ${payment.id} has durable late receipt evidence and requires review`); + } + } catch (error) { + errors += 1; + this.logger.error?.(`[LatePayment] Reconciliation failed for payment ${payment.id}: ${error.message}`); + } + } + return { skipped: false, checked, reviews, errors }; + } finally { + this.running = false; + } + } +} + +module.exports = LatePaymentReconciler; diff --git a/src/services/paymentRefundService.js b/src/services/paymentRefundService.js new file mode 100644 index 0000000..0fce342 --- /dev/null +++ b/src/services/paymentRefundService.js @@ -0,0 +1,634 @@ +const { AppError, NotFoundError } = require('../utils/errors'); + +const MAX_ERROR_LENGTH = 2000; +const TX_HASH_PATTERN = /^[0-9a-f]{64}$/i; + +function parseAtomic(value, fieldName) { + try { + const parsed = BigInt(value == null ? 0 : value); + if (parsed < 0n) { + throw new Error('negative value'); + } + return parsed; + } catch (error) { + throw new AppError(`Invalid ${fieldName} stored for payment refund`, { + code: 'INVALID_REFUND_DATA', + safeMessage: 'The payment record cannot be refunded automatically.' + }); + } +} + +function jsonInteger(value) { + const parsed = typeof value === 'bigint' ? value : BigInt(value || 0); + return parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : parsed.toString(); +} + +function errorMessage(error) { + const message = error && typeof error.message === 'string' + ? error.message + : 'Wallet returned an ambiguous refund result.'; + return message.slice(0, MAX_ERROR_LENGTH) || 'Wallet returned an ambiguous refund result.'; +} + +function jsonObject(value) { + if (value && typeof value === 'object') return value; + if (typeof value !== 'string' || !value.trim()) return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (_) { + return {}; + } +} + +function packIds(value) { + const source = Array.isArray(value) ? value : []; + return [...new Set(source.map(item => { + if (typeof item === 'string') return item.trim(); + return String(item?.id || item?.packId || item?.pack_id || '').trim(); + }).filter(Boolean))]; +} + +/** + * Durable payment-refund state machine. + * + * Safety invariant: a wallet transfer is attempted only by the request that atomically + * changes `requested` to `processing`. No code path changes `processing` back to + * `requested`, so a crash or timeout can never cause an automatic second transfer. + */ +class PaymentRefundService { + constructor({ db, walletService, logger = console, isSendEnabled = null } = {}) { + if (!db) throw new Error('PaymentRefundService requires db'); + this.db = db; + this.walletService = walletService; + this.logger = logger; + // The application injects its single payout master/readiness predicate. Keep the + // service independently testable, but never let an admin refund become a second wallet + // transfer path that bypasses PAYOUTS_ENABLED, startup recovery, or wallet health. + this.isSendEnabled = typeof isSendEnabled === 'function' ? isSendEnabled : () => true; + } + + _assertSendEnabled() { + if (!this.isSendEnabled()) { + throw new AppError('Automatic refund transfer is disabled by the payout safety gate.', { + statusCode: 503, + code: 'PAYOUT_DISPATCH_DISABLED', + safeMessage: 'Automatic refund sending is disabled. Record the refund without sending funds, or restore the payout safety gate first.' + }); + } + } + + async refundPayment({ paymentId, reason = 'Admin refund', sendFunds = false }) { + if (sendFunds === true) this._assertSendEnabled(); + const normalizedReason = typeof reason === 'string' && reason.trim() + ? reason.trim() + : 'Admin refund'; + + const recorded = await this._recordOnce({ + paymentId, + reason: normalizedReason, + sendFunds: sendFunds === true + }); + + let refund = recorded.refund; + let walletClaimed = false; + + // A recorded-only refund is terminal. An existing requested row may be safely + // claimed after a crash that happened before any wallet call was started. + if (sendFunds === true && refund.status === 'requested') { + const attempt = await this._claimAndSend(paymentId); + refund = attempt.refund; + walletClaimed = attempt.claimed; + } + + return { + payment: recorded.payment, + refund, + existing: recorded.existing, + legacyImported: recorded.legacyImported, + walletClaimed + }; + } + + async _recordOnce({ paymentId, reason, sendFunds }) { + const client = await this.db.getClient(); + try { + await client.query('BEGIN'); + + // This is the serialization point for all refund requests for one payment. + const paymentResult = await client.query(` + SELECT p.* + FROM payments p + WHERE p.id = $1 + FOR UPDATE + `, [paymentId]); + + if (paymentResult.rows.length === 0) { + throw new NotFoundError('Payment not found', { + safeMessage: `Payment ${paymentId} not found.` + }); + } + + const payment = paymentResult.rows[0]; + const existingResult = await client.query(` + SELECT * + FROM payment_refunds + WHERE payment_id = $1 + FOR UPDATE + `, [paymentId]); + + if (existingResult.rows.length > 0) { + await client.query('COMMIT'); + return { + payment, + refund: existingResult.rows[0], + existing: true, + legacyImported: false + }; + } + + const receivedAmount = parseAtomic(payment.received_amount, 'received amount'); + const expectedAmount = parseAtomic(payment.expected_amount, 'expected amount'); + // Never pay an unconfirmed invoice merely because it has an expected amount. + // Confirmed legacy rows may lack received_amount, hence the narrow fallback. + const refundAmount = receivedAmount > 0n + ? receivedAmount + : (payment.status === 'confirmed' ? expectedAmount : 0n); + + let user = null; + if (payment.user_id != null) { + const userResult = await client.query(` + SELECT id, credits, race_entries, total_credits_purchased, + premium_level, payout_address + FROM users + WHERE id = $1 + FOR UPDATE + `, [payment.user_id]); + user = userResult.rows[0] || null; + } + + const payoutAddress = typeof user?.payout_address === 'string' && user.payout_address.trim() + ? user.payout_address.trim() + : null; + + // Historic refunded rows predate the outbox. Snapshot them as recorded, but + // never deduct grants or send funds because their prior side effects cannot be + // proven from the old schema. + const legacyImported = payment.status === 'refunded'; + let grant = null; + let creditsDeducted = 0n; + let raceEntriesDeducted = 0; + let packsRevoked = []; + let premiumLevelRestored = null; + let reviewReason = null; + let lot = null; + + if (!legacyImported && payment.status === 'confirmed') { + const grantResult = await client.query(` + SELECT * + FROM payment_entitlement_grants + WHERE payment_id = $1 + FOR UPDATE + `, [paymentId]); + grant = grantResult.rows[0] || null; + + // Upgrade fallback for a confirmed product that predates migration 035. The + // marker is still created under the payment lock before any reversal. + if (!grant && ['credits_package', 'cosmetic_pack'].includes(payment.payment_type)) { + const durable = jsonObject(payment.product_grants); + const legacyCredits = parseAtomic( + durable.credits ?? payment.credits_purchased ?? 0, + 'legacy credits grant' + ); + const legacyRace = Number(durable.raceEntries ?? durable.race_entries ?? 0); + const legacyPacks = Array.isArray(durable.packs) ? durable.packs : []; + const legacyPremium = durable.premiumLevel || durable.premium_level || null; + const inserted = await client.query(` + INSERT INTO payment_entitlement_grants ( + payment_id, user_id, source, credits_granted, + purchase_progress_granted, race_entries_granted, packs_granted, + premium_level_granted, metadata + ) VALUES ( + $1, $2, 'legacy_product_backfill', $3::bigint, $3::bigint, + $4, $5::jsonb, $6, $7::jsonb + ) + ON CONFLICT (payment_id) DO NOTHING + RETURNING * + `, [ + paymentId, + payment.user_id, + legacyCredits.toString(), + Number.isSafeInteger(legacyRace) && legacyRace > 0 ? legacyRace : 0, + JSON.stringify(legacyPacks), + legacyPremium, + JSON.stringify({ runtimeLegacyBackfill: true }) + ]); + grant = inserted.rows[0] || null; + } + + // A linked game proves that a direct entry was consumed. Never send its coins + // automatically; gameplay value and any payout liability require reconciliation. + const consumedGame = await client.query( + `SELECT id FROM games WHERE payment_id = $1 LIMIT 1`, + [paymentId] + ); + if (consumedGame.rows.length > 0) { + reviewReason = 'Payment was consumed by a game; automatic refund is unsafe.'; + } + } + + const creditsGranted = grant ? parseAtomic(grant.credits_granted, 'credits grant') : 0n; + const progressGranted = grant ? parseAtomic(grant.purchase_progress_granted, 'purchase progress grant') : 0n; + const raceGranted = grant ? Number(grant.race_entries_granted || 0) : 0; + const grantedPackIds = grant ? packIds(jsonObject({ value: grant.packs_granted }).value || grant.packs_granted) : []; + // PostgreSQL returns JSONB arrays directly; string-backed test/adapter rows need parsing. + const parsedPacks = typeof grant?.packs_granted === 'string' + ? (() => { try { return JSON.parse(grant.packs_granted); } catch (_) { return []; } })() + : grant?.packs_granted; + const actualPackIds = grant ? packIds(parsedPacks) : grantedPackIds; + + if (!legacyImported && !reviewReason && payment.status !== 'confirmed') { + reviewReason = 'Only a confirmed payment can be refunded automatically.'; + } + // Direct-entry confirmation historically advanced lifetime purchase progression + // outside the payment grant snapshot. Until that benefit has payment-scoped + // provenance, neither a bare direct payment nor its recovery-credit marker can be + // reversed exactly. Keep the coins in manual review instead of refunding while + // leaving a tier/cosmetic unlock behind. + if (!legacyImported && !reviewReason && payment.payment_type === 'single_game' + && (!grant || grant.source === 'single_game_recovery')) { + reviewReason = 'Direct-entry purchase progression requires manual reconciliation.'; + } + if (!legacyImported && !reviewReason && grant && grant.status !== 'active') { + reviewReason = `Entitlement grant is ${grant.status}; manual reconciliation is required.`; + } + if (!legacyImported && !reviewReason + && (creditsGranted > 0n || progressGranted > 0n || raceGranted > 0 + || actualPackIds.length > 0 || grant?.premium_level_granted) + && !user) { + reviewReason = 'The entitlement owner no longer exists.'; + } + if (!legacyImported && !reviewReason && sendFunds && refundAmount <= 0n) { + reviewReason = 'No received payment amount is available for an automatic refund.'; + } + if (!legacyImported && !reviewReason && sendFunds && !payoutAddress) { + reviewReason = 'No payout address is available for an automatic refund.'; + } + if (!legacyImported && !reviewReason && creditsGranted > parseAtomic(user?.credits, 'user credits')) { + reviewReason = 'Purchased credits have already been consumed.'; + } + if (!legacyImported && !reviewReason + && progressGranted > parseAtomic(user?.total_credits_purchased, 'purchase progress')) { + reviewReason = 'Purchase progression cannot be reversed safely.'; + } + if (!legacyImported && !reviewReason + && (!Number.isSafeInteger(raceGranted) || raceGranted < 0 + || raceGranted > Number(user?.race_entries || 0))) { + reviewReason = 'Purchased race entries have already been consumed or escrowed.'; + } + + if (!legacyImported && !reviewReason && raceGranted > 0) { + const lotResult = await client.query(` + SELECT id, original_entries, remaining_entries, refunded_at + FROM race_entry_lots + WHERE payment_id = $1 + FOR UPDATE + `, [paymentId]); + lot = lotResult.rows[0] || null; + if (lot && (lot.refunded_at != null + || Number(lot.original_entries) !== raceGranted + || Number(lot.remaining_entries) !== raceGranted)) { + reviewReason = 'Purchased race entries have already been consumed or escrowed.'; + } + } + + let lockedPacks = []; + if (!legacyImported && !reviewReason && actualPackIds.length > 0) { + if (grant.source !== 'product_confirmation') { + reviewReason = 'Legacy pack provenance is ambiguous.'; + } else { + const packResult = await client.query(` + SELECT pack_id, source, metadata + FROM user_pack_entitlements + WHERE user_id = $1 AND pack_id = ANY($2::text[]) + ORDER BY pack_id + FOR UPDATE + `, [payment.user_id, actualPackIds]); + lockedPacks = packResult.rows || []; + const ownedByPayment = new Set(lockedPacks + .filter(row => String(jsonObject(row.metadata).paymentId) === String(paymentId)) + .map(row => row.pack_id)); + if (actualPackIds.some(id => !ownedByPayment.has(id))) { + reviewReason = 'A purchased pack is missing or has different ownership provenance.'; + } + } + } + + if (!legacyImported && !reviewReason && grant?.premium_level_granted) { + if (!grant.premium_level_previous + || user?.premium_level !== grant.premium_level_granted) { + reviewReason = 'The purchased premium level changed after purchase.'; + } + } + + if (!legacyImported && reviewReason) { + if (grant) { + await client.query(` + UPDATE payment_entitlement_grants + SET status = 'needs_review', needs_review_at = NOW(), reversal_reason = $2 + WHERE payment_id = $1 AND status = 'active' + `, [paymentId, reviewReason]); + } + const reviewInsert = await client.query(` + INSERT INTO payment_refunds ( + payment_id, user_id, status, amount, payout_address, + credits_deducted, race_entries_deducted, packs_revoked, + reason, requested_at, error_message, + entitlement_grant_payment_id + ) VALUES ( + $1, $2, 'needs_review', $3::bigint, $4, + 0, 0, '[]'::jsonb, $5, NOW(), $6, $7 + ) + RETURNING * + `, [paymentId, payment.user_id, refundAmount.toString(), payoutAddress, + reason, reviewReason, grant ? paymentId : null]); + await client.query('COMMIT'); + return { + payment, + refund: reviewInsert.rows[0], + existing: false, + legacyImported: false + }; + } + + if (!legacyImported && grant && user) { + const premiumPrevious = grant.premium_level_granted + ? grant.premium_level_previous + : user.premium_level; + const updatedUser = await client.query(` + UPDATE users + SET credits = credits - $1::bigint, + total_credits_purchased = total_credits_purchased - $2::bigint, + race_entries = race_entries - $3, + premium_level = $4 + WHERE id = $5 + RETURNING credits, race_entries, total_credits_purchased, premium_level + `, [creditsGranted.toString(), progressGranted.toString(), raceGranted, + premiumPrevious, payment.user_id]); + if (updatedUser.rows.length !== 1) { + throw new AppError('Locked refund user disappeared during entitlement reversal'); + } + + creditsDeducted = creditsGranted; + raceEntriesDeducted = raceGranted; + premiumLevelRestored = grant.premium_level_granted ? premiumPrevious : null; + if (creditsDeducted > 0n) { + await client.query(` + INSERT INTO credit_transactions ( + user_id, amount, reason, balance_after, transaction_type, payment_id + ) VALUES ($1, $2::bigint, $3, $4::bigint, 'refund', $5) + `, [payment.user_id, (-creditsDeducted).toString(), + `payment_refund:${paymentId}`, String(updatedUser.rows[0].credits), paymentId]); + } + if (raceEntriesDeducted > 0) { + await client.query(` + INSERT INTO race_entry_transactions ( + user_id, delta, balance_after, reason, payment_id, metadata + ) VALUES ($1, $2, $3, 'refund', $4, $5::jsonb) + `, [payment.user_id, -raceEntriesDeducted, + updatedUser.rows[0].race_entries, paymentId, + JSON.stringify({ refundPaymentId: paymentId })]); + } + if (lot) { + const reversedLot = await client.query(` + UPDATE race_entry_lots + SET remaining_entries = 0, refunded_at = NOW() + WHERE id = $1 AND refunded_at IS NULL + AND remaining_entries = original_entries + RETURNING id + `, [lot.id]); + if (reversedLot.rowCount !== 1) { + throw new AppError('Race-entry lot changed during refund reversal'); + } + } + if (actualPackIds.length > 0) { + const removed = await client.query(` + DELETE FROM user_pack_entitlements + WHERE user_id = $1 + AND pack_id = ANY($2::text[]) + AND metadata->>'paymentId' = $3 + RETURNING pack_id + `, [payment.user_id, actualPackIds, String(paymentId)]); + packsRevoked = (removed.rows || []).map(row => row.pack_id); + if (packsRevoked.length !== actualPackIds.length) { + throw new AppError('Pack entitlement changed during refund reversal'); + } + } + await client.query(` + UPDATE payment_entitlement_grants + SET status = 'reversed', credits_reversed = $2::bigint, + purchase_progress_reversed = $3::bigint, + race_entries_reversed = $4, packs_reversed = $5::jsonb, + reversal_reason = $6, reversed_at = NOW() + WHERE payment_id = $1 AND status = 'active' + `, [paymentId, creditsDeducted.toString(), progressGranted.toString(), + raceEntriesDeducted, JSON.stringify(packsRevoked), reason]); + } + + if (!legacyImported) { + await client.query(` + UPDATE payments + SET status = 'refunded' + WHERE id = $1 + `, [paymentId]); + payment.status = 'refunded'; + } + + const initialStatus = sendFunds && !legacyImported ? 'requested' : 'recorded'; + const insertResult = await client.query(` + INSERT INTO payment_refunds ( + payment_id, user_id, status, amount, payout_address, + credits_deducted, purchase_progress_deducted, + race_entries_deducted, packs_revoked, + premium_level_restored, reason, requested_at, + entitlement_grant_payment_id + ) VALUES ( + $1, $2, $3, $4::bigint, $5, $6::bigint, $7::bigint, + $8, $9::jsonb, $10, $11, + CASE WHEN $3 = 'requested' THEN NOW() ELSE NULL END, $12 + ) + RETURNING * + `, [ + paymentId, + payment.user_id, + initialStatus, + refundAmount.toString(), + payoutAddress, + creditsDeducted.toString(), + progressGranted.toString(), + raceEntriesDeducted, + JSON.stringify(packsRevoked), + premiumLevelRestored, + reason, + grant ? paymentId : null + ]); + + await client.query('COMMIT'); + return { + payment, + refund: insertResult.rows[0], + existing: legacyImported, + legacyImported + }; + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + throw error; + } finally { + client.release(); + } + } + + async _claimAndSend(paymentId) { + const claimResult = await this.db.query(` + UPDATE payment_refunds + SET status = 'processing', + processing_started_at = NOW(), + updated_at = NOW(), + error_message = NULL + WHERE payment_id = $1 + AND status = 'requested' + RETURNING * + `, [paymentId]); + + if (claimResult.rows.length === 0) { + return { claimed: false, refund: await this._getRefund(paymentId) }; + } + + const claimed = claimResult.rows[0]; + let observedTxHash = null; + + try { + if (!this.walletService || typeof this.walletService.processPayout !== 'function') { + throw new Error('Wallet service is unavailable; manual refund review is required.'); + } + if (BigInt(claimed.amount) <= 0n) { + throw new Error('No received payment amount is available for an automatic refund.'); + } + if (typeof claimed.payout_address !== 'string' || !claimed.payout_address.trim()) { + throw new Error('No payout address is available for an automatic refund.'); + } + + // Recheck after the durable processing claim and immediately before the wallet call. + // A concurrent kill-switch change therefore cannot be missed by a long DB reversal. + this._assertSendEnabled(); + const result = await this.walletService.processPayout({ + address: claimed.payout_address, + amount: BigInt(claimed.amount), + userId: claimed.user_id, + description: `Refund for payment ${paymentId}` + }); + + observedTxHash = typeof result?.txHash === 'string' ? result.txHash.trim() : null; + if (result?.success !== true || !observedTxHash || !TX_HASH_PATTERN.test(observedTxHash)) { + throw new Error('Wallet did not return explicit success with a valid transaction hash.'); + } + + const completedResult = await this.db.query(` + UPDATE payment_refunds + SET status = 'completed', + tx_hash = $2, + error_message = NULL, + completed_at = NOW(), + updated_at = NOW() + WHERE id = $1 + AND status = 'processing' + RETURNING * + `, [claimed.id, observedTxHash]); + + if (completedResult.rows.length === 0) { + // The transfer may have happened, so no state transition here may ever + // make this row eligible for another automatic attempt. + return { claimed: true, refund: await this._getRefund(paymentId) }; + } + + return { claimed: true, refund: completedResult.rows[0] }; + } catch (error) { + const reviewResult = await this.db.query(` + UPDATE payment_refunds + SET status = 'needs_review', + tx_hash = COALESCE($2, tx_hash), + error_message = $3, + needs_review_at = NOW(), + updated_at = NOW() + WHERE id = $1 + AND status = 'processing' + RETURNING * + `, [claimed.id, observedTxHash, errorMessage(error)]); + + const finalRefund = reviewResult.rows[0] || await this._getRefund(paymentId); + if (finalRefund.status !== 'completed') { + this.logger?.error?.('Payment refund requires manual review', { + paymentId, + refundId: claimed.id, + error: errorMessage(error) + }); + } + + return { + claimed: true, + refund: finalRefund + }; + } + } + + async _getRefund(paymentId) { + const result = await this.db.query(` + SELECT * + FROM payment_refunds + WHERE payment_id = $1 + `, [paymentId]); + + if (result.rows.length === 0) { + throw new AppError('Payment refund state disappeared', { + code: 'REFUND_STATE_MISSING', + safeMessage: 'The refund state could not be loaded.' + }); + } + return result.rows[0]; + } + + static toApiResult(result, gameModeManager) { + const { payment, refund } = result; + const requiresReview = refund.status === 'needs_review' || refund.status === 'processing'; + let message = 'Payment refunded successfully.'; + if (refund.status === 'processing') { + message = 'Refund transfer is processing. Do not retry it; reconcile the wallet if it remains processing.'; + } else if (refund.status === 'needs_review') { + message = 'Payment refund recorded; the transfer requires manual review.'; + } else if (result.existing) { + message = 'Payment refund already recorded.'; + } + return { + success: !result.existing, + message, + refund: { + paymentId: payment.id, + originalAmount: payment.expected_amount, + originalAmountFormatted: gameModeManager.formatAtomicHuman(payment.expected_amount, 4), + amount: refund.amount, + creditsDeducted: jsonInteger(refund.credits_deducted), + purchaseProgressDeducted: jsonInteger(refund.purchase_progress_deducted), + raceEntriesDeducted: Number(refund.race_entries_deducted || 0), + packsRevoked: Array.isArray(refund.packs_revoked) ? refund.packs_revoked : [], + premiumLevelRestored: refund.premium_level_restored || null, + fundsSent: refund.status === 'completed', + txHash: refund.tx_hash || null, + reason: refund.reason, + status: refund.status, + requiresReview + } + }; + } +} + +module.exports = PaymentRefundService; diff --git a/src/services/payoutAdmissionService.js b/src/services/payoutAdmissionService.js new file mode 100644 index 0000000..58e49f1 --- /dev/null +++ b/src/services/payoutAdmissionService.js @@ -0,0 +1,133 @@ +const money = require('../money/atomic'); +const { ValidationError } = require('../utils/errors'); + +// One application-wide PostgreSQL advisory namespace for accepting new payout liabilities. +// Every solo and match commitment uses this exact key inside its existing DB transaction. +const PAYOUT_ADMISSION_LOCK_KEY = '883771240519'; + +function positiveAtomic(value) { + try { + const parsed = money.toBig(String(value ?? '').replace(/_/g, '')); + return parsed > 0n ? parsed : null; + } catch (_) { + return null; + } +} + +function reserveFloor({ gameModeManager, env = process.env } = {}) { + const fromAlert = positiveAtomic(gameModeManager?.alertService?.balanceCriticalThreshold); + const fromEnv = positiveAtomic(env.BALANCE_CRITICAL); + const floor = fromAlert || fromEnv; + if (!floor) { + throw new ValidationError('Payout reserve floor is unavailable', { + code: 'PAYOUT_RESERVE_UNVERIFIED', + safeMessage: 'The house payout reserve cannot be verified. Please try again later.' + }); + } + return floor; +} + +/** + * Serialize and prove capacity for one new immutable payout promise. The SQL intentionally avoids + * double-counting: a committed game/match is counted only until its payout row exists; every payout + * row that is not provably completed remains reserved regardless of retry/review status. + */ +async function reservePayoutCapacity({ + client, + walletService, + newLiability, + gameModeManager, + env = process.env +} = {}) { + const requested = money.toBig(newLiability || 0); + if (requested <= 0n) return { bypassed: true, newLiability: 0n }; + if (!client || typeof client.query !== 'function') { + throw new ValidationError('Payout admission database transaction is unavailable', { + code: 'PAYOUT_RESERVE_UNVERIFIED' + }); + } + if (!walletService || typeof walletService.getBalance !== 'function') { + // Unit tests for non-admission concerns commonly use a minimal wallet mock. Production + // must never bypass an unavailable exact balance source. + if (process.env.JEST_WORKER_ID) { + return { bypassed: true, newLiability: requested }; + } + throw new ValidationError('Payout wallet balance source is unavailable', { + code: 'PAYOUT_RESERVE_UNVERIFIED', + safeMessage: 'The house payout reserve cannot be verified. Please try again later.' + }); + } + + await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [PAYOUT_ADMISSION_LOCK_KEY]); + + let unlocked; + try { + if (walletService.isHealthy === false) throw new Error('wallet unhealthy'); + const balance = await walletService.getBalance(); + if (balance?.unlocked_balance == null) throw new Error('unlocked balance missing'); + unlocked = money.toBig(balance.unlocked_balance); + if (unlocked < 0n) throw new Error('unlocked balance is negative'); + } catch (_) { + throw new ValidationError('Payout wallet unlocked balance could not be verified', { + code: 'PAYOUT_RESERVE_UNVERIFIED', + safeMessage: 'The house payout reserve cannot be verified. Please try again later.' + }); + } + + const liabilities = await client.query(` + SELECT + COALESCE(( + SELECT SUM(p.amount::numeric) + FROM payouts p + WHERE p.status IS DISTINCT FROM 'completed' + ), 0)::text AS payout_rows, + COALESCE(( + SELECT SUM(GREATEST( + COALESCE(g.payout_escape_amount, 0), + COALESCE(g.payout_treasure_amount, 0) + )::numeric) + FROM games g + WHERE g.payout_eligible = TRUE + AND g.payout_committed_at IS NOT NULL + AND g.status IN ('waiting', 'active', 'won') + AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.game_id = g.id) + ), 0)::text AS solo_commitments, + COALESCE(( + SELECT SUM(m.payout_liability_amount_atomic::numeric) + FROM matches m + WHERE m.payout_liability_accepted_at IS NOT NULL + AND m.payout_liability_amount_atomic > 0 + AND m.status IN ('starting', 'active', 'finished') + AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.match_id = m.id) + ), 0)::text AS match_commitments + `); + const row = liabilities.rows?.[0] || {}; + const payoutRows = money.toBig(row.payout_rows || 0); + const soloCommitments = money.toBig(row.solo_commitments || 0); + const matchCommitments = money.toBig(row.match_commitments || 0); + const outstanding = payoutRows + soloCommitments + matchCommitments; + const floor = reserveFloor({ gameModeManager, env }); + const required = outstanding + requested + floor; + + if (unlocked < required) { + throw new ValidationError('Unlocked wallet balance cannot cover committed payout liabilities', { + code: 'PAYOUT_RESERVE_INSUFFICIENT', + safeMessage: 'The house payout reserve is temporarily too low for a new payout-bearing game.' + }); + } + return { + bypassed: false, + unlocked, + outstanding, + newLiability: requested, + reserveFloor: floor, + required, + breakdown: { payoutRows, soloCommitments, matchCommitments } + }; +} + +module.exports = { + PAYOUT_ADMISSION_LOCK_KEY, + reserveFloor, + reservePayoutCapacity +}; diff --git a/src/utils/financialRecoveryError.js b/src/utils/financialRecoveryError.js new file mode 100644 index 0000000..7fc1f9a --- /dev/null +++ b/src/utils/financialRecoveryError.js @@ -0,0 +1,32 @@ +/** + * Fail-closed error contract for startup reconciliation of durable money state. + * + * Only a scope, counters, and durable row identifiers are copied into the attached summary. + * The underlying database error remains available as `cause` for trusted operator diagnostics. + */ +function createFinancialRecoveryError(scope, summary = {}, cause = undefined) { + const unresolved = Array.isArray(summary.unresolved) + ? summary.unresolved.map(item => ({ + type: String(item?.type || 'row'), + id: item?.id == null ? null : String(item.id) + })) + : []; + const recovery = Object.freeze({ + ok: false, + scope: String(scope || 'financial_recovery'), + scanFailed: summary.scanFailed === true, + scanned: Math.max(0, Number(summary.scanned) || 0), + resolved: Math.max(0, Number(summary.resolved) || 0), + unresolved: Object.freeze(unresolved) + }); + const failure = recovery.scanFailed ? 'scan failed' : `${unresolved.length} unresolved row(s)`; + const error = new Error(`Financial recovery incomplete for ${recovery.scope}: ${failure}`); + error.name = 'FinancialRecoveryError'; + error.code = 'FINANCIAL_RECOVERY_INCOMPLETE'; + error.recovery = recovery; + if (cause) error.cause = cause; + Error.captureStackTrace?.(error, createFinancialRecoveryError); + return error; +} + +module.exports = { createFinancialRecoveryError }; diff --git a/src/views/legalPages.js b/src/views/legalPages.js new file mode 100644 index 0000000..608f1de --- /dev/null +++ b/src/views/legalPages.js @@ -0,0 +1,111 @@ +const escapeHtml = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, character => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' +})[character]); + +function contactMarkup(disclosure) { + const operator = disclosure.operator || {}; + const label = escapeHtml(operator.contactLabel || 'Operator contact is not configured'); + if (!operator.contactUrl) return `${label}`; + return `${label}`; +} + +function modeSummary(disclosure) { + const service = disclosure.service || {}; + const items = []; + if (service.freePlayEnabled) { + items.push('
  • Free play: no entry payment and a separate free leaderboard; no free-play payout.
  • '); + } + if (service.directPaidEntryEnabled) { + items.push(`
  • Paid single entry: one ${escapeHtml(service.currencyLabel)} payment purchases one qualifying entry.
  • `); + } + if (service.paidCreditsEnabled) { + items.push('
  • Paid credits: a cryptocurrency purchase grants the displayed credits or products. Credits are service records, not cryptocurrency balances.
  • '); + } + if (service.paidPrestigeOnly) { + items.push('
  • Paid prestige mode: paid scores use the paid leaderboard, but this server does not currently offer a cryptocurrency prize.
  • '); + } + if (service.soloPayoutsEnabled) { + items.push('
  • Reward-enabled solo play: a paid entry can be lost. Only the outcomes and payout amounts shown before entry are eligible.
  • '); + } + if (service.cryptoMatchPayoutsEnabled) { + items.push('
  • Reward-enabled PvP: the configured race may award the collected pot after the displayed operator fee. Other PvP economies remain separate.
  • '); + } + if (service.isTestNetwork) { + items.push(`
  • Test network: ${escapeHtml(service.currencyLabel)} is test currency with no intended monetary value. Never send ${escapeHtml(service.cryptoType)} mainnet funds to a test-network address.
  • `); + } + return items.length ? `
      ${items.join('')}
    ` : '

    This instance currently exposes no paid play.

    '; +} + +function shell({ title, disclosure, body }) { + const operator = escapeHtml(disclosure.operator?.name || 'Site operator'); + const gameName = escapeHtml(disclosure.service?.gameName || 'Wowngeon'); + const effective = disclosure.termsEffectiveDate + ? `Effective ${escapeHtml(disclosure.termsEffectiveDate)}` + : 'Effective date is not configured'; + return ` + + + + + ${escapeHtml(title)} — ${gameName} + + + +
    + +

    ${operator}

    ${escapeHtml(title)}

    ${effective} · policy ${escapeHtml(disclosure.policyVersion)}

    + ${body} +

    Important status

    ${escapeHtml(disclosure.notices?.legalReview)}

    ${escapeHtml(disclosure.notices?.identityControl)}

    +

    Operator contact: ${contactMarkup(disclosure)}

    +
    + +`; +} + +function renderTerms(disclosure) { + const jurisdiction = disclosure.notices?.jurisdiction + ? `

    Operator location notice: ${escapeHtml(disclosure.notices.jurisdiction)}

    ` + : '

    No location-specific restriction list is published here. You are responsible for confirming that your use is permitted where you are located.

    '; + return shell({ + title: 'Terms of Use', + disclosure, + body: ` +

    Who may use the service

    You may use the service only if you are at least ${escapeHtml(disclosure.minimumAge)}, can lawfully enter the transactions shown, and are acting for yourself. Do not use it where paid play, cryptocurrency transactions, prizes, or gambling-like games are prohibited.

    ${jurisdiction}
    +

    Current service modes

    The server configuration, confirmation screen, and invoice shown for an action control that action. Solo scores map by entry: FREE runs use the Pleb board, while PAID_SINGLE and PAID_CREDITS runs use the Hall of Champions. PvP maps separately by economy: free matches use the Pleb board, credits_prestige matches use the Prestige board, and crypto_race matches use the Hall of Champions.

    ${modeSummary(disclosure)}
    +

    Payments, credits, and outcomes

    Blockchain transfers may be irreversible. Check the network, asset, address, and exact amount before sending. A pending invoice is not an entitlement until the server records the required confirmation. Do not send funds after an invoice expires; contact the operator for manual review.

    Credits, tickets, cosmetics, and leaderboard status are service entitlements tied to an anonymous browser session. They are not deposits, do not earn interest, and are not redeemable for cryptocurrency unless a specific reward-enabled game says so before entry. Clearing browser storage or losing the session token can make an account inaccessible.

    Random timing, dungeon generation, player decisions, other players, network conditions, and game rules affect results. A paid entry can lose its entire entry value. Previous results do not predict future results.

    +

    Fair play and acceptable use

    Do not exploit defects, automate the public service without permission, interfere with other players, evade limits, launder funds, submit unlawful content, or attempt to access another session. The operator may pause entry, quarantine ambiguous payments, reverse unconsumed service credits, or suspend access to protect players and the service. Blockchain transfers already broadcast cannot be reversed by the application.

    +

    Availability and changes

    The service is experimental software and may be paused, changed, or withdrawn. Rules and prices may change only for future entries; the server records the economic terms accepted for an admitted game. Verification pages and transaction records should be retained when resolving a dispute.

    +

    Problems and disputes

    Stop sending funds if the asset, network, amount, reward, or status is unclear. Preserve the invoice ID, transaction hash, game ID, time, and browser session. Contact the operator before retrying. These terms do not waive rights that cannot lawfully be waived.

    ` + }); +} + +function renderPrivacy(disclosure) { + return shell({ + title: 'Privacy Notice', + disclosure, + body: ` +

    Data the service handles

    • A random browser session token stored in local storage and the matching account record.
    • Network and security data such as IP address, connection identifiers, rate-limit events, user agent information available to the web stack, and application logs.
    • Chosen display name, avatar/loadout, scores, game proofs, gameplay, leaderboard records, credit and ticket ledgers, and support evidence.
    • Payment addresses, payout addresses, invoice/subaddress records, transaction hashes, amounts, confirmations, and payout status. Public blockchain activity remains public independently of this service.
    • Chat messages and, when enabled, public keys or signed events. Global chat may be relayed to public Nostr infrastructure and should be treated as public.
    +

    Why it is used

    The application uses this data to resume anonymous sessions, run games, separate leaderboards, verify payments and payouts, prevent duplicate claims and abuse, investigate failures, reconcile financial records, support players, and secure the service.

    +

    Where it goes

    Data is processed by the operator's hosting, reverse-proxy, database, blockchain node or wallet infrastructure, backups, and monitoring. Optional integrations shown in the product may send the necessary data to their operators—for example, public chat relays or a wallet extension. Operator-alert email receives operational alerts, not a player mailing list.

    +

    Storage and retention

    Session and preference data is stored in your browser. Server records and backups are retained for operations, security, dispute handling, financial reconciliation, and applicable recordkeeping. This version does not promise a fixed deletion schedule. Blockchain and public-relay records may be impossible for the operator to delete.

    +

    Your choices

    You may play free without creating a paid invoice when free play is offered. Do not put private information in a display name or chat. You may clear local storage, but doing so can permanently lose access to the anonymous account, credits, payout address, and history associated with its token. Contact the operator to ask about access, correction, or deletion where applicable; provide only enough evidence to locate the record.

    +

    Security limits

    The application uses technical safeguards, but no online or cryptocurrency system is risk-free. Never share the browser session token, wallet seed, wallet password, or private key with the site or support.

    ` + }); +} + +function renderResponsiblePlay(disclosure) { + const network = disclosure.service?.isTestNetwork + ? `

    Test-network reminder: ${escapeHtml(disclosure.service.currencyLabel)} is for testing and has no intended monetary value. Never substitute mainnet funds.

    ` + : ''; + return shell({ + title: 'Responsible Play', + disclosure, + body: ` +

    Know which mode you chose

    ${modeSummary(disclosure)}${network}

    Paid prestige and purchases can still cause financial loss through spending even when no prize is offered. Reward-enabled modes add outcome risk: you may lose the full entry and should never treat a displayed multiplier as guaranteed income.

    +

    Set limits before playing

    • Choose a time limit and a cryptocurrency spending limit before opening an invoice.
    • Use only funds you can afford to lose completely. Never borrow, use essential funds, or try to recover previous losses.
    • Stop after the planned limit, after a loss, when frustrated, or when tired or impaired.
    • Keep a wallet-level spending record and review transaction history rather than relying on memory.
    • Do not play paid modes if you are under ${escapeHtml(disclosure.minimumAge)} or if play is unlawful where you are.
    +

    Take a break or stop

    Close the site, disconnect the wallet, and move play funds out of the active wallet if you need distance. This release does not provide verified identity, geolocation, deposit limits, or an automated self-exclusion system. Contact the operator to request an access block, understanding that anonymous-session and blockchain limits may constrain what can be enforced.

    If play is causing distress or harm, stop paid play and seek confidential help from a qualified local health or gambling-support service. If there is immediate danger, contact local emergency services.

    +

    Operational safeguards are not personal limits

    Rate limits, payout caps, reserve checks, and wallet kill switches protect service integrity; they are not a personal budget or a guarantee against loss. You remain responsible for setting stricter personal limits.

    ` + }); +} + +module.exports = { escapeHtml, renderPrivacy, renderResponsiblePlay, renderTerms }; diff --git a/src/views/verifyPage.js b/src/views/verifyPage.js index d9ebe39..bdf8049 100644 --- a/src/views/verifyPage.js +++ b/src/views/verifyPage.js @@ -21,17 +21,18 @@ function renderVerifyPage(gameId, gameRecord, opts = {}) { const baseUrl = (opts.baseUrl || '').replace(/\/$/, ''); const ogImage = opts.ogImage ? `${baseUrl}/${opts.ogImage.replace(/^\//, '')}` : ''; const pageUrl = `${baseUrl}/verify/${esc(gameId)}`; + const gameIdJson = JSON.stringify(String(gameId)).replace(/ @@ -43,6 +44,12 @@ function renderVerifyPage(gameId, gameRecord, opts = {}) { ${ogImage ? `` : ''}`; + const hasV2Proof = !!(gameRecord && gameRecord.server_seed && gameRecord.proof_commitment && gameRecord.effective_seed); + let recordedManifest = gameRecord?.layout_fingerprints || null; + if (typeof recordedManifest === 'string') { + try { recordedManifest = JSON.parse(recordedManifest); } catch (_) { recordedManifest = null; } + } + const recordedDepths = Array.isArray(recordedManifest) ? recordedManifest.length : 0; const recordBlock = gameRecord ? `

    Game Record

    @@ -52,6 +59,17 @@ function renderVerifyPage(gameId, gameRecord, opts = {}) {

    Moves: ${esc(gameRecord.moves_made || 'N/A')}

    Duration: ${gameRecord.duration_seconds ? esc(gameRecord.duration_seconds) + 's' : 'N/A'}

    Created: ${esc(gameRecord.created_at)}

    + ${hasV2Proof ? ` +

    Proof version: ${esc(gameRecord.proof_version || 2)}

    +

    Published commitment: ${esc(String(gameRecord.proof_commitment).trim())}

    +

    Client seed: ${esc(gameRecord.client_seed || '(empty)')}

    +

    Revealed server seed: ${esc(String(gameRecord.server_seed).trim())}

    +

    Derived effective seed: ${esc(String(gameRecord.effective_seed).trim())}

    +

    Generator version: ${esc(gameRecord.generator_version || gameRecord.proof_context?.generatorVersion || 'legacy')}

    +

    Verification scope: ${recordedDepths ? `All ${recordedDepths} dungeon depth(s)` : 'Legacy level 1 only'}

    +

    Played layout fingerprint: ${esc(String(gameRecord.layout_fingerprint || '').trim())}

    + + ` : '

    This legacy/in-progress game has no revealed v2 proof.

    '}
    ` : ''; @@ -78,61 +96,125 @@ function renderVerifyPage(gameId, gameRecord, opts = {}) { -

    🔐 ${esc(name)} Provably Fair Verification

    +

    🔐 ${esc(name)} Committed Dungeon Verification

    How it works:

      -
    1. Before game start, you received a commitment hash (SHA-256)
    2. -
    3. After the game, you received the seed
    4. -
    5. This page verifies: SHA256(seed) === commitment
    6. -
    7. The seed deterministically generated your dungeon layout
    8. +
    9. The server publishes SHA256(serverSeed) before your browser chooses a client seed.
    10. +
    11. Your one-time offer and client seed are bound to the game before dungeon generation.
    12. +
    13. After the game, the server seed is revealed and this page re-derives the effective seed with HMAC-SHA256.
    14. +
    15. The effective seed regenerates every advertised depth and each must match its versioned recorded fingerprint.
    +

    Scope: this verifies seed commitment and recorded dungeon layouts. It does not replay player input, block timing, monster turns, the reported outcome, or payout delivery.

    -

    Verify Game

    - - +

    Manual v2 verification

    + + + + + + + + - + - +
    ${recordBlock}

    For technical verification, you can also use the API:

    - GET /api/verify?seed=YOUR_SEED&commitment=YOUR_COMMITMENT + GET /api/verify/${esc(gameId)}
    diff --git a/test/addressManager.test.js b/test/addressManager.test.js index 7be1c62..ec8f1c9 100644 --- a/test/addressManager.test.js +++ b/test/addressManager.test.js @@ -4,14 +4,19 @@ const { AppError } = require('../src/utils/errors'); describe('AddressManager', () => { const sampleAddress = 'WW3UyJFX6GPKdM21APyVYjGhfX3ebyHvPPgtFC3wHxkYTCGg8k2UM9DLTCzcgoaLHSKnvJKRtCPgJ1sxvgYiFCoK19vLk83Jn'; - function createManager({ existingAddress = null } = {}) { + function createManager({ existingAddress = null, cryptoType = null, network = null, walletValidation = null } = {}) { const emitMock = jest.fn(); const toMock = jest.fn().mockReturnValue({ emit: emitMock }); const broadcastMock = { sendStatusUpdate: jest.fn() }; const gameModeManager = { + cryptoType, + network, setUserPayoutAddress: jest.fn().mockResolvedValue(true), getOrCreateUser: jest.fn().mockResolvedValue({ payout_address: existingAddress }) }; + if (walletValidation) { + gameModeManager.validatePayoutAddress = jest.fn().mockResolvedValue(walletValidation); + } const manager = new AddressManager({ gameModeManager, @@ -47,4 +52,31 @@ describe('AddressManager', () => { expect(manager.pending.has('socket-3')).toBe(false); expect(emitMock).toHaveBeenCalledWith('address_confirmed', expect.objectContaining({ message: expect.stringContaining('already set') })); }); + + test('accepts stagenet XMR prefixes and rejects mainnet prefixes on a stagenet server', async () => { + const { manager, gameModeManager } = createManager({ + cryptoType: 'XMR', + network: 'stagenet', + walletValidation: { valid: true, nettype: 'stagenet' } + }); + const stagenetAddress = `5${'1'.repeat(94)}`; + const mainnetAddress = `4${'1'.repeat(94)}`; + + expect(manager.isValidAddress(stagenetAddress)).toBe(true); + expect(manager.isValidAddress(mainnetAddress)).toBe(false); + expect(await manager.saveAddress('socket-stage', stagenetAddress)).toBe(stagenetAddress); + expect(gameModeManager.setUserPayoutAddress).toHaveBeenCalledWith('socket-stage', stagenetAddress); + }); + + test('wallet-RPC validation fails closed before an address is persisted', async () => { + const { manager, gameModeManager } = createManager({ + cryptoType: 'XMR', + network: 'stagenet', + walletValidation: { valid: false, nettype: 'mainnet' } + }); + const stagenetShapedAddress = `7${'1'.repeat(94)}`; + + expect(await manager.saveAddress('socket-bad', stagenetShapedAddress)).toBe(false); + expect(gameModeManager.setUserPayoutAddress).not.toHaveBeenCalled(); + }); }); diff --git a/test/adminFinancialOps.test.js b/test/adminFinancialOps.test.js new file mode 100644 index 0000000..88dd397 --- /dev/null +++ b/test/adminFinancialOps.test.js @@ -0,0 +1,225 @@ +const fs = require('fs'); +const path = require('path'); +const createAdminRoutes = require('../src/routes/admin'); +const AlertService = require('../src/services/alertService'); + +function makeRouter(db) { + return createAdminRoutes({ + db, + gameModeManager: {}, + walletRPCService: {}, + socketHandlers: {}, + io: {}, + alertService: null + }); +} + +function handlerFor(router, method, routePath) { + const layer = router.stack.find(item => item.route?.path === routePath + && item.route.methods[method]); + if (!layer) throw new Error(`Missing ${method.toUpperCase()} ${routePath}`); + return layer.route.stack[layer.route.stack.length - 1].handle; +} + +function invoke(handler, req) { + return new Promise((resolve, reject) => { + const res = { + statusCode: 200, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + resolve({ status: this.statusCode, payload }); + return this; + } + }; + handler(req, res, reject); + }); +} + +describe('admin financial operations visibility', () => { + test.each(['processing', 'needs_review'])( + 'payout list accepts the %s filter and reports every operational total', + async status => { + const db = { + query: jest.fn() + .mockResolvedValueOnce({ rows: [{ + id: 7, + game_id: 8, + user_id: 9, + amount: '1200', + payout_address: 'address', + status, + tx_hash: null, + fee: null, + retry_count: 1, + last_error: 'ambiguous result', + created_at: new Date('2026-07-21T00:00:00Z'), + processed_at: null + }] }) + .mockResolvedValueOnce({ rows: [{ + pending_count: '1', + processing_count: '2', + failed_count: '3', + review_count: '4', + completed_count: '5', + permanently_failed_count: '6', + batched_count: '7', + total_volume: '8000' + }] }) + }; + const handler = handlerFor(makeRouter(db), 'get', '/api/admin/stats/payouts'); + + const response = await invoke(handler, { query: { status } }); + + expect(response.status).toBe(200); + expect(db.query.mock.calls[0][1]).toEqual([50, 0, status]); + expect(response.payload.payouts[0]).toMatchObject({ status, lastError: 'ambiguous result' }); + expect(response.payload.totals).toMatchObject({ + pending: 1, + processing: 2, + failed: 3, + needsReview: 4, + completed: 5, + permanentlyFailed: 6, + batched: 7 + }); + } + ); + + test('refund attention filter lists requested, processing, and needs_review rows', async () => { + const db = { + query: jest.fn() + .mockResolvedValueOnce({ rows: [{ + id: 11, + payment_id: 12, + user_id: 13, + payment_status: 'refunded', + status: 'needs_review', + amount: '900', + payout_address: 'refund-address', + credits_deducted: '2', + purchase_progress_deducted: '2', + race_entries_deducted: 0, + tx_hash: null, + error_message: 'wallet outcome unknown', + reason: 'operator request', + created_at: new Date('2026-07-21T00:00:00Z'), + updated_at: new Date('2026-07-21T00:01:00Z') + }] }) + .mockResolvedValueOnce({ rows: [{ + recorded_count: '1', + requested_count: '2', + processing_count: '3', + completed_count: '4', + review_count: '5', + completed_volume: '600' + }] }) + }; + const handler = handlerFor(makeRouter(db), 'get', '/api/admin/stats/refunds'); + + const response = await invoke(handler, { query: { status: 'attention' } }); + + expect(db.query.mock.calls[0][1]).toEqual([ + 50, + 0, + ['requested', 'processing', 'needs_review'] + ]); + expect(response.payload.refunds[0]).toMatchObject({ + id: 11, + status: 'needs_review', + error: 'wallet outcome unknown' + }); + expect(response.payload.totals).toMatchObject({ + recorded: 1, + requested: 2, + processing: 3, + completed: 4, + needsReview: 5 + }); + }); + + test('dashboard sends the retry endpoint its required explicit confirmation', () => { + const html = fs.readFileSync(path.join(__dirname, '../html/admin.html'), 'utf8'); + const retryFunction = html.match(/async function retryPayout[\s\S]*?async function loadRefunds/)[0]; + + expect(retryFunction).toContain("'Content-Type': 'application/json'"); + expect(retryFunction).toContain('body: JSON.stringify({ confirm: true })'); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain('id="refundStatusFilter"'); + }); +}); + +describe('financial review alerts', () => { + let logSpy; + let errorSpy; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + test('alerts for manual-review and stale nonterminal rows without mutating them', async () => { + const db = { query: jest.fn().mockResolvedValue({ rows: [{ + payout_review_count: '2', + refund_review_count: '1', + stale_payout_processing_count: '1', + stale_refund_nonterminal_count: '2' + }] }) }; + const service = new AlertService({ db, walletService: null, debugManager: {} }); + service.sendAlert = jest.fn().mockResolvedValue({ sent: true }); + service.resolveAlert = jest.fn().mockResolvedValue({ sent: false }); + + await service.checkFinancialReviewRows(); + + expect(db.query).toHaveBeenCalledTimes(1); + expect(db.query.mock.calls[0][1]).toEqual([service.financialReviewStaleMs]); + expect(service.sendAlert).toHaveBeenCalledWith( + 'financial_needs_review', + expect.objectContaining({ level: 'crit' }) + ); + expect(service.sendAlert).toHaveBeenCalledWith( + 'financial_nonterminal_stale', + expect.objectContaining({ level: 'crit' }) + ); + expect(service.resolveAlert).not.toHaveBeenCalled(); + expect(db.query.mock.calls[0][0]).not.toMatch(/\bUPDATE\b/i); + }); + + test('resolves both aggregate alerts after all review queues clear', async () => { + const db = { query: jest.fn().mockResolvedValue({ rows: [{ + payout_review_count: '0', + refund_review_count: '0', + stale_payout_processing_count: '0', + stale_refund_nonterminal_count: '0' + }] }) }; + const service = new AlertService({ db, walletService: null, debugManager: {} }); + service.sendAlert = jest.fn(); + service.resolveAlert = jest.fn().mockResolvedValue({ sent: true }); + + await service.checkFinancialReviewRows(); + + expect(service.sendAlert).not.toHaveBeenCalled(); + expect(service.resolveAlert).toHaveBeenCalledWith('financial_needs_review'); + expect(service.resolveAlert).toHaveBeenCalledWith('financial_nonterminal_stale'); + }); + + test('periodic checks include the financial review query', async () => { + const service = new AlertService({ db: {}, walletService: null, debugManager: {} }); + service.checkWalletBalance = jest.fn().mockResolvedValue(); + service.checkWalletConnection = jest.fn().mockResolvedValue(); + service.checkPendingPayouts = jest.fn().mockResolvedValue(); + service.checkFinancialReviewRows = jest.fn().mockResolvedValue(); + + await service.runChecks(); + + expect(service.checkFinancialReviewRows).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/adminRoutes.test.js b/test/adminRoutes.test.js index 7ac0c16..1916474 100644 --- a/test/adminRoutes.test.js +++ b/test/adminRoutes.test.js @@ -38,6 +38,7 @@ describe('admin routes factory', () => { '/api/admin/users/search', '/api/admin/stats/overview', '/api/admin/stats/payouts', + '/api/admin/stats/refunds', '/api/admin/stats/games', '/api/admin/users', '/api/admin/users/:id', @@ -52,7 +53,14 @@ describe('admin routes factory', () => { describe('auth (smirk) routes factory', () => { test('builds a router and registers the smirk endpoints', () => { - const router = createAuthRoutes({ db: { query: jest.fn() } }); + const query = jest.fn(); + const router = createAuthRoutes({ + db: { query, withTransaction: jest.fn() }, + sessionManager: { + generateSecureToken: jest.fn(), + disconnectUserSessions: jest.fn() + } + }); const paths = router.stack.filter(l => l.route).map(l => l.route.path); expect(paths).toEqual(expect.arrayContaining([ '/api/auth/smirk/challenge', diff --git a/test/alertServiceMoney.test.js b/test/alertServiceMoney.test.js new file mode 100644 index 0000000..8dfe2c5 --- /dev/null +++ b/test/alertServiceMoney.test.js @@ -0,0 +1,57 @@ +const AlertService = require('../src/services/alertService'); + +describe('AlertService exact reserve gate', () => { + const keys = ['BALANCE_CRITICAL', 'BALANCE_WARN', 'LOW_BALANCE_THRESHOLD']; + let previous; + let logSpy; + let errorSpy; + + beforeEach(() => { + previous = Object.fromEntries(keys.map(key => [key, process.env[key]])); + process.env.BALANCE_CRITICAL = '10000000000000001'; + process.env.BALANCE_WARN = '10000000000000002'; + delete process.env.LOW_BALANCE_THRESHOLD; + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + for (const key of keys) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + }); + + test('compares balances above Number.MAX_SAFE_INTEGER without rounding', async () => { + const service = new AlertService({ + walletService: { + isHealthy: true, + getBalance: jest.fn(async () => ({ unlocked_balance: '10000000000000000' })) + }, + db: null, + debugManager: { CONSOLE_LOGGING: false } + }); + + expect(service.balanceCriticalThreshold).toBe(10000000000000001n); + await expect(service.checkBalanceForGameStart()).resolves.toEqual(expect.objectContaining({ halted: true })); + }); + + test('wallet balance read errors fail closed for new paid games', async () => { + const service = new AlertService({ + walletService: { + isHealthy: true, + getBalance: jest.fn(async () => { throw new Error('rpc unavailable'); }) + }, + db: null, + debugManager: { CONSOLE_LOGGING: false } + }); + + await expect(service.checkBalanceForGameStart()).resolves.toEqual(expect.objectContaining({ + halted: true, + reason: expect.stringContaining('could not be verified') + })); + expect(service.isBalanceCritical).toBe(true); + }); +}); diff --git a/test/batchPayout.test.js b/test/batchPayout.test.js index 65ddf57..725a0fc 100644 --- a/test/batchPayout.test.js +++ b/test/batchPayout.test.js @@ -52,12 +52,35 @@ const TWO_PENDING = [ { id: 10, user_id: 1, game_id: 'g1', payout_address: 'addrA', amount: '200000000000', multiplier: 2, reason: 'escape' }, { id: 11, user_id: 2, game_id: 'g2', payout_address: 'addrB', amount: '300000000000', multiplier: 3, reason: 'escape' } ]; +const ONE_PENDING = [TWO_PENDING[0]]; +const SHARED_TX_HASH = 'e'.repeat(64); describe('Batch payout dispatch', () => { + test('shared runtime dispatch predicate stops internal batch paths before DB or wallet work', async () => { + const walletService = { + getBalance: jest.fn(), + processPayout: jest.fn(), + processBatchPayout: jest.fn() + }; + const { gmm, db } = buildGmm(walletService); + gmm.payoutDispatchAllowed = jest.fn(() => false); + + gmm._scheduleBatchPayout(); + await gmm._processPendingPayouts(); + + expect(gmm.payoutDispatchAllowed).toHaveBeenCalled(); + expect(gmm._batchPayoutTimer).toBeNull(); + expect(db.withTransaction).not.toHaveBeenCalled(); + expect(db.query).not.toHaveBeenCalled(); + expect(walletService.processPayout).not.toHaveBeenCalled(); + expect(walletService.processBatchPayout).not.toHaveBeenCalled(); + }); + test('marks the whole batch completed with the shared tx_hash in one transaction', async () => { const walletService = { processPayout: jest.fn(), - processBatchPayout: jest.fn().mockResolvedValue({ tx_hash_list: ['txSHARED'], totalFee: 200 }) + processBatchPayout: jest.fn().mockResolvedValue({ success: true, tx_hash_list: [SHARED_TX_HASH], totalFee: 200 }), + getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }) }; const { gmm, db } = buildGmm(walletService); @@ -79,7 +102,7 @@ describe('Batch payout dispatch', () => { expect(batchUpdate).toMatch(/status = 'completed'/); const batchUpdateCall = db._mockClient.query.mock.calls.find(c => /UPDATE payouts SET tx_hash/i.test(c[0]) && /id = ANY/i.test(c[0])); - expect(batchUpdateCall[1][0]).toBe('txSHARED'); // tx_hash param + expect(batchUpdateCall[1][0]).toBe(SHARED_TX_HASH); // tx_hash param expect(batchUpdateCall[1][3]).toEqual([10, 11]); // ids array // Per-user stats incremented for each winner inside the same transaction (BIGINT, no float). @@ -112,6 +135,78 @@ describe('Batch payout dispatch', () => { expect(failedCall).toBeUndefined(); }); + test('single payout retains a valid wallet hash when the completion DB write fails', async () => { + const walletService = { + getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }), + processPayout: jest.fn().mockResolvedValue({ success: true, txHash: SHARED_TX_HASH, fee: 20 }), + processBatchPayout: jest.fn() + }; + const { gmm, db } = buildGmm(walletService); + db._mockClient.query + .mockResolvedValueOnce({ rows: ONE_PENDING }) + .mockResolvedValueOnce({ rows: [] }) + .mockRejectedValueOnce(new Error('database write failed after broadcast')); + + await gmm._processPendingPayouts(); + + const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review'); + expect(review).toBeDefined(); + expect(review[0]).toMatch(/tx_hash = COALESCE\(\$3, tx_hash\)/i); + expect(review[1][2]).toBe(SHARED_TX_HASH); + expect(review[1][3]).toBe(10); + expect(db.query.mock.calls.some(call => call[1]?.[0] === 'pending')).toBe(false); + expect(walletService.processPayout).toHaveBeenCalledTimes(1); + }); + + test('batch retains its one valid wallet hash when the completion DB write fails', async () => { + const walletService = { + getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }), + processPayout: jest.fn(), + processBatchPayout: jest.fn().mockResolvedValue({ + success: true, + tx_hash_list: [SHARED_TX_HASH], + totalFee: 200 + }) + }; + const { gmm, db } = buildGmm(walletService); + db._mockClient.query + .mockResolvedValueOnce({ rows: TWO_PENDING }) + .mockResolvedValueOnce({ rows: [] }) + .mockRejectedValueOnce(new Error('database write failed after batch broadcast')); + + await gmm._processPendingPayouts(); + + const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review'); + expect(review).toBeDefined(); + expect(review[0]).toMatch(/tx_hash = COALESCE\(\$4, tx_hash\)/i); + expect(review[1][2]).toEqual([10, 11]); + expect(review[1][3]).toBe(SHARED_TX_HASH); + expect(db.query.mock.calls.some(call => call[1]?.[0] === 'pending')).toBe(false); + expect(walletService.processBatchPayout).toHaveBeenCalledTimes(1); + }); + + test('multiple valid wallet hashes are not guessed onto every liability', async () => { + const walletService = { + getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }), + processPayout: jest.fn(), + processBatchPayout: jest.fn().mockResolvedValue({ + success: true, + tx_hash_list: ['a'.repeat(64), 'b'.repeat(64)], + totalFee: 200 + }) + }; + const { gmm, db } = buildGmm(walletService); + db._mockClient.query + .mockResolvedValueOnce({ rows: TWO_PENDING }) + .mockResolvedValue({ rows: [] }); + + await gmm._processPendingPayouts(); + + const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review'); + expect(review).toBeDefined(); + expect(db._mockClient.query.mock.calls.some(call => /status = 'completed'/i.test(call[0]))).toBe(false); + }); + test('MONERO LOCKING: defers the batch to pending when unlocked balance is insufficient (no transfer attempted)', async () => { // TWO_PENDING needs 200000000000 + 300000000000 = 500000000000 atomic. // Unlocked balance is far below that (outputs locked ~10 blocks) -> defer, do not send. @@ -138,6 +233,50 @@ describe('Batch payout dispatch', () => { expect(deferSql[1][0]).toEqual([10, 11]); // ids array param }); + test('defers without transfer when unlocked balance cannot be verified', async () => { + const walletService = { + getBalance: jest.fn().mockRejectedValue(new Error('wallet unavailable')), + processPayout: jest.fn(), + processBatchPayout: jest.fn() + }; + const { gmm, db } = buildGmm(walletService); + db._mockClient.query + .mockResolvedValueOnce({ rows: TWO_PENDING }) + .mockResolvedValue({ rows: [] }); + + await gmm._processPendingPayouts(); + + expect(walletService.processPayout).not.toHaveBeenCalled(); + expect(walletService.processBatchPayout).not.toHaveBeenCalled(); + const deferred = db.query.mock.calls.find(call => /wallet unlocked balance could not be verified/.test(call[1]?.[1] || '')); + expect(deferred).toBeDefined(); + expect(deferred[1][0]).toEqual([10, 11]); + }); + + test('rechecks a gate that closes after claim and wallet probe before transfer', async () => { + const walletService = { + getBalance: jest.fn().mockResolvedValue({ + balance: '10000000000000', + unlocked_balance: '10000000000000' + }), + processPayout: jest.fn(), + processBatchPayout: jest.fn() + }; + const { gmm, db } = buildGmm(walletService); + let checks = 0; + gmm.payoutDispatchAllowed = jest.fn(() => ++checks === 1); + db._mockClient.query + .mockResolvedValueOnce({ rows: TWO_PENDING }) + .mockResolvedValue({ rows: [] }); + + await gmm._processPendingPayouts(); + + expect(walletService.getBalance).toHaveBeenCalledTimes(1); + expect(walletService.processPayout).not.toHaveBeenCalled(); + expect(walletService.processBatchPayout).not.toHaveBeenCalled(); + expect(db.query.mock.calls.some(call => call[1]?.[1] === 'Payout dispatch paused before wallet transfer')).toBe(true); + }); + test('a pre-broadcast insufficient-funds batch error is retried (pending), not needs_review', async () => { const walletService = { getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }), diff --git a/test/canaryDatabaseIdentity.test.js b/test/canaryDatabaseIdentity.test.js new file mode 100644 index 0000000..3ac51c7 --- /dev/null +++ b/test/canaryDatabaseIdentity.test.js @@ -0,0 +1,164 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + CANARY_HANDSHAKE_CONFIRM, + CANARY_HANDSHAKE_PROTOCOL, + computeProof, + createCanaryDatabaseIdentityHandler, + equalProof, + readDatabaseIdentity +} = require('../src/services/canaryDatabaseIdentity'); + +function protectedNonceFile(nonce = 'ab'.repeat(32)) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wowngeon-canary-nonce-')); + const fileName = path.join(directory, 'nonce'); + fs.writeFileSync(fileName, `${nonce}\n`, { mode: 0o600 }); + return { directory, fileName, nonce }; +} + +function enabledEnv(fileName, overrides = {}) { + return { + NODE_ENV: 'production', + CRYPTO_TYPE: 'XMR', + MONERO_NETWORK: 'stagenet', + PORT: '3102', + DB_NAME: 'monerogue_canary_direct_e2e', + CANARY_EXPECT_DATABASE: 'monerogue_canary_direct_e2e', + CANARY_DATABASE_HANDSHAKE: CANARY_HANDSHAKE_CONFIRM, + CANARY_DATABASE_NONCE_FILE: fileName, + ...overrides + }; +} + +function responseRecorder() { + return { + statusCode: 200, + body: null, + status(code) { this.statusCode = code; return this; }, + json(body) { this.body = body; return this; } + }; +} + +describe('isolated canary database identity endpoint', () => { + test('is absent by default and fails closed on partial or non-stagenet configuration', () => { + expect(createCanaryDatabaseIdentityHandler({ db: {}, env: {} })).toBeNull(); + expect(() => createCanaryDatabaseIdentityHandler({ + db: {}, env: { CANARY_EXPECT_DATABASE: 'monerogue_canary_direct_e2e' } + })).toThrow(/CANARY_DATABASE_HANDSHAKE/); + + const fixture = protectedNonceFile(); + try { + expect(() => createCanaryDatabaseIdentityHandler({ + db: {}, env: enabledEnv(fixture.fileName, { MONERO_NETWORK: 'mainnet' }) + })).toThrow(/XMR stagenet/); + expect(() => createCanaryDatabaseIdentityHandler({ + db: {}, env: enabledEnv(fixture.fileName, { PORT: '3000' }) + })).toThrow(/3102/); + expect(() => createCanaryDatabaseIdentityHandler({ + db: {}, env: enabledEnv(fixture.fileName, { DB_NAME: 'monerogue' }) + })).toThrow(/DB_NAME/); + + fs.chmodSync(fixture.fileName, 0o644); + expect(() => createCanaryDatabaseIdentityHandler({ + db: {}, env: enabledEnv(fixture.fileName) + })).toThrow(/group\/world/); + fs.chmodSync(fixture.fileName, 0o600); + const link = path.join(fixture.directory, 'nonce-link'); + fs.symlinkSync(fixture.fileName, link); + expect(() => createCanaryDatabaseIdentityHandler({ + db: {}, env: enabledEnv(link) + })).toThrow(); + } finally { + fs.rmSync(fixture.directory, { recursive: true, force: true }); + } + }); + + test('signs the application pool exact cluster, OID, database, and caller challenge', async () => { + const fixture = protectedNonceFile(); + const identity = { + cluster_id: '7612345678901234567', + database_oid: '24591', + database_name: 'monerogue_canary_direct_e2e' + }; + const db = { query: jest.fn().mockResolvedValue({ rows: [identity] }) }; + try { + const handler = createCanaryDatabaseIdentityHandler({ + db, env: enabledEnv(fixture.fileName) + }); + const challenge = '12'.repeat(32); + const response = responseRecorder(); + await handler({ + get: () => challenge, + socket: { remoteAddress: '::ffff:127.0.0.1' } + }, response); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(expect.objectContaining({ + protocol: CANARY_HANDSHAKE_PROTOCOL, + challenge, + database: { + clusterId: identity.cluster_id, + databaseOid: identity.database_oid, + databaseName: identity.database_name + } + })); + const expected = computeProof(fixture.nonce, response.body.database, challenge); + expect(equalProof(response.body.proof, expected)).toBe(true); + expect(JSON.stringify(response.body)).not.toContain(fixture.nonce); + expect(db.query.mock.calls[0][0]).toContain('pg_control_system()'); + } finally { + fs.rmSync(fixture.directory, { recursive: true, force: true }); + } + }); + + test('rejects malformed challenges and refuses an application pool on another database', async () => { + const fixture = protectedNonceFile(); + try { + const wrongDb = { query: jest.fn().mockResolvedValue({ rows: [{ + cluster_id: '7612345678901234567', + database_oid: '24592', + database_name: 'monerogue_canary_credits_e2e' + }] }) }; + const handler = createCanaryDatabaseIdentityHandler({ + db: wrongDb, env: enabledEnv(fixture.fileName) + }); + const malformed = responseRecorder(); + const remote = responseRecorder(); + await handler({ + get: () => '34'.repeat(32), socket: { remoteAddress: '192.0.2.10' } + }, remote); + expect(remote.statusCode).toBe(404); + expect(wrongDb.query).not.toHaveBeenCalled(); + + await handler({ + get: () => 'not-a-challenge', socket: { remoteAddress: '127.0.0.1' } + }, malformed); + expect(malformed.statusCode).toBe(400); + expect(wrongDb.query).not.toHaveBeenCalled(); + + const mismatch = responseRecorder(); + await handler({ + get: () => '34'.repeat(32), socket: { remoteAddress: '::1' } + }, mismatch); + expect(mismatch.statusCode).toBe(503); + expect(mismatch.body).toEqual({ error: 'Canary database identity mismatch.' }); + } finally { + fs.rmSync(fixture.directory, { recursive: true, force: true }); + } + }); + + test('requires one unambiguous row from PostgreSQL identity functions', async () => { + await expect(readDatabaseIdentity({ + query: jest.fn().mockResolvedValue({ rows: [] }) + })).rejects.toThrow(/ambiguous/); + await expect(readDatabaseIdentity({ + query: jest.fn().mockResolvedValue({ rows: [{ + cluster_id: '', database_oid: '1', database_name: 'db' + }] }) + })).rejects.toThrow(/cluster identity/); + }); +}); diff --git a/test/cloneMigrationRunner.test.js b/test/cloneMigrationRunner.test.js new file mode 100644 index 0000000..650097b --- /dev/null +++ b/test/cloneMigrationRunner.test.js @@ -0,0 +1,234 @@ +'use strict'; + +const { + CloneMigrationVerificationError, + MINIMUM_RELEASE_MIGRATION, + cloneMigrationConfirmation, + discoverMigrationManifest, + isDisposableCloneDatabaseName, + runCloneMigrations, + validateCloneMigrationEnvironment, + verifyExactMigrationLedger +} = require('../src/scripts/migrate-disposable-clone'); + +function safeEnvironment(overrides = {}) { + const database = overrides.DB_NAME || 'monerogue_restore_release_20260721'; + return { + DB_HOST: '127.0.0.1', + DB_PORT: '5432', + DB_NAME: database, + DB_USER: 'monerogue_migrator', + CLONE_MIGRATION_EXPECT_DATABASE: database, + CLONE_MIGRATION_CONFIRM: cloneMigrationConfirmation(database), + ...overrides + }; +} + +class FakePool { + constructor(config, { actualDatabase = config.database, hideLatest = false } = {}) { + this.config = config; + this.actualDatabase = actualDatabase; + this.hideLatest = hideLatest; + this.ledger = new Set(); + this.transactions = []; + this.ended = false; + } + + async connect() { + const pool = this; + let pending = null; + return { + async query(sql, params = []) { + if (/current_database\(\)/.test(sql)) { + return { rows: [{ database_name: pool.actualDatabase }], rowCount: 1 }; + } + if (sql === 'BEGIN') { + pending = null; + return { rows: [], rowCount: null }; + } + if (/INSERT INTO schema_migrations/.test(sql)) { + pending = params[0]; + return { rows: [], rowCount: 1 }; + } + if (sql === 'COMMIT') { + if (pending) pool.ledger.add(pending); + pool.transactions.push(pending); + pending = null; + return { rows: [], rowCount: null }; + } + if (sql === 'ROLLBACK') { + pending = null; + return { rows: [], rowCount: null }; + } + return { rows: [], rowCount: null }; + }, + release() {} + }; + } + + async query(sql, params = []) { + if (/CREATE TABLE IF NOT EXISTS schema_migrations/.test(sql)) { + return { rows: [], rowCount: null }; + } + if (/SELECT 1 FROM schema_migrations/.test(sql)) { + return { rows: [], rowCount: this.ledger.has(params[0]) ? 1 : 0 }; + } + if (/SELECT filename FROM schema_migrations/.test(sql)) { + let filenames = [...this.ledger].sort(); + if (this.hideLatest) filenames = filenames.slice(0, -1); + return { rows: filenames.map(filename => ({ filename })), rowCount: filenames.length }; + } + throw new Error(`unexpected fake pool query: ${sql}`); + } + + async end() { + this.ended = true; + } +} + +describe('disposable clone migration target guard', () => { + test.each([ + 'monerogue_clone_release_a1', + 'wownerogue_restore_20260721', + 'wowngeon_canary_direct_e2e' + ])('accepts an explicitly disposable application database: %s', database => { + expect(isDisposableCloneDatabaseName(database)).toBe(true); + expect(validateCloneMigrationEnvironment(safeEnvironment({ DB_NAME: database }), [])) + .toEqual(expect.objectContaining({ database, expectedDatabase: database })); + }); + + test.each([ + 'monerogue', + 'wownerogue', + 'wowngeon', + 'monerogue_prod_clone', + 'monerogue_clone_prd', + 'monerogue_clone_production2026', + 'monerogue_master_restore', + 'wownerogue_clone_live', + 'wownerogue_clone_live1', + 'monerogue_mainnet_restore', + 'postgres_restore_20260721', + 'monerogue_staging_20260721', + 'monerogue-clone-release' + ])('refuses a live-looking, unscoped, or malformed name: %s', database => { + expect(isDisposableCloneDatabaseName(database)).toBe(false); + expect(() => validateCloneMigrationEnvironment(safeEnvironment({ DB_NAME: database }), [])) + .toThrow(/clone, restore, or canary/); + }); + + test('requires an exact expected database, database-bound confirmation, and local host', () => { + expect(() => validateCloneMigrationEnvironment(safeEnvironment({ + CLONE_MIGRATION_EXPECT_DATABASE: 'monerogue_restore_other' + }), [])).toThrow(/exactly equal/); + expect(() => validateCloneMigrationEnvironment(safeEnvironment({ + CLONE_MIGRATION_CONFIRM: 'yes' + }), [])).toThrow(/exactly bind/); + expect(() => validateCloneMigrationEnvironment(safeEnvironment({ + DB_HOST: 'database.internal' + }), [])).toThrow(/loopback/); + expect(() => validateCloneMigrationEnvironment(safeEnvironment({ + DB_HOST: '/tmp/untrusted-postgresql-socket' + }), [])).toThrow(/loopback/); + expect(validateCloneMigrationEnvironment(safeEnvironment({ + DB_HOST: '/var/run/postgresql' + }), [])).toEqual(expect.objectContaining({ host: '/var/run/postgresql' })); + }); + + test('forbids argv and credential-bearing DATABASE_URL before constructing a pool', async () => { + expect(() => validateCloneMigrationEnvironment(safeEnvironment(), ['--database', 'x'])) + .toThrow(/command-line/); + expect(() => validateCloneMigrationEnvironment(safeEnvironment({ + DATABASE_URL: 'postgres://user:secret@127.0.0.1/db' + }), [])).toThrow(/DATABASE_URL/); + + const poolFactory = jest.fn(); + await expect(runCloneMigrations({ + env: safeEnvironment(), argv: ['--password', 'secret'], poolFactory + })).rejects.toThrow(/command-line/); + expect(poolFactory).not.toHaveBeenCalled(); + }); +}); + +describe('disposable clone migration execution', () => { + let logSpy; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + test('discovers the contiguous repository manifest through migration 40 dynamically', () => { + const manifest = discoverMigrationManifest(); + expect(manifest.latestOrdinal).toBeGreaterThanOrEqual(MINIMUM_RELEASE_MIGRATION); + expect(manifest.latestFilename).toBe('040_paid_match_entropy_precommit.sql'); + expect(manifest.files).toHaveLength(40); + }); + + test('runs the normal ordered migration implementation and proves the exact ledger', async () => { + const secret = 'do-not-print-this-database-password'; + const env = safeEnvironment({ DB_PASSWORD: secret }); + let pool; + const output = []; + + const result = await runCloneMigrations({ + env, + argv: [], + poolFactory: config => { + pool = new FakePool(config); + return pool; + }, + logger: { info: line => output.push(line) } + }); + + expect(pool.transactions).toEqual(result.files); + expect(result.latestFilename).toBe('040_paid_match_entropy_precommit.sql'); + expect(output).toContain('clone_migration_status=ok'); + expect(output).toContain('latest_migration=040_paid_match_entropy_precommit.sql'); + expect(output.join('\n')).not.toContain(secret); + expect(pool.config).toEqual(expect.objectContaining({ + database: env.DB_NAME, + host: '127.0.0.1', + max: 1, + application_name: 'wowngeon-disposable-clone-migrations' + })); + expect(pool.ended).toBe(true); + }); + + test('checks server database identity before applying a migration', async () => { + let pool; + await expect(runCloneMigrations({ + env: safeEnvironment(), + argv: [], + poolFactory: config => { + pool = new FakePool(config, { actualDatabase: 'monerogue_restore_wrong' }); + return pool; + }, + logger: { info: jest.fn() } + })).rejects.toThrow(/exact expected clone name/); + expect(pool.transactions).toEqual([]); + expect(pool.ended).toBe(true); + }); + + test('fails closed if the post-run ledger is not exactly the repository manifest', async () => { + await expect(runCloneMigrations({ + env: safeEnvironment(), + argv: [], + poolFactory: config => new FakePool(config, { hideLatest: true }), + logger: { info: jest.fn() } + })).rejects.toBeInstanceOf(CloneMigrationVerificationError); + }); + + test('rejects missing, extra, or reordered ledger entries', () => { + const manifest = discoverMigrationManifest(); + const rows = manifest.files.map(filename => ({ filename })); + expect(verifyExactMigrationLedger(rows, manifest)).toBe(true); + expect(() => verifyExactMigrationLedger(rows.slice(1), manifest)).toThrow(/count/); + expect(() => verifyExactMigrationLedger([...rows].reverse(), manifest)).toThrow(/differs/); + expect(() => verifyExactMigrationLedger([...rows, { filename: '999_unknown.sql' }], manifest)) + .toThrow(/count/); + }); +}); diff --git a/test/commerceConsentClient.test.js b/test/commerceConsentClient.test.js new file mode 100644 index 0000000..2fd8b26 --- /dev/null +++ b/test/commerceConsentClient.test.js @@ -0,0 +1,100 @@ +const STORAGE_KEY = 'wowngeon_paid_ack'; + +function policy(version, isTestNetwork = false) { + return { + policyVersion: version, + paidAcknowledgementRequired: true, + minimumAge: 18, + service: { + isTestNetwork, + currencyLabel: isTestNetwork ? 'sXMR' : 'WOW', + cryptoType: isTestNetwork ? 'XMR' : 'WOW' + }, + links: { terms: '/terms', privacy: '/privacy', responsiblePlay: '/responsible-play' } + }; +} + +function response(value) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(value) }); +} + +describe('browser commerce acknowledgement cache', () => { + let values; + + beforeEach(() => { + jest.resetModules(); + values = new Map(); + global.sessionStorage = { + getItem: jest.fn(key => values.has(key) ? values.get(key) : null), + setItem: jest.fn((key, value) => values.set(key, value)), + removeItem: jest.fn(key => values.delete(key)) + }; + global.fetch = jest.fn(); + delete global.CommerceConsent; + }); + + afterEach(() => { + delete global.fetch; + delete global.sessionStorage; + delete global.CommerceConsent; + }); + + test('accepts only the five-field canonical cache and clears extra data', async () => { + global.fetch.mockReturnValueOnce(response(policy('browser-v1'))); + const consent = require('../html/js/legal/commerceConsent'); + await consent.loadPolicy(); + values.set(STORAGE_KEY, JSON.stringify({ + policyVersion: 'browser-v1', + ageEligible: true, + termsRead: true, + riskAccepted: true, + testnetUnderstood: false, + acknowledgedAt: 'must-not-be-stored' + })); + + expect(consent.acknowledgement()).toBeNull(); + expect(values.has(STORAGE_KEY)).toBe(false); + }); + + test('server version rejection clears acceptance and refetches current policy', async () => { + global.fetch + .mockReturnValueOnce(response(policy('browser-v1'))) + .mockReturnValueOnce(response(policy('browser-v2'))); + const consent = require('../html/js/legal/commerceConsent'); + await consent.loadPolicy(); + values.set(STORAGE_KEY, JSON.stringify({ + policyVersion: 'browser-v1', + ageEligible: true, + termsRead: true, + riskAccepted: true, + testnetUnderstood: false + })); + expect(consent.acknowledgement()).toEqual(expect.objectContaining({ policyVersion: 'browser-v1' })); + + const refreshed = await consent.reject({ code: 'PAID_ACK_VERSION', policyVersion: 'browser-v2' }); + + expect(refreshed.policyVersion).toBe('browser-v2'); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(consent.acknowledgement()).toBeNull(); + expect(values.has(STORAGE_KEY)).toBe(false); + }); + + test('disconnect-facing clear removes both memory and session copies', async () => { + global.fetch.mockReturnValueOnce(response(policy('browser-v1'))); + const consent = require('../html/js/legal/commerceConsent'); + await consent.loadPolicy(); + values.set(STORAGE_KEY, JSON.stringify({ + policyVersion: 'browser-v1', + ageEligible: true, + termsRead: true, + riskAccepted: true, + testnetUnderstood: false + })); + expect(consent.acknowledgement()).not.toBeNull(); + + consent.clear(); + + expect(consent.acknowledgement()).toBeNull(); + expect(values.has(STORAGE_KEY)).toBe(false); + }); +}); diff --git a/test/commerceDisclosurePolicy.test.js b/test/commerceDisclosurePolicy.test.js new file mode 100644 index 0000000..dc2d472 --- /dev/null +++ b/test/commerceDisclosurePolicy.test.js @@ -0,0 +1,216 @@ +const { + buildCommerceDisclosure, + safeContactUrl, + validatePaidAcknowledgement +} = require('../src/config/commerceDisclosurePolicy'); +const PaymentHandlers = require('../src/network/paymentHandlers'); + +function manager(overrides = {}) { + return { + cryptoType: 'XMR', + currencyLabel: 'sXMR', + network: 'stagenet', + isTestNetwork: true, + gameName: 'Monerogue', + paymentsEnabled: true, + freePlayEnabled: true, + directModeEnabled: true, + creditsModeEnabled: true, + payoutsEnabled: true, + isPayoutEnabledForMode: mode => mode === 'PAID_SINGLE', + ...overrides + }; +} + +function env(overrides = {}) { + return { + NODE_ENV: 'production', + LEGAL_POLICY_VERSION: '2026-07-21-v1', + TERMS_EFFECTIVE_DATE: '2026-07-21', + MINIMUM_AGE: '21', + PAID_ACKNOWLEDGEMENT_REQUIRED: 'true', + OPERATOR_NAME: 'Test Operator', + OPERATOR_CONTACT_URL: 'mailto:support@example.invalid', + MATCH_ENABLED: 'false', + ...overrides + }; +} + +describe('commerce disclosure and paid-action policy', () => { + test('describes active test-network payout mode without conflating it with mainnet', () => { + const disclosure = buildCommerceDisclosure(manager(), env()); + + expect(disclosure).toEqual(expect.objectContaining({ + policyVersion: '2026-07-21-v1', + minimumAge: 21, + paidAcknowledgementRequired: true + })); + expect(disclosure.service).toEqual(expect.objectContaining({ + network: 'stagenet', + isTestNetwork: true, + paidPrestigeOnly: false, + soloPayoutsEnabled: true + })); + }); + + test('describes paid prestige separately when every payout path is off', () => { + const disclosure = buildCommerceDisclosure(manager({ + cryptoType: 'WOW', currencyLabel: 'WOW', network: 'mainnet', isTestNetwork: false, + payoutsEnabled: false, isPayoutEnabledForMode: () => false + }), env()); + + expect(disclosure.service.paidPrestigeOnly).toBe(true); + expect(disclosure.service.anyPayoutsEnabled).toBe(false); + }); + + test('does not advertise a stale solo payout flag when that paid mode is disabled', () => { + const disclosure = buildCommerceDisclosure(manager({ + directModeEnabled: false, + creditsModeEnabled: true, + isPayoutEnabledForMode: mode => mode === 'PAID_SINGLE' + }), env()); + + expect(disclosure.service.directPaidEntryEnabled).toBe(false); + expect(disclosure.service.paidCreditsEnabled).toBe(true); + expect(disclosure.service.soloPayoutsEnabled).toBe(false); + + const activeCreditsPayout = buildCommerceDisclosure(manager({ + directModeEnabled: false, + creditsModeEnabled: true, + isPayoutEnabledForMode: mode => mode === 'PAID_CREDITS' + }), env()); + expect(activeCreditsPayout.service.soloPayoutsEnabled).toBe(true); + + const noPaidModes = buildCommerceDisclosure(manager({ + directModeEnabled: false, + creditsModeEnabled: false, + isPayoutEnabledForMode: () => true + }), env()); + expect(noPaidModes.service.soloPayoutsEnabled).toBe(false); + expect(noPaidModes.service.anyPayoutsEnabled).toBe(false); + }); + + test('fails closed on missing, stale, incomplete, or testnet-unaware acknowledgement', () => { + const disclosure = buildCommerceDisclosure(manager(), env()); + const good = { + policyVersion: disclosure.policyVersion, + ageEligible: true, + termsRead: true, + riskAccepted: true, + testnetUnderstood: true + }; + + expect(validatePaidAcknowledgement(null, disclosure).ok).toBe(false); + expect(validatePaidAcknowledgement({ ...good, policyVersion: 'old' }, disclosure).code).toBe('PAID_ACK_VERSION'); + expect(validatePaidAcknowledgement({ ...good, riskAccepted: false }, disclosure).code).toBe('PAID_ACK_INCOMPLETE'); + expect(validatePaidAcknowledgement({ ...good, testnetUnderstood: false }, disclosure).code).toBe('PAID_ACK_TESTNET'); + expect(validatePaidAcknowledgement({ ...good, extra: true }, disclosure).code).toBe('PAID_ACK_MALFORMED'); + expect(validatePaidAcknowledgement({ + policyVersion: disclosure.policyVersion, + ageEligible: true, + termsRead: true, + riskAccepted: true + }, disclosure).code).toBe('PAID_ACK_MALFORMED'); + expect(validatePaidAcknowledgement(good, disclosure)).toEqual({ + ok: true, + code: 'ACKNOWLEDGED', + acknowledgement: Object.freeze(good) + }); + }); + + test('mainnet acknowledgement is canonical and carries an explicit false testnet flag', () => { + const disclosure = buildCommerceDisclosure(manager({ + cryptoType: 'WOW', + currencyLabel: 'WOW', + network: 'mainnet', + isTestNetwork: false + }), env()); + const good = { + policyVersion: disclosure.policyVersion, + ageEligible: true, + termsRead: true, + riskAccepted: true, + testnetUnderstood: false + }; + + expect(validatePaidAcknowledgement(good, disclosure)).toEqual({ + ok: true, + code: 'ACKNOWLEDGED', + acknowledgement: Object.freeze(good) + }); + expect(validatePaidAcknowledgement({ ...good, testnetUnderstood: true }, disclosure).code) + .toBe('PAID_ACK_MALFORMED'); + }); + + test('keeps acknowledgement UI enabled for paid match entitlements while invoice intake is off', () => { + const disclosure = buildCommerceDisclosure(manager({ + paymentsEnabled: false, + directModeEnabled: false, + creditsModeEnabled: false, + freePlayEnabled: true, + _getMatchEconomies: () => ({ free: true, credits_prestige: true }) + }), env({ PAID_ACKNOWLEDGEMENT_REQUIRED: 'false', MATCH_ENABLED: 'true' })); + + expect(disclosure.service.paymentsEnabled).toBe(false); + expect(disclosure.paidAcknowledgementRequired).toBe(true); + }); + + test('allows no acknowledgement only where the policy is actually disabled', () => { + const disclosure = buildCommerceDisclosure(manager({ + paymentsEnabled: false, + directModeEnabled: false, + creditsModeEnabled: false, + freePlayEnabled: false + }), { + NODE_ENV: 'test', PAID_ACKNOWLEDGEMENT_REQUIRED: 'false' + }); + expect(validatePaidAcknowledgement(null, disclosure)).toEqual({ + ok: true, code: 'NOT_REQUIRED', acknowledgement: null + }); + }); + + test('contact URLs reject executable and unrelated schemes', () => { + expect(safeContactUrl('javascript:alert(1)')).toBeNull(); + expect(safeContactUrl('data:text/html,boom')).toBeNull(); + expect(safeContactUrl('mailto:support@example.invalid')).toBe('mailto:support@example.invalid'); + expect(safeContactUrl('https://example.invalid/support')).toBe('https://example.invalid/support'); + }); + + test('payment handler refuses an invoice before wallet or database work when acknowledgement is absent', async () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousVersion = process.env.LEGAL_POLICY_VERSION; + process.env.NODE_ENV = 'production'; + process.env.LEGAL_POLICY_VERSION = 'server-enforcement-v1'; + const emit = jest.fn(); + const createPaymentRequest = jest.fn(); + const handler = new PaymentHandlers({ + io: { to: () => ({ emit }) }, + gameModeManager: { + ...manager({ isTestNetwork: false, network: 'mainnet' }), + createPaymentRequest, + isPayoutEnabledForMode: () => false + }, + walletService: {}, + debugManager: { CONSOLE_LOGGING: false }, + queueManager: {}, + broadcastManager: { sendStatusUpdate: jest.fn() }, + sessionManager: null + }); + try { + await handler.handlePaymentRequest({ id: 'socket-1' }, { type: 'single_game' }); + expect(createPaymentRequest).not.toHaveBeenCalled(); + expect(emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({ + code: 'PAID_ACK_REQUIRED', policyVersion: 'server-enforcement-v1' + })); + expect(emit).toHaveBeenCalledWith('payment_error', expect.objectContaining({ + code: 'PAID_ACK_REQUIRED' + })); + } finally { + handler.dispose(); + if (previousNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previousNodeEnv; + if (previousVersion === undefined) delete process.env.LEGAL_POLICY_VERSION; + else process.env.LEGAL_POLICY_VERSION = previousVersion; + } + }); +}); diff --git a/test/databaseHealth.test.js b/test/databaseHealth.test.js new file mode 100644 index 0000000..a976810 --- /dev/null +++ b/test/databaseHealth.test.js @@ -0,0 +1,15 @@ +const DatabaseManager = require('../src/db/databaseManager'); + +describe('DatabaseManager readiness freshness', () => { + test('requires both a connection and a recent successful query', () => { + const db = new DatabaseManager(); + expect(db.isHealthy()).toBe(false); + + db.connected = true; + db.lastSuccessfulQueryAt = Date.now(); + expect(db.isHealthy(1000)).toBe(true); + + db.lastSuccessfulQueryAt = Date.now() - 2000; + expect(db.isHealthy(1000)).toBe(false); + }); +}); diff --git a/test/debugManagerHealth.test.js b/test/debugManagerHealth.test.js new file mode 100644 index 0000000..fba8ca5 --- /dev/null +++ b/test/debugManagerHealth.test.js @@ -0,0 +1,32 @@ +const DebugManager = require('../src/debug/debugManager'); + +describe('DebugManager daemon freshness', () => { + const originalEnv = process.env.NODE_ENV; + const originalSource = process.env.BLOCK_SOURCE; + + beforeEach(() => { + process.env.NODE_ENV = 'production'; + process.env.BLOCK_SOURCE = 'daemon'; + }); + + afterAll(() => { + if (originalEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalEnv; + if (originalSource === undefined) delete process.env.BLOCK_SOURCE; + else process.env.BLOCK_SOURCE = originalSource; + }); + + test('rejects a stale cached height until a recent RPC poll succeeds', () => { + const manager = new DebugManager({ broadcastBlockHeight: jest.fn() }); + manager.lastProductionBlockHeight = 123; + manager.lastSuccessfulRpcAt = 0; + + expect(manager.isChainHealthy()).toBe(false); + + manager.lastSuccessfulRpcAt = Date.now(); + expect(manager.isChainHealthy(1000)).toBe(true); + + manager.lastSuccessfulRpcAt = Date.now() - 2000; + expect(manager.isChainHealthy(1000)).toBe(false); + }); +}); diff --git a/test/digestAuth.test.js b/test/digestAuth.test.js new file mode 100644 index 0000000..d47673a --- /dev/null +++ b/test/digestAuth.test.js @@ -0,0 +1,31 @@ +const { DigestAuthClient, parseDigestChallenge } = require('../src/payments/digestAuth'); + +describe('wallet RPC Digest authentication', () => { + test('parses quoted qop lists and required fields', () => { + expect(parseDigestChallenge('Digest realm="wallet", nonce="n1", qop="auth,auth-int", algorithm=MD5')) + .toMatchObject({ realm: 'wallet', nonce: 'n1', qop: 'auth,auth-int', algorithm: 'MD5' }); + }); + + test('caches the challenge and increments nonce-count without another unauthenticated probe', async () => { + const unauthorized = new Error('unauthorized'); + unauthorized.response = { + status: 401, + headers: { 'www-authenticate': 'Digest realm="wallet", nonce="nonce", qop="auth"' } + }; + const http = { post: jest.fn() + .mockRejectedValueOnce(unauthorized) + .mockResolvedValue({ data: { result: {} } }) }; + const client = new DigestAuthClient(http, { + username: 'user', + password: 'pass', + randomBytes: () => Buffer.alloc(16, 1) + }); + + await client.post('http://127.0.0.1:38083/json_rpc', { id: '1' }); + await client.post('http://127.0.0.1:38083/json_rpc', { id: '2' }); + + expect(http.post).toHaveBeenCalledTimes(3); + expect(http.post.mock.calls[1][2].headers.Authorization).toContain('nc=00000001'); + expect(http.post.mock.calls[2][2].headers.Authorization).toContain('nc=00000002'); + }); +}); diff --git a/test/economicImmutability.test.js b/test/economicImmutability.test.js new file mode 100644 index 0000000..5385edd --- /dev/null +++ b/test/economicImmutability.test.js @@ -0,0 +1,96 @@ +const fs = require('fs'); +const path = require('path'); + +describe('database economic identity immutability', () => { + const readMigration = name => fs.readFileSync( + path.join(__dirname, `../src/migrations/${name}`), 'utf8' + ); + + test('committed solo owner is frozen on and after commitment', () => { + const initial = readMigration('032_solo_liability_invariants.sql'); + const forward = readMigration('038_economic_identity_immutability.sql'); + for (const sql of [initial, forward]) { + expect(sql).toMatch(/NEW\.user_id IS DISTINCT FROM OLD\.user_id/i); + expect(sql).toMatch(/OLD\.payout_committed_at IS NULL[\s\S]*NEW\.payout_committed_at IS NOT NULL[\s\S]*NEW\.user_id IS DISTINCT FROM OLD\.user_id/i); + } + }); + + test('receipt-backed or final invoice economics, destination, product, and provider are frozen', () => { + const forward = readMigration('038_economic_identity_immutability.sql'); + for (const field of [ + 'user_id', 'payment_type', 'expected_amount', 'subaddress', 'address_index', + 'provider_id', 'provider_invoice_id', 'payment_mode', 'credit_package_id', + 'product_id', 'product_grants', 'description', 'expires_at' + ]) { + expect(forward).toMatch(new RegExp(`NEW\\.${field} IS DISTINCT FROM OLD\\.${field}`, 'i')); + } + expect(forward).toMatch(/OLD\.status IN \('confirmed', 'refunded', 'expired'\)/i); + expect(forward).toMatch(/EXISTS \(SELECT 1 FROM payment_receipts r WHERE r\.payment_id = OLD\.id\)/i); + expect(forward).toMatch(/OLD\.fairness_bound_at IS NULL AND NEW\.fairness_bound_at IS NOT NULL/i); + }); + + test('confirmed-payment refunds change operational status without rewriting immutable description', () => { + const refundService = fs.readFileSync( + path.join(__dirname, '../src/services/paymentRefundService.js'), 'utf8' + ); + const update = refundService.match(/UPDATE payments[\s\S]*?WHERE id = \$1/gi) || []; + expect(update).toHaveLength(1); + expect(update[0]).toMatch(/SET status = 'refunded'/i); + expect(update[0]).not.toMatch(/description/i); + }); + + test('payment receipt authorization evidence is append-only', () => { + const forward = readMigration('038_economic_identity_immutability.sql'); + expect(forward).toMatch(/CREATE TRIGGER trg_payment_receipts_append_only/i); + expect(forward).toMatch(/BEFORE UPDATE OR DELETE ON payment_receipts/i); + expect(forward).toMatch(/payment receipt evidence is append-only/i); + }); + + test('payout economics are immutable with only the exact saved-address claim exception', () => { + const sql = readMigration('038_economic_identity_immutability.sql'); + for (const field of ['user_id', 'game_id', 'match_id', 'amount', 'multiplier', 'reason']) { + expect(sql).toMatch(new RegExp(`NEW\\.${field} IS DISTINCT FROM OLD\\.${field}`, 'i')); + } + expect(sql).toMatch(/OLD\.payout_address = 'PENDING_NO_ADDRESS'/i); + expect(sql).toMatch(/OLD\.status = 'needs_review'/i); + expect(sql).toMatch(/NEW\.status = 'pending'/i); + expect(sql).toMatch(/OLD\.tx_hash IS NULL[\s\S]*NEW\.tx_hash IS NULL/i); + expect(sql).toMatch(/u\.id = OLD\.user_id[\s\S]*u\.payout_address = NEW\.payout_address/i); + expect(sql).toMatch(/CREATE TRIGGER trg_payouts_immutable_obligation/i); + }); + + test('unresolved solo-win obligations are recordable but excluded from automatic address claim', () => { + const initial = readMigration('038_economic_identity_immutability.sql'); + const forward = readMigration('039_unresolved_solo_payout_review.sql'); + const runtime = fs.readFileSync( + path.join(__dirname, '../src/game/gameModeManager.js'), 'utf8' + ); + + expect(runtime).toContain("'solo_winner_identity_review'"); + expect(forward).toContain("'solo_winner_identity_review'"); + expect(forward).toMatch(/status = 'needs_review'[\s\S]*tx_hash IS NULL/i); + expect(initial).toMatch( + /OLD\.reason IN \('match_winner_no_address', 'solo_winner_no_address'\)/i + ); + expect(initial).not.toMatch( + /OLD\.reason IN \([^)]*solo_winner_identity_review/i + ); + }); + + test('paid entropy precommit witness is shaped, immutable, and locks active fairness identity', () => { + const sql = readMigration('040_paid_match_entropy_precommit.sql'); + expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS entropy_precommit_tip_height BIGINT/i); + expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS entropy_precommit_verified_at TIMESTAMPTZ/i); + expect(sql).toMatch(/entropy_precommit_tip_height < start_block_height/i); + expect(sql).toMatch(/NEW\.entropy_precommit_tip_height IS DISTINCT FROM OLD\.entropy_precommit_tip_height/i); + expect(sql).toMatch(/NEW\.entropy_precommit_verified_at IS DISTINCT FROM OLD\.entropy_precommit_verified_at/i); + expect(sql).toMatch(/NOT \(OLD\.status = 'pending' AND NEW\.status = 'starting'\)/i); + for (const field of [ + 'economy', 'variant', 'ruleset_id', 'difficulty_preset', 'max_players', + 'seed_hash', 'dungeon', 'start_block_height' + ]) { + expect(sql).toMatch(new RegExp(`NEW\\.${field} IS DISTINCT FROM OLD\\.${field}`, 'i')); + } + expect(sql).toMatch(/CREATE TRIGGER trg_matches_immutable_entropy_precommit/i); + }); +}); diff --git a/test/environmentValidator.test.js b/test/environmentValidator.test.js new file mode 100644 index 0000000..1c45b3c --- /dev/null +++ b/test/environmentValidator.test.js @@ -0,0 +1,548 @@ +const EnvironmentValidator = require('../src/config/environmentValidator'); + +function config(overrides = {}) { + const base = { + paymentsEnabled: true, + currency: { symbol: 'XMR' }, + modes: { + direct: { enabled: true, price: 10000n }, + credits: { + enabled: true, + packages: [{ id: 'small', credits: 10, price: 90000000000n }] + } + }, + payouts: { + enabled: false, + rules: { + direct: { + enabled: false, + multipliers: { escape: 2, escapeWithTreasure: 3 }, + minPayout: 1n, + maxPayout: 50000n + }, + credits: { + enabled: false, + multipliers: { escape: 2, escapeWithTreasure: 3 }, + baseValue: 10000n, + minPayout: 1n, + maxPayout: 50000n + } + } + } + }; + return { + ...base, + ...overrides, + modes: { + ...base.modes, + ...(overrides.modes || {}) + }, + payouts: { + ...base.payouts, + ...(overrides.payouts || {}), + rules: { + direct: { + ...base.payouts.rules.direct, + ...(overrides.payouts?.rules?.direct || {}) + }, + credits: { + ...base.payouts.rules.credits, + ...(overrides.payouts?.rules?.credits || {}) + } + } + } + }; +} + +function productionEnv(overrides = {}) { + return { + NODE_ENV: 'production', + CRYPTO_TYPE: 'XMR', + MONERO_NETWORK: 'stagenet', + PAYMENTS_ENABLED: 'true', + PAYOUTS_ENABLED: 'false', + DIRECT_PAYOUTS_ENABLED: 'false', + CREDITS_PAYOUTS_ENABLED: 'false', + PRIMARY_WALLET_ENDPOINT: 'http://127.0.0.1:38083', + WALLET_RPC_USER: 'monerogue', + WALLET_RPC_PASSWORD: 'wallet-rpc-correct-horse-2026', + PRIMARY_RPC_ENDPOINT: 'http://127.0.0.1:38081', + DB_PASSWORD: 'correct-horse-battery-staple-2026', + ADMIN_API_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + OPERATOR_NAME: 'Test Operator LLC', + OPERATOR_CONTACT_URL: 'mailto:support@operator.invalid', + LEGAL_POLICY_VERSION: '2026-07-21-v1', + TERMS_EFFECTIVE_DATE: '2026-07-21', + MINIMUM_AGE: '18', + PAID_ACKNOWLEDGEMENT_REQUIRED: 'true', + BLOCK_SOURCE: 'daemon', + TRUST_PROXY: 'true', + MATCH_PAID_ENTROPY_DELAY_BLOCKS: '2', + MATCH_PAID_ENTROPY_CONFIRMATIONS: '2', + PAYOUT_MAX_PER_GAME: '50000', + BALANCE_CRITICAL: '50000', + BALANCE_WARN: '100000', + CREDITS_PACKAGES: '[{"id":"small","credits":10,"price":"90000000000"}]', + ...overrides + }; +} + +const silentLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() +}; + +describe('EnvironmentValidator production launch gate', () => { + beforeEach(() => jest.clearAllMocks()); + + test('accepts explicit paid-credits plus free-play style config with payouts disabled', () => { + const validator = new EnvironmentValidator({ env: productionEnv(), logger: silentLogger }); + const result = validator.assertValid(config()); + + expect(result.errors).toEqual([]); + expect(result.money).toEqual(expect.objectContaining({ + paymentsEnabled: true, + payoutsEnabled: false + })); + }); + + test('accepts explicitly capped stagenet crypto match payouts', () => { + const env = productionEnv({ + PAYOUTS_ENABLED: 'true', + MATCH_ENABLED: 'true', + MATCH_CRYPTO_RACE_ENABLED: 'true', + MATCH_PAYOUTS_ENABLED: 'true', + MATCH_PAYOUT_MAX: '50000', + MATCH_ENTRY_FEE_ATOMIC: '10000', + MATCH_HOUSE_FEE_PERCENT: '5', + MATCH_RULESET_ID: 'race', + MATCH_MAX_PLAYERS: '4' + }); + const result = new EnvironmentValidator({ env, logger: silentLogger }) + .assertValid(config({ + payouts: { enabled: true }, + products: { + cosmetic: [{ + id: 'race_2', + price: 20000n, + grants: { race_entries: 2, race_entry_value_atomic: '10000' } + }] + } + })); + + expect(result.money.matchPayoutsEnabled).toBe(true); + }); + + test.each([ + ['MATCH_PAID_ENTROPY_DELAY_BLOCKS', '1'], + ['MATCH_PAID_ENTROPY_CONFIRMATIONS', '1'], + ['MATCH_PAID_ENTROPY_DELAY_BLOCKS', '2.5'], + ['MATCH_PAID_ENTROPY_CONFIRMATIONS', '9007199254740992'], + ['MATCH_PAID_ENTROPY_DELAY_BLOCKS', '101'] + ])('rejects unsafe production paid entropy setting %s=%s', (key, value) => { + const result = new EnvironmentValidator({ + env: productionEnv({ MATCH_ENABLED: 'true', [key]: value }), + logger: silentLogger + }).validate(config()); + + expect(result.errors).toContain( + `${key} must be an explicit safe integer from 2 through 100 when production match mode is enabled.` + ); + }); + + test('rejects crypto co-op and a cap below the configured maximum pot liability', () => { + const env = productionEnv({ + PAYOUTS_ENABLED: 'true', + MATCH_ENABLED: 'true', + MATCH_CRYPTO_RACE_ENABLED: 'true', + MATCH_PAYOUTS_ENABLED: 'true', + MATCH_PAYOUT_MAX: '100', + MATCH_ENTRY_FEE_ATOMIC: '10000', + MATCH_HOUSE_FEE_PERCENT: '5', + MATCH_RULESET_ID: 'coop-escape', + MATCH_MAX_PLAYERS: '4' + }); + const result = new EnvironmentValidator({ env, logger: silentLogger }) + .validate(config({ payouts: { enabled: true } })); + + expect(result.errors).toEqual(expect.arrayContaining([ + expect.stringContaining('single-winner crypto payout semantics'), + expect.stringContaining('below the configured 4-player winner liability') + ])); + }); + + test('rejects mismatched or underfunded race-ticket products', () => { + const env = productionEnv({ + PAYOUTS_ENABLED: 'true', + MATCH_ENABLED: 'true', + MATCH_CRYPTO_RACE_ENABLED: 'true', + MATCH_PAYOUTS_ENABLED: 'true', + MATCH_PAYOUT_MAX: '50000', + MATCH_ENTRY_FEE_ATOMIC: '10000', + MATCH_HOUSE_FEE_PERCENT: '5', + MATCH_RULESET_ID: 'race', + MATCH_MAX_PLAYERS: '4' + }); + const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config({ + payouts: { enabled: true }, + products: { + cosmetic: [ + { + id: 'wrong-value', + price: 20000n, + grants: { race_entries: 2, race_entry_value_atomic: '9000' } + }, + { + id: 'underfunded', + price: 19999n, + grants: { race_entries: 2, race_entry_value_atomic: '10000' } + } + ] + } + })); + + expect(result.errors).toEqual(expect.arrayContaining([ + expect.stringContaining('wrong-value backing must equal MATCH_ENTRY_FEE_ATOMIC'), + expect.stringContaining('underfunded price must fund at least 20000') + ])); + }); + + test('rejects a 100% or over-precision match house fee', () => { + for (const value of ['100', '5.555']) { + const result = new EnvironmentValidator({ + env: productionEnv({ MATCH_HOUSE_FEE_PERCENT: value }), + logger: silentLogger + }).validate(config()); + expect(result.errors).toContain( + 'MATCH_HOUSE_FEE_PERCENT must be at least 0 and less than 100.' + ); + } + }); + + test('missing production secrets and RPC endpoints are fatal', () => { + const env = productionEnv({ + ADMIN_API_KEY: 'change-me', + DB_PASSWORD: '', + PRIMARY_WALLET_ENDPOINT: '', + PRIMARY_RPC_ENDPOINT: '' + }); + const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config()); + + expect(result.errors).toEqual(expect.arrayContaining([ + expect.stringContaining('PRIMARY_WALLET_ENDPOINT'), + expect.stringContaining('PRIMARY_RPC_ENDPOINT'), + expect.stringContaining('ADMIN_API_KEY'), + expect.stringContaining('DB_PASSWORD') + ])); + }); + + test('wallet RPC authentication requires a complete credential pair', () => { + const result = new EnvironmentValidator({ + env: productionEnv({ WALLET_RPC_USER: 'rpc-user', WALLET_RPC_PASSWORD: '' }), + logger: silentLogger + }).validate(config()); + + expect(result.errors).toContain('WALLET_RPC_USER and WALLET_RPC_PASSWORD must be set together.'); + }); + + test('production payments reject unauthenticated or remotely exposed wallet RPC', () => { + const missing = productionEnv({ WALLET_RPC_USER: '', WALLET_RPC_PASSWORD: '' }); + const missingResult = new EnvironmentValidator({ env: missing, logger: silentLogger }).validate(config()); + expect(missingResult.errors).toContain( + 'Production financial workers require WALLET_RPC_USER and WALLET_RPC_PASSWORD for wallet-rpc Digest authentication.' + ); + + const remote = productionEnv({ PRIMARY_WALLET_ENDPOINT: 'http://10.42.1.20:38083' }); + const remoteResult = new EnvironmentValidator({ env: remote, logger: silentLogger }).validate(config()); + expect(remoteResult.errors).toContain( + 'Production wallet-rpc over plaintext HTTP must bind to a loopback address.' + ); + }); + + test('payout-only recovery still requires an authenticated loopback wallet', () => { + const payoutOnlyConfig = config({ + paymentsEnabled: false, + modes: { + direct: { enabled: false }, + credits: { enabled: false, packages: [] } + }, + payouts: { enabled: true } + }); + const env = productionEnv({ + PAYMENTS_ENABLED: 'false', + PAYOUTS_ENABLED: 'true', + DIRECT_PAYMENT_ENABLED: 'false', + CREDITS_ENABLED: 'false', + MONERO_NETWORK: 'mainnet', + WALLET_RPC_USER: '', + WALLET_RPC_PASSWORD: '', + PRIMARY_WALLET_ENDPOINT: 'http://10.42.1.20:38083' + }); + + const errors = new EnvironmentValidator({ env, logger: silentLogger }) + .validate(payoutOnlyConfig).errors; + + expect(errors).toEqual(expect.arrayContaining([ + expect.stringContaining('Production financial workers require WALLET_RPC_USER'), + expect.stringContaining('plaintext HTTP must bind to a loopback'), + expect.stringContaining('ALLOW_MAINNET_PAYOUTS=true') + ])); + + env.PRIMARY_WALLET_ENDPOINT = ''; + expect(new EnvironmentValidator({ env, logger: silentLogger }) + .validate(payoutOnlyConfig).errors) + .toEqual(expect.arrayContaining([ + expect.stringContaining('Financial workers require a wallet'), + expect.stringContaining('PRIMARY_WALLET_ENDPOINT') + ])); + }); + + test('payout-only recovery cannot bypass the mainnet acknowledgement', () => { + const payoutOnlyConfig = config({ + paymentsEnabled: false, + modes: { + direct: { enabled: false }, + credits: { enabled: false, packages: [] } + }, + payouts: { enabled: true } + }); + const env = productionEnv({ + PAYMENTS_ENABLED: 'false', + PAYOUTS_ENABLED: 'true', + DIRECT_PAYMENT_ENABLED: 'false', + CREDITS_ENABLED: 'false', + MONERO_NETWORK: 'mainnet' + }); + + const validator = new EnvironmentValidator({ env, logger: silentLogger }); + expect(validator.validate(payoutOnlyConfig).errors).toContain( + 'Mainnet payouts require the explicit safety acknowledgement ALLOW_MAINNET_PAYOUTS=true.' + ); + env.ALLOW_MAINNET_PAYOUTS = 'true'; + expect(validator.validate(payoutOnlyConfig).errors).toEqual([]); + }); + + test('production requires explicit payout intent instead of dangerous defaults', () => { + const env = productionEnv(); + delete env.PAYOUTS_ENABLED; + delete env.DIRECT_PAYOUTS_ENABLED; + delete env.CREDITS_PAYOUTS_ENABLED; + + const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config()); + + expect(result.errors).toEqual(expect.arrayContaining([ + expect.stringContaining('explicit PAYOUTS_ENABLED'), + expect.stringContaining('explicit DIRECT_PAYOUTS_ENABLED'), + expect.stringContaining('explicit CREDITS_PAYOUTS_ENABLED') + ])); + }); + + test('production paid play requires truthful operator metadata and acknowledgement policy', () => { + const result = new EnvironmentValidator({ + env: productionEnv({ + OPERATOR_NAME: 'CHANGE_ME', + OPERATOR_CONTACT_URL: 'javascript:alert(1)', + LEGAL_POLICY_VERSION: '', + TERMS_EFFECTIVE_DATE: '2026-02-30', + MINIMUM_AGE: '0', + PAID_ACKNOWLEDGEMENT_REQUIRED: 'false' + }), + logger: silentLogger + }).validate(config()); + + expect(result.errors).toEqual(expect.arrayContaining([ + expect.stringContaining('PAID_ACKNOWLEDGEMENT_REQUIRED=true'), + expect.stringContaining('LEGAL_POLICY_VERSION'), + expect.stringContaining('TERMS_EFFECTIVE_DATE'), + expect.stringContaining('MINIMUM_AGE'), + expect.stringContaining('OPERATOR_NAME'), + expect.stringContaining('OPERATOR_CONTACT_URL') + ])); + }); + + test('rejects misspelled booleans and legacy modes instead of using defaults', () => { + const env = productionEnv({ + GAME_MODE: 'FRE', + PAYOUTS_ENABLED: 'flase', + DIRECT_PAYOUTS_ENABLED: 'treu' + }); + + const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config()); + + expect(result.errors).toEqual(expect.arrayContaining([ + expect.stringContaining('GAME_MODE must be'), + expect.stringContaining('PAYOUTS_ENABLED must be an explicit boolean'), + expect.stringContaining('DIRECT_PAYOUTS_ENABLED must be an explicit boolean') + ])); + }); + + test('rejects malformed atomic amounts and payout multipliers', () => { + const env = productionEnv({ + DIRECT_GAME_PRICE: '0.01 XMR', + PAYOUT_MAX_PER_GAME: '-1', + DIRECT_PAYOUT_ESCAPE: 'double' + }); + + const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config()); + + expect(result.errors).toEqual(expect.arrayContaining([ + expect.stringContaining('DIRECT_GAME_PRICE must be a non-negative integer'), + expect.stringContaining('PAYOUT_MAX_PER_GAME must be a non-negative integer'), + expect.stringContaining('DIRECT_PAYOUT_ESCAPE must be a finite number') + ])); + }); + + test('rejects unknown payment modes instead of silently disabling them', () => { + const result = new EnvironmentValidator({ + env: productionEnv({ PAYMENT_MODES: 'direct,magic' }), + logger: silentLogger + }).validate(config()); + + expect(result.errors).toContain('PAYMENT_MODES must contain only direct and/or credits.'); + }); + + test('rejects an invalid trusted-proxy hop count', () => { + const result = new EnvironmentValidator({ + env: productionEnv({ TRUST_PROXY_HOPS: 'all' }), + logger: silentLogger + }).validate(config()); + + expect(result.errors).toContain('TRUST_PROXY_HOPS must be an integer from 1 through 8.'); + }); + + test('mainnet payout dispatch needs a deliberate acknowledgement', () => { + const payoutConfig = config({ + payouts: { + enabled: true, + rules: { direct: { enabled: true }, credits: { enabled: true } } + } + }); + const env = productionEnv({ + MONERO_NETWORK: 'mainnet', + PAYOUTS_ENABLED: 'true', + DIRECT_PAYOUTS_ENABLED: 'true', + CREDITS_PAYOUTS_ENABLED: 'true' + }); + const validator = new EnvironmentValidator({ env, logger: silentLogger }); + + expect(validator.validate(payoutConfig).errors) + .toContain('Mainnet payouts require the explicit safety acknowledgement ALLOW_MAINNET_PAYOUTS=true.'); + + env.ALLOW_MAINNET_PAYOUTS = 'true'; + expect(validator.validate(payoutConfig).errors).toEqual([]); + }); + + test('stagenet 2x/3x payouts do not require the mainnet acknowledgement', () => { + const payoutConfig = config({ + payouts: { + enabled: true, + rules: { direct: { enabled: true }, credits: { enabled: true } } + } + }); + const env = productionEnv({ + PAYOUTS_ENABLED: 'true', + DIRECT_PAYOUTS_ENABLED: 'true', + CREDITS_PAYOUTS_ENABLED: 'true' + }); + + const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(payoutConfig); + expect(result.errors).toEqual([]); + expect(result.money.payoutsEnabled).toBe(true); + }); + + test('rejects direct 3x above cap and credits 2x below minimum', () => { + const env = productionEnv({ + PAYOUTS_ENABLED: 'true', + DIRECT_PAYOUTS_ENABLED: 'true', + CREDITS_PAYOUTS_ENABLED: 'true' + }); + const payoutConfig = config({ + modes: { direct: { enabled: true, price: 20000n } }, + payouts: { + enabled: true, + rules: { + direct: { enabled: true, maxPayout: 50000n }, + credits: { enabled: true, baseValue: 1000n, minPayout: 3000n } + } + } + }); + + const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(payoutConfig); + + expect(result.errors).toEqual(expect.arrayContaining([ + 'Direct escapeWithTreasure payout (60000 atomic units) exceeds its configured maximum (50000).', + 'Credits escape payout (2000 atomic units) is below its configured minimum (3000).' + ])); + }); + + test('payout launch gate requires a reserve that covers the largest accepted liability', () => { + const payoutConfig = config({ + payouts: { + enabled: true, + rules: { direct: { enabled: true }, credits: { enabled: false } } + } + }); + const missing = productionEnv({ + PAYOUTS_ENABLED: 'true', + DIRECT_PAYOUTS_ENABLED: 'true', + PAYOUT_MAX_PER_GAME: '60000', + BALANCE_CRITICAL: '', + BALANCE_WARN: '' + }); + expect(new EnvironmentValidator({ env: missing, logger: silentLogger }) + .validate(payoutConfig).errors).toEqual(expect.arrayContaining([ + expect.stringContaining('BALANCE_CRITICAL must be an explicit positive'), + expect.stringContaining('BALANCE_WARN must be an explicit positive') + ])); + + const underfunded = productionEnv({ + PAYOUTS_ENABLED: 'true', + DIRECT_PAYOUTS_ENABLED: 'true', + PAYOUT_MAX_PER_GAME: '60000', + BALANCE_CRITICAL: '59999', + BALANCE_WARN: '59998' + }); + expect(new EnvironmentValidator({ env: underfunded, logger: silentLogger }) + .validate(payoutConfig).errors).toEqual(expect.arrayContaining([ + expect.stringContaining('BALANCE_CRITICAL must be at least PAYOUT_MAX_PER_GAME (60000'), + expect.stringContaining('BALANCE_WARN must be greater than or equal to BALANCE_CRITICAL') + ])); + }); + + test.each([ + ['BLOCK_SOURCE', 'simulated'], + ['SIMULATED_BLOCKS', 'true'], + ['FORCE_SIMULATED_BLOCKS', 'true'] + ])('rejects production simulation via %s', (key, value) => { + const result = new EnvironmentValidator({ + env: productionEnv({ [key]: value }), + logger: silentLogger + }).validate(config()); + + expect(result.errors).toContain('Simulated blocks are forbidden when NODE_ENV=production.'); + }); + + test('assertValid throws a typed error for startup to fail closed', () => { + const validator = new EnvironmentValidator({ + env: productionEnv({ ADMIN_API_KEY: 'weak' }), + logger: silentLogger + }); + + expect(() => validator.assertValid(config())).toThrow(expect.objectContaining({ + name: 'EnvironmentValidationError', + code: 'INVALID_ENVIRONMENT' + })); + }); +}); + +describe('EnvironmentValidator development ergonomics', () => { + test('missing wallet/daemon endpoints warn without hard-stopping development', () => { + const env = { NODE_ENV: 'development', CRYPTO_TYPE: 'XMR', MONERO_NETWORK: 'stagenet' }; + const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config()); + + expect(result.errors).toEqual([]); + expect(result.warnings.some(message => message.includes('PRIMARY_WALLET_ENDPOINT'))).toBe(true); + expect(result.warnings.some(message => message.includes('PRIMARY_RPC_ENDPOINT'))).toBe(true); + }); +}); diff --git a/test/fairnessOffer.test.js b/test/fairnessOffer.test.js new file mode 100644 index 0000000..62b5bb1 --- /dev/null +++ b/test/fairnessOffer.test.js @@ -0,0 +1,251 @@ +const FairnessOfferManager = require('../src/game/fairnessOfferManager'); +const Game = require('../src/game/game'); +const DungeonGenerator = require('../src/game/dungeon'); +const { attachDungeonVerification } = require('../src/game/fairnessVerifier'); +const { + hashSeed, + deriveSeed, + createGameProof, + getPreGameCommitment, + getPostGameReveal, + verifyGameProof +} = require('../src/game/provablyFair'); + +const SERVER_SEED = 'a'.repeat(64); +const CLIENT_SEED = '12'.repeat(32); + +describe('two-phase solo fairness offers', () => { + test('publishes a commitment before client seed and consumes it exactly once', () => { + let now = 1000; + const offers = new FairnessOfferManager({ + now: () => now, + seedFactory: () => SERVER_SEED, + idFactory: () => 'offer-1', + ttlMs: 60000 + }); + + const published = offers.ensureOffer('socket-a'); + expect(published).toEqual(expect.objectContaining({ + offerId: 'offer-1', + commitment: hashSeed(SERVER_SEED), + issuedAt: 1000, + proofVersion: 2 + })); + + now = 2000; // client contribution arrives strictly after publication + const consumed = offers.consume('socket-a', { + offerId: published.offerId, + clientSeed: CLIENT_SEED.toUpperCase() + }); + expect(consumed.success).toBe(true); + expect(consumed.proofInput.offerIssuedAt).toBeLessThan(now); + expect(consumed.proofInput.clientSeed).toBe(CLIENT_SEED); + expect(consumed.proofInput.commitment).toBe(published.commitment); + + expect(offers.consume('socket-a', { + offerId: published.offerId, + clientSeed: CLIENT_SEED + })).toEqual(expect.objectContaining({ success: false, code: 'OFFER_REPLAYED' })); + }); + + test('cannot steal, swap, or attach a client seed without echoing the published offer', () => { + const offers = new FairnessOfferManager({ + seedFactory: () => SERVER_SEED, + idFactory: () => 'offer-owner' + }); + const published = offers.ensureOffer('owner'); + + expect(offers.consume('attacker', { + offerId: published.offerId, + clientSeed: CLIENT_SEED + }).code).toBe('OFFER_OWNER_MISMATCH'); + expect(offers.consume('owner', { clientSeed: CLIENT_SEED }).code).toBe('OFFER_REQUIRED'); + + const consumed = offers.consume('owner', { + offerId: published.offerId, + clientSeed: CLIENT_SEED + }); + expect(consumed.success).toBe(true); + + expect(() => createGameProof('game', CLIENT_SEED, { + ...consumed.proofInput, + commitment: 'b'.repeat(64) + })).toThrow(/commitment does not match/i); + }); + + test('legacy clients may consume the already-published current offer with an empty seed', () => { + const offers = new FairnessOfferManager({ + seedFactory: () => SERVER_SEED, + idFactory: () => 'legacy-offer' + }); + offers.ensureOffer('legacy'); + const consumed = offers.consume('legacy'); + expect(consumed.success).toBe(true); + expect(consumed.proofInput.clientSeed).toBe(''); + }); +}); + +describe('v2 proof binds the accepted client seed to the played dungeon', () => { + function precommit(clientSeed, offerId) { + return { + proofVersion: 2, + offerId, + offerIssuedAt: 123, + serverSeed: SERVER_SEED, + commitment: hashSeed(SERVER_SEED), + clientSeed + }; + } + + test('Game derives its RNG before layout generation and persists reproducible context', () => { + const game = new Game('socket', { id: 1 }, { fairnessProof: precommit(CLIENT_SEED, 'offer-a') }); + expect(game.gameProof.seed).toBe(deriveSeed(SERVER_SEED, CLIENT_SEED)); + expect(game.gameProof.clientSeed).toBe(CLIENT_SEED); + expect(game.gameProof.offerId).toBe('offer-a'); + + const context = game.gameProof.context; + const regenerated = DungeonGenerator.regenerateFromSeed( + game.gameProof.seed, + context.cryptoType, + context.generationOptions + ); + if (context.maxDepth > 1) regenerated.treasure = null; + expect(DungeonGenerator.layoutFingerprint(regenerated)).toBe(game.gameProof.layoutFingerprint); + }); + + test('changing the client seed changes both effective seed and dungeon', () => { + const one = new Game('one', { id: 1 }, { fairnessProof: precommit('01', 'offer-1') }); + const two = new Game('two', { id: 2 }, { fairnessProof: precommit('02', 'offer-2') }); + expect(one.gameProof.seed).not.toBe(two.gameProof.seed); + expect(one.gameProof.layoutFingerprint).not.toBe(two.gameProof.layoutFingerprint); + }); + + test('pre-game data hides server seed, reveal links by game id, and tampering fails', () => { + const proof = createGameProof('game-uuid', CLIENT_SEED, precommit(CLIENT_SEED, 'offer-z')); + proof.layoutFingerprint = 'f'.repeat(64); + const before = getPreGameCommitment(proof); + expect(before.clientSeed).toBe(CLIENT_SEED); + expect(before.offerId).toBe('offer-z'); + expect(before.serverSeed).toBeUndefined(); + expect(before.seed).toBeUndefined(); + + const reveal = getPostGameReveal(proof); + expect(reveal.verificationUrl).toBe('/verify/game-uuid'); + expect(verifyGameProof(reveal).valid).toBe(true); + expect(verifyGameProof({ ...reveal, clientSeed: '03' }).valid).toBe(false); + expect(verifyGameProof({ ...reveal, effectiveSeed: 'b'.repeat(64), seed: 'b'.repeat(64) }).valid).toBe(false); + expect(verifyGameProof({ ...reveal, serverSeed: 'b'.repeat(64) }).valid).toBe(false); + }); + + test('post-game reveal preserves the terminal result after endGame changes runtime state', () => { + const game = new Game('result-socket', { id: 3, endGame() {} }, { + fairnessProof: precommit(CLIENT_SEED, 'result-offer') + }); + game.endGame('won', { score: 10 }); + expect(game.gameState).toBe('ended'); + expect(game.getProofReveal().gameResult.won).toBe(true); + }); +}); + +describe('all-depth versioned verification', () => { + let savedLevels; + let savedCrypto; + + beforeEach(() => { + savedLevels = process.env.DUNGEON_LEVELS; + savedCrypto = process.env.CRYPTO_TYPE; + process.env.DUNGEON_LEVELS = '3'; + process.env.CRYPTO_TYPE = 'WOW'; + }); + + afterEach(() => { + if (savedLevels === undefined) delete process.env.DUNGEON_LEVELS; + else process.env.DUNGEON_LEVELS = savedLevels; + if (savedCrypto === undefined) delete process.env.CRYPTO_TYPE; + else process.env.CRYPTO_TYPE = savedCrypto; + }); + + test('persists and deterministically verifies every advertised depth', () => { + const fairnessProof = { + proofVersion: 2, + offerId: 'all-depth-offer', + offerIssuedAt: 123, + serverSeed: SERVER_SEED, + commitment: hashSeed(SERVER_SEED), + clientSeed: CLIENT_SEED + }; + const game = new Game('depth-socket', { id: 7 }, { fairnessProof }); + const reveal = game.getProofReveal(); + + expect(game.gameProof.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION); + expect(game.gameProof.layoutFingerprints).toHaveLength(3); + expect(game.gameProof.layoutFingerprints.map(item => item.depth)).toEqual([1, 2, 3]); + + const result = attachDungeonVerification(verifyGameProof(reveal), { + effectiveSeed: reveal.effectiveSeed, + proofContext: reveal.context, + expectedFingerprint: reveal.layoutFingerprint, + expectedFingerprints: reveal.layoutFingerprints, + generatorVersion: reveal.generatorVersion + }); + + expect(result.valid).toBe(true); + expect(result.verificationScope).toBe('all_depths'); + expect(result.verifiedDepths).toEqual([1, 2, 3]); + expect(result.levels.every(level => level.matches)).toBe(true); + }); + + test('fails if any depth is tampered with or omitted', () => { + const game = new Game('tamper-socket', { id: 8 }, { + fairnessProof: { + proofVersion: 2, + offerId: 'tamper-offer', + offerIssuedAt: 123, + serverSeed: SERVER_SEED, + commitment: hashSeed(SERVER_SEED), + clientSeed: CLIENT_SEED + } + }); + const reveal = game.getProofReveal(); + const tampered = reveal.layoutFingerprints.map(item => ({ ...item })); + tampered[1].fingerprint = '0'.repeat(64); + const mismatch = attachDungeonVerification(verifyGameProof(reveal), { + effectiveSeed: reveal.effectiveSeed, + proofContext: reveal.context, + expectedFingerprints: tampered, + generatorVersion: reveal.generatorVersion + }); + expect(mismatch.valid).toBe(false); + expect(mismatch.levels[1].matches).toBe(false); + + const incomplete = attachDungeonVerification(verifyGameProof(reveal), { + effectiveSeed: reveal.effectiveSeed, + proofContext: reveal.context, + expectedFingerprints: reveal.layoutFingerprints.slice(0, 2), + generatorVersion: reveal.generatorVersion + }); + expect(incomplete.valid).toBe(false); + expect(incomplete.regenerationError).toBe('incomplete_layout_manifest'); + }); +}); + +describe('browser contribution helper', () => { + test('generates a fresh 256-bit client seed after selecting the offer', () => { + const oldWindow = global.window; + const oldSocket = global.socket; + global.window = { + crypto: { getRandomValues: (bytes) => { for (let i = 0; i < bytes.length; i++) bytes[i] = i; return bytes; } } + }; + global.socket = { emit: jest.fn() }; + jest.resetModules(); + const BrowserHandlers = require('../html/js/network/socketHandlers.js'); + BrowserHandlers._fairnessOffer = { offerId: 'browser-offer', commitment: hashSeed(SERVER_SEED) }; + const payload = BrowserHandlers.fairnessAttempt({ free: true }); + expect(payload.free).toBe(true); + expect(payload.fairnessOfferId).toBe('browser-offer'); + expect(payload.clientSeed).toMatch(/^[0-9a-f]{64}$/); + expect(BrowserHandlers._pendingFairnessAttempt.commitment).toBe(hashSeed(SERVER_SEED)); + global.window = oldWindow; + global.socket = oldSocket; + }); +}); diff --git a/test/financialAuditContract.test.js b/test/financialAuditContract.test.js new file mode 100644 index 0000000..edbe76d --- /dev/null +++ b/test/financialAuditContract.test.js @@ -0,0 +1,22 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const deploy = name => fs.readFileSync(path.join(__dirname, '..', 'scripts', 'deploy', name), 'utf8'); + +describe('deployment financial-audit contract', () => { + const audit = deploy('financial-audit.sql'); + + test('retained consumed match anchors are not treated as live escrow', () => { + expect(audit).toMatch(/match_queue_entries\s+WHERE status IS NULL OR status NOT IN \('consumed', 'cancelled'\)/); + expect(audit).not.toMatch(/status NOT IN \('cancelled'\)/); + }); + + test('nullable or unknown money/game states and a stale schema ledger fail closed', () => { + expect(audit).toContain("payouts WHERE status IS DISTINCT FROM 'completed'"); + expect(audit).toContain("games WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired')"); + expect(audit).toContain("MAX(filename) = '040_paid_match_entropy_precommit.sql'"); + expect(audit).toContain('COUNT(*) = 40'); + }); +}); diff --git a/test/financialConstraintValidation.test.js b/test/financialConstraintValidation.test.js new file mode 100644 index 0000000..94957d5 --- /dev/null +++ b/test/financialConstraintValidation.test.js @@ -0,0 +1,80 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const MIGRATIONS = path.join(__dirname, '../src/migrations'); +const AUDIT = path.join(__dirname, '../scripts/deploy/financial-constraint-audit.sql'); +const VALIDATE = path.join(__dirname, '../scripts/deploy/financial-constraint-validate-restore.sql'); + +const CONSTRAINTS = Object.freeze([ + ['payments', 'payments_status_check'], + ['payments', 'payments_payment_type_check'], + ['payouts', 'payouts_status_check'], + ['games', 'games_payout_commitment_complete'], + ['games', 'games_layout_fingerprints_array_check'], + ['race_entry_lots', 'race_entry_lots_remaining_within_original'], + ['match_queue_entries', 'match_queue_entries_status_check'], + ['match_queue_entries', 'match_queue_entries_escrow_nonnegative'], + ['match_queue_entries', 'match_queue_committed_escrow_complete'], + ['matches', 'matches_payout_liability_amount_nonnegative'], + ['matches', 'matches_payout_liability_cap_positive'], + ['matches', 'matches_payout_liability_complete'], + ['matches', 'matches_payout_liability_economics_consistent'], + ['race_entry_lots', 'race_entry_lots_refund_shape'], + ['payment_refunds', 'payment_refunds_progress_nonnegative'], + ['games', 'games_entry_consumption_shape'], + ['payments', 'payments_fairness_binding_complete'], + ['payouts', 'payouts_no_address_review_shape'] +]); + +function migrationNotValidConstraintNames() { + const names = new Set(); + for (const fileName of fs.readdirSync(MIGRATIONS).filter(name => name.endsWith('.sql'))) { + const sql = fs.readFileSync(path.join(MIGRATIONS, fileName), 'utf8'); + for (const marker of sql.matchAll(/NOT\s+VALID\s*;/gi)) { + const prefix = sql.slice(0, marker.index); + const declarations = [...prefix.matchAll(/ADD\s+CONSTRAINT\s+([a-z0-9_]+)/gi)]; + if (declarations.length) names.add(declarations.at(-1)[1].toLowerCase()); + } + } + return [...names].sort(); +} + +describe('historical financial constraint release gates', () => { + const expectedNames = CONSTRAINTS.map(([, name]) => name).sort(); + + test('inventory stays synchronized with every migration NOT VALID constraint', () => { + expect(migrationNotValidConstraintNames()).toEqual(expectedNames); + expect(CONSTRAINTS).toHaveLength(18); + }); + + test('read-only audit names every constraint and checks historical predicates', () => { + const sql = fs.readFileSync(AUDIT, 'utf8'); + expect(sql).toMatch(/BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;/i); + expect(sql).toContain("current_database() = :'expected_database'"); + expect(sql).toContain('historical_constraints_clean'); + expect(sql).toContain('IS FALSE'); + expect(sql).not.toMatch(/\bALTER\s+TABLE\b/i); + expect(sql).not.toMatch(/\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM|TRUNCATE)\b/i); + for (const [, name] of CONSTRAINTS) { + expect(sql).toContain(`'${name}'`); + } + }); + + test('disposable-restore gate explicitly validates every constraint then rolls back', () => { + const sql = fs.readFileSync(VALIDATE, 'utf8'); + expect(sql).toContain("current_database() ~* '(restore|scratch)'"); + expect(sql).toContain('I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE'); + expect(sql).toContain('\\ir financial-constraint-audit.sql'); + expect(sql).toContain('SET LOCAL lock_timeout'); + expect(sql).toMatch(/ROLLBACK;[\s\S]*PASS: PostgreSQL validated all 18 constraints/i); + for (const [table, name] of CONSTRAINTS) { + expect(sql).toContain( + `ALTER TABLE public.${table} VALIDATE CONSTRAINT ${name};` + ); + } + expect((sql.match(/\bVALIDATE CONSTRAINT\s+[a-z0-9_]+;/gi) || [])) + .toHaveLength(CONSTRAINTS.length); + }); +}); diff --git a/test/followups_ultra.test.js b/test/followups_ultra.test.js index d647268..b5287cb 100644 --- a/test/followups_ultra.test.js +++ b/test/followups_ultra.test.js @@ -50,8 +50,8 @@ describe('Follow-up 1 — no-address match winner reconciliation', () => { expect(ok).toBe(true); const payoutUpdate = db.query.mock.calls.find(c => /UPDATE\s+payouts/i.test(c[0])); expect(payoutUpdate).toBeTruthy(); - // Narrow, safe filter — only the no-address match liability, never generic needs_review. - expect(payoutUpdate[0]).toMatch(/reason\s*=\s*'match_winner_no_address'/); + // Narrow, safe filter — only explicit no-address liabilities, never generic needs_review. + expect(payoutUpdate[0]).toMatch(/reason\s+IN\s*\(\s*'match_winner_no_address'\s*,\s*'solo_winner_no_address'\s*\)/); expect(payoutUpdate[0]).toMatch(/status\s*=\s*'needs_review'/); expect(payoutUpdate[0]).toMatch(/PENDING_NO_ADDRESS/); expect(payoutUpdate[0]).toMatch(/SET payout_address = \$1, status = 'pending'/); diff --git a/test/gameManagerWinFeed.test.js b/test/gameManagerWinFeed.test.js new file mode 100644 index 0000000..adde38b --- /dev/null +++ b/test/gameManagerWinFeed.test.js @@ -0,0 +1,59 @@ +const GameManager = require('../src/game/gameManager'); + +function makeHarness(completion) { + const targetedEmit = jest.fn(); + const io = { + to: jest.fn(() => ({ emit: targetedEmit })), + emit: jest.fn() + }; + const game = { + id: 'game-seed', + player: { hasTreasure: false }, + moveCount: 12, + startedAt: Date.now() - 1000, + endGame: jest.fn(), + getProofReveal: jest.fn(() => ({ commitment: 'proof' })) + }; + const gameModeManager = { + completeGame: jest.fn().mockResolvedValue(completion), + db: { query: jest.fn().mockResolvedValue({ rows: [{ name: 'Alice' }] }) } + }; + const manager = new GameManager({ + activeGames: new Map([['socket-1', game]]), + io, + broadcastManager: {}, + debugManager: { CONSOLE_LOGGING: false }, + gameModeManager + }); + return { manager, game, io }; +} + +async function finishWin(completion) { + const harness = makeHarness(completion); + await harness.manager.handleGameOver( + { id: 'socket-1' }, + harness.game, + 'won', + 'escaped', + 'Escaped', + 100 + ); + return harness.io.emit.mock.calls.find(([event]) => event === 'win_feed')?.[1]; +} + +describe('solo win feed payout status', () => { + test('marks a win paid when completion contains a nested payout amount', async () => { + const feed = await finishWin({ + success: true, + payout: { status: 'queued', amount: '200000000000', multiplier: 2 } + }); + + expect(feed).toEqual(expect.objectContaining({ name: 'Alice', score: 100, paid: true })); + }); + + test('does not trust an obsolete top-level amount when no payout was created', async () => { + const feed = await finishWin({ success: true, amount: '200000000000', payout: null }); + + expect(feed).toEqual(expect.objectContaining({ paid: false })); + }); +}); diff --git a/test/gameModeManager.test.js b/test/gameModeManager.test.js index 930472c..86002e9 100644 --- a/test/gameModeManager.test.js +++ b/test/gameModeManager.test.js @@ -110,7 +110,205 @@ describe('GameModeManager', () => { }); }); + describe('production payout and free-play policy', () => { + test('financial recovery gate blocks invoice intake and every paid start but preserves explicit free play', async () => { + gmm.paymentsEnabled = true; + gmm.directModeEnabled = true; + gmm.creditsModeEnabled = true; + gmm.financialAdmissionAllowed = () => false; + + expect(gmm.isPaymentIntakeEnabled('single_game')).toBe(false); + expect(gmm.isPaymentIntakeEnabled('credits_package')).toBe(false); + expect(gmm.isPaymentIntakeEnabled('cosmetic_pack')).toBe(false); + + const user = { id: 7, credits: 5, payout_address: 'wallet' }; + jest.spyOn(gmm, 'getOrCreateUser').mockResolvedValue(user); + const paid = await gmm.processGameStart('socket-paid', 'game-paid'); + expect(paid).toMatchObject({ + success: false, + code: 'FINANCIAL_RECOVERY_PENDING' + }); + expect(mockDb.query).not.toHaveBeenCalled(); + expect(mockDb.withTransaction).not.toHaveBeenCalled(); + + expect(await gmm._processGameStartWithCredits(user, 'socket-paid', 'game-credits')) + .toMatchObject({ success: false, code: 'FINANCIAL_RECOVERY_PENDING' }); + expect(await gmm._processGameStartWithPayment(user, { id: 9 }, 'game-direct')) + .toMatchObject({ success: false, code: 'FINANCIAL_RECOVERY_PENDING' }); + expect(mockDb.withTransaction).not.toHaveBeenCalled(); + + jest.spyOn(gmm, '_processGameStartFree').mockResolvedValue({ + success: true, + effectiveMode: 'FREE' + }); + await expect(gmm.processGameStart('socket-free', 'game-free', { forceFree: true })) + .resolves.toEqual({ success: true, effectiveMode: 'FREE' }); + }); + + test('master payout switch overrides enabled per-mode rules and stops dispatch', async () => { + gmm.payoutsEnabled = false; + gmm.directPayoutEnabled = true; + gmm.creditsPayoutEnabled = true; + gmm.directRequiresAddress = true; + + expect(gmm.isPayoutEnabledForMode('PAID_SINGLE')).toBe(false); + expect(gmm.isPayoutEnabledForMode('PAID_CREDITS')).toBe(false); + expect(gmm.requiresPayoutAddressForMode('PAID_SINGLE')).toBe(false); + + await gmm._processPendingPayouts(); + expect(mockDb.withTransaction).not.toHaveBeenCalled(); + expect(mockWalletService.processPayout).not.toHaveBeenCalled(); + }); + + test('shutdown cancels a queued payout debounce and rejects later scheduling', async () => { + jest.useFakeTimers(); + try { + const processPending = jest.spyOn(gmm, '_processPendingPayouts').mockResolvedValue(); + + gmm._scheduleBatchPayout(); + expect(jest.getTimerCount()).toBe(1); + + gmm.shutdown(); + expect(gmm._batchPayoutTimer).toBeNull(); + expect(jest.getTimerCount()).toBe(0); + + gmm._scheduleBatchPayout(); + jest.advanceTimersByTime(5000); + await Promise.resolve(); + expect(processPending).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.useRealTimers(); + } + }); + + test('unified paid config does not accidentally inherit free mode when FREE_PLAY_ENABLED is false', () => { + const oldGameMode = process.env.GAME_MODE; + const oldFree = process.env.FREE_PLAY_ENABLED; + try { + delete process.env.GAME_MODE; + process.env.FREE_PLAY_ENABLED = 'false'; + const manager = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfigManager); + expect(manager.paymentsEnabled).toBe(true); + expect(manager.freePlayEnabled).toBe(false); + } finally { + if (oldGameMode === undefined) delete process.env.GAME_MODE; else process.env.GAME_MODE = oldGameMode; + if (oldFree === undefined) delete process.env.FREE_PLAY_ENABLED; else process.env.FREE_PLAY_ENABLED = oldFree; + } + }); + + test('game mode info advertises the selected safe ruleset catalog', () => { + const old = process.env.MATCH_RULESET_ID; + try { + process.env.MATCH_RULESET_ID = 'last-alive'; + const match = gmm.getGameModeInfo().modes.match; + expect(match.activeRuleset).toEqual(expect.objectContaining({ + id: 'last-alive', + label: 'Last Alive', + winCondition: 'last-alive' + })); + expect(match.rulesets.map(r => r.id)).toEqual(expect.arrayContaining(['race', 'last-alive', 'score-attack', 'coop-escape'])); + expect(match.rulesets.map(r => r.id)).not.toContain('solo-classic'); + } finally { + if (old === undefined) delete process.env.MATCH_RULESET_ID; else process.env.MATCH_RULESET_ID = old; + } + }); + + test('crypto race economy is hidden when the payout master switch is off', () => { + const keys = [ + 'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', + 'MATCH_PAYOUT_MAX', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_HOUSE_FEE_PERCENT', + 'MATCH_MAX_PLAYERS' + ]; + const old = Object.fromEntries(keys.map(key => [key, process.env[key]])); + try { + process.env.MATCH_ENABLED = 'true'; + process.env.MATCH_CRYPTO_RACE_ENABLED = 'true'; + process.env.MATCH_PAYOUTS_ENABLED = 'true'; + process.env.MATCH_PAYOUT_MAX = '1000000000000'; + process.env.MATCH_ENTRY_FEE_ATOMIC = '10000'; + process.env.MATCH_HOUSE_FEE_PERCENT = '5'; + process.env.MATCH_MAX_PLAYERS = '4'; + gmm.payoutsEnabled = false; + expect(gmm._getMatchEconomies().crypto_race).toBeUndefined(); + gmm.payoutsEnabled = true; + expect(gmm._getMatchEconomies().crypto_race).toBe(true); + } finally { + for (const key of keys) { + if (old[key] === undefined) delete process.env[key]; else process.env[key] = old[key]; + } + } + }); + }); + describe('calculatePayout', () => { + test('publishes only the double/triple outcomes implemented by payout calculation', () => { + gmm.directPayoutMultipliers = { + escape: 2, + escapeWithTreasure: 3, + perfectRun: 5, + experimentalJackpot: 20 + }; + gmm.creditPayoutMultipliers = { + escape: 2, + escapeWithTreasure: 3, + perfectRun: 3 + }; + + expect(gmm.getImplementedPayoutMultipliersForMode('PAID_SINGLE')).toStrictEqual({ + escape: 2, + escapeWithTreasure: 3 + }); + expect(gmm.getImplementedPayoutMultipliersForMode('PAID_CREDITS')).toStrictEqual({ + escape: 2, + escapeWithTreasure: 3 + }); + + const publicInfo = gmm.getGameModeInfo(); + expect(publicInfo.payoutMultipliers).toStrictEqual({ + direct: { escape: 2, escapeWithTreasure: 3 }, + credits: { escape: 2, escapeWithTreasure: 3 } + }); + }); + + test('publishes exact effective outcomes after cap/min policy', () => { + gmm.payoutsEnabled = true; + gmm.directPayoutEnabled = true; + gmm.creditsPayoutEnabled = true; + gmm.singleGamePrice = 20000; + gmm.creditsPayoutBaseValue = 1000; + gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; + gmm.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; + gmm.configSnapshot.payouts.rules.direct.minPayout = 1n; + gmm.configSnapshot.payouts.rules.direct.maxPayout = 50000n; + gmm.configSnapshot.payouts.rules.credits.minPayout = 3000n; + gmm.configSnapshot.payouts.rules.credits.maxPayout = 50000n; + + const outcomes = gmm.getGameModeInfo().payoutOutcomes; + expect(outcomes.direct.escape).toEqual(expect.objectContaining({ + payable: true, + amountAtomic: '40000', + rawAmountAtomic: '40000', + capApplied: false + })); + expect(outcomes.direct.escapeWithTreasure).toEqual(expect.objectContaining({ + payable: true, + amountAtomic: '50000', + rawAmountAtomic: '60000', + capApplied: true + })); + expect(outcomes.credits.escape).toEqual(expect.objectContaining({ + payable: false, + amountAtomic: '0', + rawAmountAtomic: '2000', + suppressedReason: 'below_minimum' + })); + + const snapshot = gmm._computePayoutSnapshot('PAID_SINGLE'); + expect(snapshot.treasureAmount).toBe('50000'); + expect(snapshot.terms.version).toBe(2); + }); + test('calculates direct mode payout correctly', () => { gmm.singleGamePrice = 100000000000; // 1 WOW gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; @@ -189,10 +387,14 @@ describe('GameModeManager', () => { }); // Step 2: withTransaction — client.query calls - mockClient.query - .mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed' - .mockResolvedValueOnce({ rows: [{ credits: 15 }] }) // UPDATE users SET credits = credits + 15 - .mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions + mockClient.query.mockImplementation(async (sql) => { + if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 }; + if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 }; + if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'free' }], rowCount: 1 }; + if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 }; + if (/SELECT pack_id/i.test(sql)) return { rows: [] }; + return { rows: [], rowCount: 1 }; + }); const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { credits: 10, bonus: 5 }); expect(result.success).toBe(true); @@ -204,12 +406,15 @@ describe('GameModeManager', () => { rows: [{ user_id: 1, description: '', status: 'pending', payment_type: 'credits_package' }] }); - mockClient.query - .mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments - .mockResolvedValueOnce({ rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'supporter' }] }) // UPDATE users - .mockResolvedValueOnce({ rows: [] }) // INSERT credit_transactions - .mockResolvedValueOnce({ rows: [] }) // INSERT pack entitlement - .mockResolvedValueOnce({ rows: [{ pack_id: 'kenney-3d-characters' }] }); // SELECT active grants + mockClient.query.mockImplementation(async (sql) => { + if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 }; + if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 }; + if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'supporter' }], rowCount: 1 }; + if (/INSERT INTO user_pack_entitlements/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }], rowCount: 1 }; + if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 }; + if (/SELECT pack_id/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }] }; + return { rows: [], rowCount: 1 }; + }); const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { id: 'bundle', @@ -243,11 +448,15 @@ describe('GameModeManager', () => { }] }); - mockClient.query - .mockResolvedValueOnce({ rows: [{ id: 456 }] }) // UPDATE payments - .mockResolvedValueOnce({ rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }] }) // UPDATE users - .mockResolvedValueOnce({ rows: [] }) // INSERT pack entitlement - .mockResolvedValueOnce({ rows: [{ pack_id: 'kenney-3d-characters' }] }); // SELECT active grants + mockClient.query.mockImplementation(async (sql) => { + if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 456 }], rowCount: 1 }; + if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 }; + if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }], rowCount: 1 }; + if (/INSERT INTO user_pack_entitlements/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }], rowCount: 1 }; + if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 456 }], rowCount: 1 }; + if (/SELECT pack_id/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }] }; + return { rows: [], rowCount: 1 }; + }); const result = await gmm.processProductPaymentConfirmation('socket1', 456, null, 1000); @@ -257,6 +466,105 @@ describe('GameModeManager', () => { expect(mockClient.query.mock.calls.some(([sql]) => /INSERT INTO credit_transactions/i.test(sql))).toBe(false); }); + test('creates an exact funded lot from the durable confirmed-payment grant snapshot', async () => { + const keys = ['MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', 'MATCH_ENTRY_FEE_ATOMIC']; + const previous = Object.fromEntries(keys.map(key => [key, process.env[key]])); + try { + process.env.MATCH_CRYPTO_RACE_ENABLED = 'true'; + process.env.MATCH_PAYOUTS_ENABLED = 'true'; + process.env.MATCH_ENTRY_FEE_ATOMIC = '10000'; + mockDb.query.mockResolvedValueOnce({ + rows: [{ + user_id: 1, + description: 'Two race entries', + status: 'pending', + payment_type: 'cosmetic_pack', + product_id: 'race_2', + product_grants: { + credits: 0, + raceEntries: 2, + raceEntryValueAtomic: '10000', + packs: [], + premiumLevel: null + }, + received_amount: '20000', + expected_amount: '20000' + }] + }); + mockClient.query.mockImplementation(async (sql) => { + if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 700 }], rowCount: 1 }; + if (/SELECT id, premium_level/i.test(sql)) { + return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 }; + } + if (/SET credits = credits/i.test(sql)) { + return { + rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }], + rowCount: 1 + }; + } + if (/SET race_entries = race_entries \+/i.test(sql)) { + return { rows: [{ race_entries: 2 }], rowCount: 1 }; + } + if (/INSERT INTO race_entry_lots/i.test(sql)) { + return { rows: [{ id: 88 }], rowCount: 1 }; + } + if (/SELECT pack_id/i.test(sql)) return { rows: [], rowCount: 0 }; + return { rows: [], rowCount: 1 }; + }); + + const result = await gmm.processProductPaymentConfirmation('new-socket', 700, { + id: 'changed-live-catalog', + grants: { race_entries: 99, race_entry_value_atomic: '1' } + }, '20000'); + + expect(result.success).toBe(true); + const lot = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO race_entry_lots/i.test(sql)); + expect(lot[1]).toEqual([1, 700, '10000', 2, 'race_2']); + const ticketLedger = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO race_entry_transactions/i.test(sql)); + expect(ticketLedger[1][3]).toBe(700); + } finally { + for (const key of keys) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + } + }); + + test('does not confirm an underfunded race-ticket invoice', async () => { + const keys = ['MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', 'MATCH_ENTRY_FEE_ATOMIC']; + const previous = Object.fromEntries(keys.map(key => [key, process.env[key]])); + try { + process.env.MATCH_CRYPTO_RACE_ENABLED = 'true'; + process.env.MATCH_PAYOUTS_ENABLED = 'true'; + process.env.MATCH_ENTRY_FEE_ATOMIC = '10000'; + mockDb.query.mockResolvedValueOnce({ + rows: [{ + user_id: 1, + status: 'pending', + payment_type: 'cosmetic_pack', + product_id: 'race_2', + product_grants: { + credits: 0, + raceEntries: 2, + raceEntryValueAtomic: '10000' + }, + received_amount: '19999', + expected_amount: '20000' + }] + }); + + const result = await gmm.processProductPaymentConfirmation('new-socket', 701, null, '19999'); + + expect(result.success).toBe(false); + expect(mockDb.withTransaction).not.toHaveBeenCalled(); + } finally { + for (const key of keys) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + } + }); + test('falls back to default credits when package info missing and no description', async () => { // Step 1: db.query — SELECT payment lookup with no credits in description mockDb.query.mockResolvedValueOnce({ @@ -264,10 +572,14 @@ describe('GameModeManager', () => { }); // Step 2: withTransaction — client.query calls - mockClient.query - .mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed' - .mockResolvedValueOnce({ rows: [{ credits: 10 }] }) // UPDATE users - .mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions + mockClient.query.mockImplementation(async (sql) => { + if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 }; + if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 }; + if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 10, total_credits_purchased: 10, premium_level: 'free' }], rowCount: 1 }; + if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 }; + if (/SELECT pack_id/i.test(sql)) return { rows: [] }; + return { rows: [], rowCount: 1 }; + }); const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null); expect(result.success).toBe(true); @@ -309,6 +621,24 @@ describe('GameModeManager', () => { expect(result.effectiveMode).toBe('PAID_SINGLE'); }); + test('when direct is preferred, a confirmed direct entry is not replaced by an existing credit', async () => { + gmm.paymentsEnabled = true; + gmm.directModeEnabled = true; + gmm.creditsModeEnabled = true; + gmm.preferCreditsFirst = false; + + mockDb.query + .mockResolvedValueOnce({ rows: [{ id: 1, credits: 5 }] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ id: 999 }] }); + + const result = await gmm.canUserStartGame('socket1'); + + expect(result.allowed).toBe(true); + expect(result.paymentId).toBe(999); + expect(result.effectiveMode).toBe('PAID_SINGLE'); + }); + test('denies game when no credits and no payment in mixed mode', async () => { gmm.paymentsEnabled = true; gmm.directModeEnabled = true; diff --git a/test/gamePersistence.test.js b/test/gamePersistence.test.js new file mode 100644 index 0000000..eec9cc7 --- /dev/null +++ b/test/gamePersistence.test.js @@ -0,0 +1,51 @@ +const Game = require('../src/game/game'); +const GameManager = require('../src/game/gameManager'); + +describe('paid game persistence gate', () => { + afterEach(() => jest.restoreAllMocks()); + + test('a failed durable INSERT aborts before the game becomes active', async () => { + const game = { id: 'durable-seed', gameProof: {} }; + jest.spyOn(Game, 'createStandardGame').mockReturnValue(game); + const user = { id: 'socket-1', joinGame: jest.fn() }; + const activeGames = new Map(); + const dbError = new Error('database unavailable'); + const manager = new GameManager({ + activeGames, + io: {}, + broadcastManager: {}, + debugManager: { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 100 }, + gameModeManager: { + paymentsEnabled: true, + gameMode: 'PAID_SINGLE', + db: { query: jest.fn().mockRejectedValue(dbError) } + } + }); + + await expect(manager.createGameForUser(user)).rejects.toThrow('database unavailable'); + + expect(user.joinGame).not.toHaveBeenCalled(); + expect(activeGames.has(user.id)).toBe(false); + expect(game.dbId).toBeNull(); + }); + + test('a paid game also rejects an INSERT that returns no durable id', async () => { + const game = { id: 'missing-id', gameProof: {} }; + jest.spyOn(Game, 'createStandardGame').mockReturnValue(game); + const user = { id: 'socket-2', joinGame: jest.fn() }; + const manager = new GameManager({ + activeGames: new Map(), + io: {}, + broadcastManager: {}, + debugManager: { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 100 }, + gameModeManager: { + paymentsEnabled: true, + gameMode: 'PAID_SINGLE', + db: { query: jest.fn().mockResolvedValue({ rows: [] }) } + } + }); + + await expect(manager.createGameForUser(user)).rejects.toThrow('Durable game record is required'); + expect(user.joinGame).not.toHaveBeenCalled(); + }); +}); diff --git a/test/healthService.test.js b/test/healthService.test.js new file mode 100644 index 0000000..301ad7d --- /dev/null +++ b/test/healthService.test.js @@ -0,0 +1,160 @@ +const { buildPublicHealth } = require('../src/services/healthService'); + +describe('public health payload', () => { + test('is ready only when required production dependencies are up', () => { + const health = buildPublicHealth({ + databaseReady: true, + blockHeight: 123, + paymentsEnabled: true, + walletHealthy: true, + payoutsEnabled: false, + now: 1, + uptime: 10 + }); + + expect(health).toMatchObject({ + status: 'ok', + ready: true, + checks: { database: 'up', chain: 'up', wallet: 'up' }, + money: { paymentsEnabled: true, payoutsEnabled: false } + }); + }); + + test('free-only instances do not require a wallet', () => { + const health = buildPublicHealth({ + databaseReady: true, + blockHeight: 123, + paymentsEnabled: false, + walletHealthy: false + }); + + expect(health.ready).toBe(true); + expect(health.checks.wallet).toBe('not_required'); + }); + + test('payout-only settlement requires a wallet without claiming intake is enabled', () => { + const health = buildPublicHealth({ + databaseReady: true, + blockHeight: 123, + paymentsEnabled: false, + payoutsEnabled: true, + walletHealthy: false + }); + + expect(health.ready).toBe(false); + expect(health.checks.wallet).toBe('down'); + expect(health.money).toEqual({ paymentsEnabled: false, payoutsEnabled: true }); + expect(health.paymentsEnabled).toBe(false); + }); + + test('reports degraded readiness when the daemon or required wallet is down', () => { + const health = buildPublicHealth({ + databaseReady: true, + blockHeight: 0, + paymentsEnabled: true, + walletHealthy: false + }); + + expect(health.status).toBe('degraded'); + expect(health.ready).toBe(false); + expect(health.checks).toMatchObject({ chain: 'down', wallet: 'down' }); + }); + + test('keeps readiness closed while startup financial recovery is pending', () => { + const health = buildPublicHealth({ + databaseReady: true, + blockHeight: 123, + paymentsEnabled: true, + walletHealthy: true, + financialRecoveryReady: false + }); + + expect(health.ready).toBe(false); + expect(health.checks.financialRecovery).toBe('pending'); + }); + + test('does not treat a stale cached block height as a healthy daemon', () => { + const health = buildPublicHealth({ + databaseReady: true, + blockHeight: 123, + chainHealthy: false, + paymentsEnabled: false + }); + + expect(health.ready).toBe(false); + expect(health.checks.chain).toBe('down'); + }); + + test('never includes sensitive operational detail', () => { + const health = buildPublicHealth({ + databaseReady: true, + blockHeight: 1, + paymentsEnabled: true, + walletHealthy: true + }); + + expect(JSON.stringify(health)).not.toMatch(/balance|endpoint|password|memory|rateLimiter/i); + }); + + test('requires verified daemon and wallet identities for production money mode', () => { + const identity = { + verified: true, + expected: { cryptoType: 'XMR', network: 'stagenet', genesisHash: 'secret-internal' }, + actual: { cryptoType: 'XMR', network: 'stagenet', genesisHash: 'secret-internal' }, + reason: 'internal-reason', + endpoint: 'http://user:password@wallet.test' + }; + const health = buildPublicHealth({ + databaseReady: true, + blockHeight: 123, + chainHealthy: true, + paymentsEnabled: true, + walletHealthy: true, + identityRequired: true, + daemonIdentity: identity, + walletIdentity: identity + }); + + expect(health).toMatchObject({ + ready: true, + checks: { daemonIdentity: 'verified', walletIdentity: 'verified' }, + chain: { network: 'stagenet' }, + identities: { + daemon: { required: true, verified: true }, + wallet: { required: true, verified: true } + } + }); + expect(JSON.stringify(health)).not.toMatch(/secret-internal|internal-reason|endpoint|password/); + }); + + test('fails readiness on a daemon identity mismatch', () => { + const health = buildPublicHealth({ + databaseReady: true, + blockHeight: 123, + chainHealthy: true, + paymentsEnabled: false, + identityRequired: true, + daemonIdentity: { verified: false } + }); + + expect(health.ready).toBe(false); + expect(health.checks).toMatchObject({ chain: 'down', daemonIdentity: 'unverified' }); + }); + + test('fails payout-only readiness on a wallet identity mismatch', () => { + const health = buildPublicHealth({ + databaseReady: true, + blockHeight: 123, + chainHealthy: true, + paymentsEnabled: false, + payoutsEnabled: true, + walletHealthy: true, + identityRequired: true, + daemonIdentity: { verified: true }, + walletIdentity: { verified: false } + }); + + expect(health.ready).toBe(false); + expect(health.checks).toMatchObject({ wallet: 'down', walletIdentity: 'unverified' }); + }); +}); diff --git a/test/inputValidation.test.js b/test/inputValidation.test.js index 2f9bbd3..cb1d19c 100644 --- a/test/inputValidation.test.js +++ b/test/inputValidation.test.js @@ -53,3 +53,18 @@ describe('handleRegisterClient validation', () => { expect(reg.data.success).toBe(false); }); }); + +describe('session resume credential transport', () => { + let ch; + afterEach(() => ch && ch.shutdown && ch.shutdown()); + + test('accepts auth payload token and ignores URL query token', () => { + ch = buildHandler(); + expect(ch._getResumeToken({ + handshake: { auth: { resumeToken: 'auth-secret' }, query: { resumeToken: 'url-secret' } } + })).toBe('auth-secret'); + expect(ch._getResumeToken({ + handshake: { auth: {}, query: { resumeToken: 'url-secret' } } + })).toBeNull(); + }); +}); diff --git a/test/latePaymentReconciliation.test.js b/test/latePaymentReconciliation.test.js new file mode 100644 index 0000000..66877b0 --- /dev/null +++ b/test/latePaymentReconciliation.test.js @@ -0,0 +1,94 @@ +const GameModeManager = require('../src/game/gameModeManager'); +const LatePaymentReconciler = require('../src/services/latePaymentReconciler'); + +describe('late incoming payment reconciliation', () => { + test('bounded scanner sends an expired native receipt only to manual review', async () => { + const row = { + id: 7, + user_id: 3, + status: 'expired', + subaddress: 'late-address', + address_index: 11, + expected_amount: '100', + provider_id: 'native-monero', + provider_invoice_id: 'late-address' + }; + const db = { query: jest.fn().mockResolvedValue({ rows: [row] }) }; + const receipt = { + evidenceType: 'chain_output', providerId: 'native-monero', + txHash: 'a'.repeat(64), outputId: 'b'.repeat(64), + evidenceId: `${'a'.repeat(64)}:${'b'.repeat(64)}`, + addressIndex: 11, amount: '100', confirmed: true + }; + const walletService = { + accountIndex: 0, + addressToUser: new Map(), + addressToSocket: new Map(), + checkPaymentStatus: jest.fn(async address => { + expect(address).toBe('late-address'); + expect(walletService.addressToUser.get(address)).toEqual(expect.objectContaining({ + amount: 100, + addressIndex: 11 + })); + return { confirmed: true, complete: true, amount: 100, receipts: [receipt] }; + }), + stopPaymentMonitoring: jest.fn() + }; + const gameModeManager = { + paymentProviders: { get: jest.fn(() => null) }, + reconcileLatePaymentForReview: jest.fn().mockResolvedValue({ checked: true, needsReview: true }) + }; + const service = new LatePaymentReconciler({ + db, gameModeManager, walletService, lookbackHours: 24, batchSize: 20, + logger: { warn: jest.fn(), error: jest.fn() } + }); + + const result = await service.runOnce(); + + expect(result).toEqual({ skipped: false, checked: 1, reviews: 1, errors: 0 }); + expect(db.query.mock.calls[0][1]).toEqual([24, 20]); + expect(gameModeManager.reconcileLatePaymentForReview).toHaveBeenCalledWith(7, [receipt]); + expect(walletService.addressToUser.has('late-address')).toBe(false); + expect(walletService.stopPaymentMonitoring).toHaveBeenCalledWith('late-address'); + }); + + test('durable late receipt stays expired and creates review without any entitlement write', async () => { + const calls = []; + const client = { + query: jest.fn(async (sql, params = []) => { + calls.push({ sql, params }); + if (/SELECT id, status, expected_amount/i.test(sql)) { + return { rows: [{ + id: 8, status: 'expired', expected_amount: '100', + provider_id: 'native-monero', address_index: 12 + }] }; + } + if (/COALESCE\(SUM\(amount\)/i.test(sql)) { + return { rows: [{ total: '100', primary_tx_hash: 'c'.repeat(64), receipt_count: '1' }] }; + } + return { rows: [], rowCount: 1 }; + }) + }; + const manager = Object.create(GameModeManager.prototype); + manager.db = { withTransaction: async callback => callback(client) }; + const outputId = 'd'.repeat(64); + const receipt = { + evidenceType: 'chain_output', providerId: 'native-monero', + txHash: 'c'.repeat(64), outputId, + evidenceId: `${'c'.repeat(64)}:${outputId}`, + addressIndex: 12, amount: '100', confirmed: true + }; + + const result = await manager.reconcileLatePaymentForReview(8, [receipt]); + + expect(result).toEqual(expect.objectContaining({ + checked: true, + needsReview: true, + coverageComplete: true + })); + expect(calls.some(call => /INSERT INTO payment_receipts/i.test(call.sql))).toBe(true); + expect(calls.some(call => /INSERT INTO payment_late_reviews/i.test(call.sql))).toBe(true); + expect(calls.some(call => /SET status = CASE WHEN status = 'pending' THEN 'expired'/i.test(call.sql))).toBe(true); + expect(calls.some(call => /UPDATE users|payment_entitlement_grants|credit_transactions/i.test(call.sql))).toBe(false); + }); +}); diff --git a/test/legalPages.test.js b/test/legalPages.test.js new file mode 100644 index 0000000..fff1516 --- /dev/null +++ b/test/legalPages.test.js @@ -0,0 +1,42 @@ +const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy'); +const { renderPrivacy, renderResponsiblePlay, renderTerms } = require('../src/views/legalPages'); + +function disclosure(overrides = {}) { + const manager = { + cryptoType: 'WOW', currencyLabel: 'WOW', network: 'mainnet', isTestNetwork: false, + gameName: '', paymentsEnabled: true, freePlayEnabled: true, + directModeEnabled: true, creditsModeEnabled: true, payoutsEnabled: false, + isPayoutEnabledForMode: () => false + }; + return buildCommerceDisclosure(manager, { + NODE_ENV: 'production', LEGAL_POLICY_VERSION: 'v1', TERMS_EFFECTIVE_DATE: '2026-07-21', + MINIMUM_AGE: '18', PAID_ACKNOWLEDGEMENT_REQUIRED: 'true', + OPERATOR_NAME: '', + OPERATOR_CONTACT_URL: 'javascript:alert(1)', + ...overrides + }); +} + +describe('production disclosure pages', () => { + test.each([ + ['terms', renderTerms, 'Paid prestige mode'], + ['privacy', renderPrivacy, 'random browser session token'], + ['responsible play', renderResponsiblePlay, 'automated self-exclusion system'] + ])('%s page is mode-aware, complete, and HTML-escapes operator data', (_name, render, expected) => { + const html = render(disclosure()); + expect(html).toContain(expected); + expect(html).toContain('/responsible-play'); + expect(html).toContain('<script>bad</script>'); + expect(html).toContain('<img src=x onerror=alert(1)>'); + expect(html).not.toContain(''); + expect(html).not.toContain('href="javascript:'); + }); + + test('terms state the exact solo and PvP leaderboard mappings', () => { + const html = renderTerms(disclosure()); + expect(html).toContain('FREE runs use the Pleb board'); + expect(html).toContain('PAID_SINGLE and PAID_CREDITS runs use the Hall of Champions'); + expect(html).toContain('credits_prestige matches use the Prestige board'); + expect(html).toContain('crypto_race matches use the Hall of Champions'); + }); +}); diff --git a/test/matchClientSettlement.test.js b/test/matchClientSettlement.test.js new file mode 100644 index 0000000..55faede --- /dev/null +++ b/test/matchClientSettlement.test.js @@ -0,0 +1,19 @@ +const fs = require('fs'); +const path = require('path'); + +describe('match client durable finish and fairness messaging', () => { + const source = fs.readFileSync(path.join(__dirname, '../html/js/matchClient.js'), 'utf8'); + + test('does not treat a settlement-pending event as a final result', () => { + const pending = source.match(/socket\.on\('match_settlement_pending',[\s\S]*?\n \}\);/); + expect(pending).toBeTruthy(); + expect(pending[0]).toContain('not final yet'); + expect(pending[0]).not.toContain('inRace = false'); + }); + + test('checks the revealed seed against the published SHA-256 commitment', () => { + expect(source).toContain("subtle.digest('SHA-256'"); + expect(source).toContain("actual === String(fairness.seedHash).toLowerCase()"); + expect(source).toContain('seed proof ✓'); + }); +}); diff --git a/test/matchCrashRecovery.test.js b/test/matchCrashRecovery.test.js new file mode 100644 index 0000000..82f1b99 --- /dev/null +++ b/test/matchCrashRecovery.test.js @@ -0,0 +1,497 @@ +const MatchQueue = require('../src/network/matchQueue'); +const MatchRoom = require('../src/multiplayer/MatchRoom'); +const MatchScheduler = require('../src/network/matchScheduler'); +const MatchManager = require('../src/network/matchManager'); + +describe('match queue crash/restart invariants', () => { + const priorMatchEnabled = process.env.MATCH_ENABLED; + + beforeEach(() => { + process.env.MATCH_ENABLED = 'true'; + }); + + afterEach(() => { + if (priorMatchEnabled === undefined) delete process.env.MATCH_ENABLED; + else process.env.MATCH_ENABLED = priorMatchEnabled; + }); + + test('persisting a match links every exact queue row before inserting linked entrants', async () => { + const captured = []; + const entries = [ + { userId: 7, socketId: 's7', queueEntryId: 101 }, + { userId: 8, socketId: 's8', queueEntryId: 102 } + ]; + const db = { + async withTransaction(fn) { + return fn({ + async query(sql, params) { + captured.push({ sql, params }); + if (sql.includes('UPDATE match_queue_entries') && sql.includes('SET match_id')) { + return { + rows: entries.map(entry => ({ + id: entry.queueEntryId, + user_id: entry.userId + })), + rowCount: entries.length + }; + } + return { rows: [{ id: 1 }], rowCount: 1 }; + } + }); + } + }; + const room = new MatchRoom({ + economy: 'crypto_race', + entrants: { + s7: { userId: 7, socketId: 's7' }, + s8: { userId: 8, socketId: 's8' } + }, + seed: 'a'.repeat(64) + }); + const scheduler = new MatchScheduler({ matchQueue: {}, matchManager: { db } }); + + await scheduler._persistMatch(room, entries, 'crypto_race'); + + const link = captured.find(call => call.sql.includes('SET match_id')); + expect(link.params).toEqual([room.id, [101, 102], 'crypto_race']); + expect(link.sql).toContain("status = 'matched'"); + expect(link.sql).toContain('match_id IS NULL'); + const entrantInserts = captured.filter(call => call.sql.includes('INSERT INTO match_entrants')); + expect(entrantInserts.map(call => call.params)).toEqual([ + [room.id, 7, 's7', 101], + [room.id, 8, 's8', 102] + ]); + scheduler.shutdown(); + }); + + test('a finished match with linked consumed tickets is never treated as stale escrow', async () => { + let staleSql = ''; + const db = { + query: jest.fn(async (sql) => { + if (sql.includes('FROM matches')) return { rows: [], rowCount: 0 }; + if (sql.includes('FROM match_queue_entries') && sql.includes("status = 'matched'")) { + staleSql = sql; + return { rows: [], rowCount: 0 }; + } + return { rows: [], rowCount: 0 }; + }), + withTransaction: jest.fn() + }; + const queue = new MatchQueue({ db }); + + await queue.initialize(); + await queue.shutdown(); + + expect(staleSql).toContain("status = 'matched' AND match_id IS NULL"); + expect(staleSql).not.toContain("status = 'consumed'"); + expect(db.withTransaction).not.toHaveBeenCalled(); + }); + + test('a crash after drain but before match persistence refunds the unlinked hold exactly once', async () => { + const state = { + queueStatus: 'matched', + matchId: null, + lotRemaining: 0, + userTickets: 0, + ledgerRows: 0 + }; + const db = { + async query(sql) { + if (sql.includes('FROM match_queue_entries') + && state.queueStatus === 'matched' + && state.matchId === null) { + return { + rows: [{ + id: 41, + user_id: 9, + economy: 'crypto_race', + escrow_amount: '1', + race_entry_lot_id: 71 + }], + rowCount: 1 + }; + } + return { rows: [], rowCount: 0 }; + }, + async withTransaction(fn) { + return fn({ + async query(sql) { + if (sql.includes('UPDATE match_queue_entries')) { + expect(sql).toContain("status = 'matched' AND match_id IS NULL"); + if (state.queueStatus !== 'matched' || state.matchId !== null) { + return { rows: [], rowCount: 0 }; + } + state.queueStatus = 'cancelled'; + return { + rows: [{ id: 41, escrow_amount: '1', race_entry_lot_id: 71 }], + rowCount: 1 + }; + } + if (sql.includes('UPDATE race_entry_lots')) { + expect(sql).toContain('refunded_at IS NULL'); + expect(sql).toContain('remaining_entries + $1 <= original_entries'); + state.lotRemaining += 1; + return { rows: [{ id: 71 }], rowCount: 1 }; + } + if (sql.includes('UPDATE users')) { + state.userTickets += 1; + return { rows: [{ race_entries: state.userTickets }], rowCount: 1 }; + } + if (sql.includes('INSERT INTO race_entry_transactions')) { + state.ledgerRows += 1; + return { rows: [], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + } + }); + } + }; + const queue = new MatchQueue({ db }); + + await queue._recoverStaleQueueEntries(); + await queue._recoverStaleQueueEntries(); + + expect(state).toEqual(expect.objectContaining({ + queueStatus: 'cancelled', + lotRemaining: 1, + userTickets: 1, + ledgerRows: 1 + })); + }); + + test('an abandoned starting match restores a consumed funded ticket only once', async () => { + const state = { + matchStatus: 'starting', + queueStatus: 'consumed', + lotRemaining: 0, + userTickets: 0, + entrantRefunded: false, + ledgerRows: 0 + }; + const db = { + async query(sql) { + if (sql.includes('FROM matches') && state.matchStatus === 'starting') { + return { rows: [{ id: 'match-9', economy: 'crypto_race' }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }, + async withTransaction(fn) { + return fn({ + async query(sql) { + if (sql.includes('SELECT id FROM payouts')) return { rows: [], rowCount: 0 }; + if (sql.includes('UPDATE matches SET status')) { + if (state.matchStatus !== 'starting') return { rows: [], rowCount: 0 }; + state.matchStatus = 'cancelled'; + return { rows: [{ id: 'match-9' }], rowCount: 1 }; + } + if (sql.includes('FROM match_entrants') && sql.includes('queue_entry_id')) { + return { + rows: [{ id: 91, user_id: 9, queue_entry_id: 41 }], + rowCount: 1 + }; + } + if (sql.includes('UPDATE match_queue_entries') && sql.includes('match_id = $2')) { + if (state.queueStatus !== 'consumed') return { rows: [], rowCount: 0 }; + state.queueStatus = 'cancelled'; + return { + rows: [{ id: 41, escrow_amount: '1', race_entry_lot_id: 71 }], + rowCount: 1 + }; + } + if (sql.includes('UPDATE race_entry_lots')) { + state.lotRemaining += 1; + return { rows: [{ id: 71 }], rowCount: 1 }; + } + if (sql.includes('UPDATE users')) { + state.userTickets += 1; + return { rows: [{ race_entries: state.userTickets }], rowCount: 1 }; + } + if (sql.includes('INSERT INTO race_entry_transactions')) { + state.ledgerRows += 1; + return { rows: [], rowCount: 1 }; + } + if (sql.includes('UPDATE match_entrants')) { + state.entrantRefunded = true; + return { rows: [], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + } + }); + } + }; + const queue = new MatchQueue({ db }); + + await queue._recoverAbandonedMatches(); + await queue._recoverAbandonedMatches(); + + expect(state).toEqual({ + matchStatus: 'cancelled', + queueStatus: 'cancelled', + lotRemaining: 1, + userTickets: 1, + entrantRefunded: true, + ledgerRows: 1 + }); + }); + + test('queue initialization stays closed when the abandoned-match scan fails', async () => { + const db = { + query: jest.fn(async sql => { + if (sql.includes('FROM matches')) throw new Error('scan unavailable'); + return { rows: [], rowCount: 0 }; + }), + withTransaction: jest.fn() + }; + const queue = new MatchQueue({ db }); + + await expect(queue.initialize()).rejects.toMatchObject({ + code: 'FINANCIAL_RECOVERY_INCOMPLETE', + recovery: { + scope: 'abandoned_matches', + scanFailed: true, + scanned: 0, + resolved: 0, + unresolved: [] + } + }); + expect(queue.initialized).toBe(false); + expect(db.withTransaction).not.toHaveBeenCalled(); + }); + + test('queue initialization rejects after processing peers when one match remains unresolved', async () => { + let transactionCount = 0; + const db = { + query: jest.fn(async sql => { + if (sql.includes('FROM matches')) { + return { + rows: [ + { id: 'unresolved-match', economy: 'crypto_race' }, + { id: 'resolved-match', economy: 'crypto_race' } + ], + rowCount: 2 + }; + } + return { rows: [], rowCount: 0 }; + }), + withTransaction: jest.fn(async fn => { + transactionCount += 1; + if (transactionCount === 1) throw new Error('refund transaction failed'); + return fn({ + query: jest.fn(async sql => { + if (sql.includes('SELECT id FROM payouts')) { + return { rows: [{ id: 91 }], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + }) + }); + }) + }; + const queue = new MatchQueue({ db }); + + await expect(queue.initialize()).rejects.toMatchObject({ + code: 'FINANCIAL_RECOVERY_INCOMPLETE', + recovery: { + scope: 'abandoned_matches', + scanFailed: false, + scanned: 2, + resolved: 1, + unresolved: [{ type: 'match', id: 'unresolved-match' }] + } + }); + expect(queue.initialized).toBe(false); + expect(db.withTransaction).toHaveBeenCalledTimes(2); + }); + + test('paid queue admission is closed before durable recovery completes', async () => { + const queue = new MatchQueue({ db: {} }); + + expect(await queue.join({ userId: 7, economy: 'credits_prestige' })).toEqual({ + success: false, + reason: 'financial_recovery_pending' + }); + }); + + test('a later liability-recovery failure closes paid joins, drains, and freezes dynamically', async () => { + const queue = new MatchQueue({ + db: {}, + isFinancialRecoveryReady: () => false + }); + queue.initialized = true; + queue._queues.credits_prestige.push( + { queueEntryId: 1, userId: 1, socketId: 's1' }, + { queueEntryId: 2, userId: 2, socketId: 's2' } + ); + + expect(await queue.join({ userId: 7, economy: 'credits_prestige' })).toEqual({ + success: false, + reason: 'financial_recovery_pending' + }); + await expect(queue.drain('credits_prestige', 2, 2)).resolves.toBeNull(); + await expect(queue.freezePaidMatch({ + economy: 'credits_prestige', + maxPlayers: 2, + minPlayers: 2, + freezeBlockHeight: 10, + targetBlockHeight: 11, + rulesetId: 'race' + })).resolves.toBeNull(); + }); + + test('MATCH_ENABLED=false still refunds durable queued escrow during boot', async () => { + process.env.MATCH_ENABLED = 'false'; + let userTickets = 0; + let lotTickets = 0; + let ledgerRows = 0; + const db = { + async query(sql) { + if (sql.includes('FROM matches')) return { rows: [], rowCount: 0 }; + if (sql.includes("status = 'matched'") && sql.includes('INTERVAL')) { + return { rows: [], rowCount: 0 }; + } + if (sql.includes("WHERE status = 'queued'")) { + return { + rows: [{ + id: 55, + user_id: 12, + economy: 'crypto_race', + socket_id: 'old-socket', + session_token: 'session', + created_at: new Date().toISOString() + }], + rowCount: 1 + }; + } + return { rows: [], rowCount: 0 }; + }, + async withTransaction(fn) { + return fn({ + async query(sql) { + if (sql.includes('DELETE FROM match_queue_entries')) { + return { + rows: [{ id: 55, escrow_amount: '1', race_entry_lot_id: 77 }], + rowCount: 1 + }; + } + if (sql.includes('UPDATE users')) { + userTickets += 1; + return { rows: [{ race_entries: userTickets }], rowCount: 1 }; + } + if (sql.includes('UPDATE race_entry_lots')) { + lotTickets += 1; + return { rows: [{ id: 77 }], rowCount: 1 }; + } + if (sql.includes('INSERT INTO race_entry_transactions')) { + ledgerRows += 1; + return { rows: [], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + } + }); + } + }; + const queue = new MatchQueue({ db }); + + await queue.initialize(); + await queue.shutdown(); + + expect(queue.isEnabled()).toBe(false); + expect(queue.length('crypto_race')).toBe(0); + expect({ userTickets, lotTickets, ledgerRows }).toEqual({ + userTickets: 1, + lotTickets: 1, + ledgerRows: 1 + }); + }); + + test('MATCH_ENABLED=false still reconciles an already-finished accepted liability', async () => { + process.env.MATCH_ENABLED = 'false'; + const manager = new MatchManager({ db: {}, io: null }); + manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({ + scanned: 1, + created: 1, + failed: 0 + }); + + await manager.initialize(); + + expect(manager.enabled).toBe(false); + expect(manager.matchPayoutService.reconcileFinishedLiabilities).toHaveBeenCalledTimes(1); + manager.shutdown(); + }); + + test('finished accepted-liability failures keep match recovery and startup closed', async () => { + const manager = new MatchManager({ db: {}, io: null }); + manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({ + ok: false, + scanned: 1, + created: 0, + failed: 1, + unresolved: [{ type: 'match_liability', id: 'match-owed' }] + }); + + await expect(manager.initialize()).rejects.toMatchObject({ + code: 'FINANCIAL_RECOVERY_INCOMPLETE', + recovery: { + scope: 'finished_match_liabilities', + scanFailed: false, + scanned: 1, + resolved: 0, + unresolved: [{ type: 'match_liability', id: 'match-owed' }] + } + }); + expect(manager.financialRecoveryReady).toBe(false); + expect(manager._liabilityReconcileTimer).toBeNull(); + manager.shutdown(); + }); + + test('finished-liability scan errors have explicit fail-closed startup semantics', async () => { + const manager = new MatchManager({ db: {}, io: null }); + manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn() + .mockRejectedValue(new Error('liability scan unavailable')); + + await expect(manager.initialize()).rejects.toMatchObject({ + code: 'FINANCIAL_RECOVERY_INCOMPLETE', + recovery: { + scope: 'finished_match_liabilities', + scanFailed: true, + scanned: 0, + resolved: 0, + unresolved: [] + } + }); + expect(manager.financialRecoveryReady).toBe(false); + manager.shutdown(); + }); + + test('bounded reconciliation retries a later transient failure and reopens the dynamic gate', async () => { + const manager = new MatchManager({ db: {}, io: null }); + manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({ + ok: true, scanned: 0, created: 0, failed: 0, unresolved: [] + }); + await manager.initialize(); + manager.matchPayoutService.reconcileFinishedLiabilities + .mockResolvedValueOnce({ + ok: false, + scanned: 1, + created: 0, + failed: 1, + unresolved: [{ type: 'match_liability', id: 'match-retry' }] + }) + .mockResolvedValueOnce({ + ok: true, + scanned: 1, + created: 1, + failed: 0, + unresolved: [] + }); + + const failed = await manager._reconcileFinishedLiabilities({ failClosed: false }); + expect(failed.ok).toBe(false); + expect(manager.financialRecoveryReady).toBe(false); + + const retried = await manager._reconcileFinishedLiabilities({ failClosed: false }); + expect(retried).toMatchObject({ ok: true, scanned: 1, created: 1, failed: 0 }); + expect(manager.financialRecoveryReady).toBe(true); + manager.shutdown(); + }); +}); diff --git a/test/matchLeaderboard.test.js b/test/matchLeaderboard.test.js index 6b8f7fc..b6e2505 100644 --- a/test/matchLeaderboard.test.js +++ b/test/matchLeaderboard.test.js @@ -1,5 +1,6 @@ const MatchLeaderboard = require('../src/network/matchLeaderboard'); const MatchRoom = require('../src/multiplayer/MatchRoom'); +const { WIN } = require('../src/game/rulesets'); describe('MatchLeaderboard', () => { function makeDb() { @@ -64,4 +65,87 @@ describe('MatchLeaderboard', () => { expect(gamesInserts.length).toBe(2); expect(gamesInserts[0].params[2]).toBe('PAID_CREDITS'); }); + + test('last-alive win/loss follows winnerId + placement, never the escaped flag', async () => { + const db = makeDb(); + const lb = new MatchLeaderboard({ db }); + const room = new MatchRoom({ + entrants: { a: { userId: 1 }, b: { userId: 2 } }, + economy: 'free', + ruleset: { + id: 'test-last-alive', mode: 'pvp', + entities: { monster: false, pvpCombat: true }, + players: { min: 2, max: 2 }, + winCondition: { type: WIN.LAST_ALIVE } + }, + seed: '0'.repeat(64) + }); + room.start(); + room._finishPlayer('a', true); + room.winCondition.onExit(room, 'a'); + room.finalize(); + + expect(room.playerStates.get('a').escaped).toBe(true); + expect(room.winnerId).toBe('b'); + expect(room.playerStates.get('b').placement).toBe(1); + + await lb.postMatch(room); + const inserts = db.captured.filter(q => q.sql.includes('INSERT INTO games')); + const bySocket = new Map(inserts.map(q => [q.params[1], q.params])); + expect(bySocket.get('a').slice(3, 5)).toEqual(['lost', 'match_loss']); + expect(bySocket.get('b').slice(3, 5)).toEqual(['won', 'match_winner']); + }); + + test('score-attack awards the authoritative score winner even when another entrant escaped', async () => { + const db = makeDb(); + const lb = new MatchLeaderboard({ db }); + const room = new MatchRoom({ + entrants: { a: { userId: 1 }, b: { userId: 2 } }, + economy: 'free', + ruleset: { + id: 'test-score', mode: 'race', entities: { monster: false }, + players: { min: 2, max: 2 }, winCondition: { type: WIN.HIGH_SCORE } + }, + seed: '0'.repeat(64) + }); + room.start(); + room.playerStates.get('a').escaped = true; + room.playerStates.get('a').finished = true; + room.playerStates.get('a').moves = 200; + room.playerStates.get('b').hasTreasure = true; + room.playerStates.get('b').moves = 1; + room.expire('hard_ceiling'); + room.finalize(); + + expect(room.winnerId).toBe('b'); + await lb.postMatch(room); + const inserts = db.captured.filter(q => q.sql.includes('INSERT INTO games')); + const bySocket = new Map(inserts.map(q => [q.params[1], q.params])); + expect(bySocket.get('a')[3]).toBe('lost'); + expect(bySocket.get('b')[3]).toBe('won'); + }); + + test('co-op results stay in match history and never enter individual boards', async () => { + const emitted = []; + const db = makeDb(); + const lb = new MatchLeaderboard({ db, io: { emit: (e, p) => emitted.push({ e, p }) } }); + const room = new MatchRoom({ + entrants: { a: { userId: 1 }, b: { userId: 2 } }, + economy: 'free', + ruleset: { + id: 'coop-escape', mode: 'coop', + entities: { monster: false }, players: { min: 2, max: 2 }, + winCondition: { type: WIN.ALL_ESCAPE } + }, + seed: '0'.repeat(64) + }); + room.start(); + room.expire('hard_ceiling'); + room.finalize(); + + expect(room.winnerId).toBeNull(); + await lb.postMatch(room); + expect(db.captured).toHaveLength(0); + expect(emitted).toHaveLength(0); + }); }); diff --git a/test/matchLiabilityPolicy.test.js b/test/matchLiabilityPolicy.test.js new file mode 100644 index 0000000..0c179ea --- /dev/null +++ b/test/matchLiabilityPolicy.test.js @@ -0,0 +1,297 @@ +const MatchManager = require('../src/network/matchManager'); +const MatchPayoutService = require('../src/network/matchPayoutService'); +const MatchQueue = require('../src/network/matchQueue'); +const MatchRoom = require('../src/multiplayer/MatchRoom'); +const MatchScheduler = require('../src/network/matchScheduler'); +const { + buildPaidEntrantFreeze, + deriveFutureBlockMatchSeed +} = require('../src/network/matchFairness'); + +const MATCH_ENV_KEYS = [ + 'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', + 'MATCH_PAYOUT_MAX', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_HOUSE_FEE_PERCENT', + 'MATCH_RULESET_ID', 'MATCH_MAX_PLAYERS', 'PAYOUTS_ENABLED', 'PAYMENTS_ENABLED' +]; + +describe('match payout liability production invariants', () => { + let priorEnv; + + beforeEach(() => { + priorEnv = Object.fromEntries(MATCH_ENV_KEYS.map(key => [key, process.env[key]])); + Object.assign(process.env, { + MATCH_ENABLED: 'true', + MATCH_CRYPTO_RACE_ENABLED: 'true', + MATCH_PAYOUTS_ENABLED: 'true', + MATCH_PAYOUT_MAX: '1000000', + MATCH_ENTRY_FEE_ATOMIC: '10000', + MATCH_HOUSE_FEE_PERCENT: '5', + MATCH_RULESET_ID: 'race', + MATCH_MAX_PLAYERS: '4', + PAYOUTS_ENABLED: 'true', + PAYMENTS_ENABLED: 'true' + }); + }); + + afterEach(() => { + for (const [key, value] of Object.entries(priorEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + const gameModeManager = (overrides = {}) => ({ + payoutsEnabled: true, + directModeEnabled: true, + creditsModeEnabled: false, + _scheduleBatchPayout: jest.fn(), + ...overrides + }); + + function cryptoRoom(rulesetId = 'race') { + return new MatchRoom({ + economy: 'crypto_race', + rulesetId, + entrants: { a: { userId: 1 }, b: { userId: 2 } }, + seed: '0'.repeat(64) + }); + } + + test('MATCH_PAYOUT_MAX is checked before any ticket commitment transaction', async () => { + process.env.MATCH_PAYOUT_MAX = '18000'; // two entries owe 19,000 after the 5% fee + const db = { withTransaction: jest.fn() }; + const service = new MatchPayoutService({ db, gameModeManager: gameModeManager() }); + + await expect(service.collectEntryTickets(cryptoRoom(), [ + { userId: 1, queueEntryId: 11 }, { userId: 2, queueEntryId: 12 } + ])) + .rejects.toMatchObject({ code: 'PAYOUT_CAP_EXCEEDED' }); + expect(db.withTransaction).not.toHaveBeenCalled(); + }); + + test('crypto co-op is rejected before commitment because split semantics do not exist', async () => { + process.env.MATCH_RULESET_ID = 'coop-escape'; + const db = { withTransaction: jest.fn() }; + const service = new MatchPayoutService({ db, gameModeManager: gameModeManager() }); + + await expect(service.collectEntryTickets(cryptoRoom('coop-escape'), [ + { userId: 1, queueEntryId: 11 }, { userId: 2, queueEntryId: 12 } + ])) + .rejects.toMatchObject({ code: 'UNSUPPORTED_CRYPTO_RULESET' }); + expect(db.withTransaction).not.toHaveBeenCalled(); + }); + + test('scheduler clamps entrants to the selected ruleset player contract', () => { + const scheduler = new MatchScheduler({ + matchQueue: {}, + matchManager: {}, + rulesetId: 'race', + maxPlayers: 32 + }); + + expect(scheduler.minPlayers).toBe(2); + expect(scheduler.maxPlayers).toBe(8); + scheduler.shutdown(); + }); + + test('hot-disable refunds queued crypto escrow and removes memory only after commit', async () => { + let available = true; + const captured = []; + const db = { + async withTransaction(fn) { + return fn({ + async query(sql, params) { + captured.push({ sql, params }); + if (sql.includes('UPDATE match_queue_entries')) return { rows: [{ id: 1 }], rowCount: 1 }; + if (sql.includes('UPDATE users')) return { rows: [{ race_entries: 2 }], rowCount: 1 }; + return { rows: [{ id: 1 }], rowCount: 1 }; + } + }); + } + }; + const manager = gameModeManager({ + _getMatchEconomies: () => available ? { free: true, crypto_race: true } : { free: true } + }); + const queue = new MatchQueue({ db, gameModeManager: manager }); + queue._queues.crypto_race.push({ userId: 1, socketId: 'a', createdAt: 1 }); + + available = false; + await queue.refundUnavailableQueues(); + + expect(queue.length('crypto_race')).toBe(0); + expect(captured.some(q => q.sql.includes('race_entries = race_entries + $1'))).toBe(true); + expect(captured.some(q => q.sql.includes('INSERT INTO race_entry_transactions'))).toBe(true); + }); + + test('failed hot-disable refund remains queued for the next reconciliation attempt', async () => { + const db = { async withTransaction() { throw new Error('db down'); } }; + const manager = gameModeManager({ _getMatchEconomies: () => ({ free: true }) }); + const queue = new MatchQueue({ db, gameModeManager: manager }); + queue._queues.crypto_race.push({ userId: 1, socketId: 'a', createdAt: 1 }); + + await queue.refundUnavailableQueues(); + + expect(queue.length('crypto_race')).toBe(1); + }); + + test('finished liability is reconciled after a transient payout insertion failure', async () => { + let insertFails = true; + let payoutInserted = false; + let successfulInserts = 0; + const captured = []; + const db = { + async query(sql) { + if (sql.includes('SELECT m.id')) { + return payoutInserted + ? { rows: [], rowCount: 0 } + : { rows: [{ id: 'match-1' }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }, + async withTransaction(fn) { + return fn({ + async query(sql, params) { + captured.push({ sql, params }); + if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) { + return { rows: [{ + id: 'match-1', economy: 'crypto_race', status: 'finished', winner_user_id: 7, + payout_liability_amount_atomic: '19000', + payout_liability_cap_atomic: '1000000', + payout_liability_accepted_at: new Date().toISOString() + }], rowCount: 1 }; + } + if (sql.includes('FROM payouts')) { + return payoutInserted + ? { rows: [{ id: 88 }], rowCount: 1 } + : { rows: [], rowCount: 0 }; + } + if (sql.includes('FROM users')) return { rows: [{ id: 7, payout_address: 'wallet' }], rowCount: 1 }; + if (sql.includes('INSERT INTO payouts') && insertFails) throw new Error('insert unavailable'); + if (sql.includes('INSERT INTO payouts')) { + payoutInserted = true; + successfulInserts += 1; + } + return { rows: [{ id: 88 }], rowCount: 1 }; + } + }); + } + }; + const manager = gameModeManager(); + const service = new MatchPayoutService({ db, gameModeManager: manager }); + + expect(await service.reconcileFinishedLiabilities()).toEqual({ + ok: false, + scanned: 1, + created: 0, + failed: 1, + unresolved: [{ type: 'match_liability', id: 'match-1' }] + }); + insertFails = false; + expect(await service.reconcileFinishedLiabilities()).toEqual({ + ok: true, + scanned: 1, + created: 1, + failed: 0, + unresolved: [] + }); + expect(await service.reconcileFinishedLiabilities()).toEqual({ + ok: true, + scanned: 0, + created: 0, + failed: 0, + unresolved: [] + }); + expect(successfulInserts).toBe(1); + expect(manager._scheduleBatchPayout).toHaveBeenCalledTimes(1); + expect(captured.filter(q => q.sql.includes('INSERT INTO payouts'))).toHaveLength(2); + }); + + test('manager durably persists completion before attempting liability insertion', async () => { + const manager = new MatchManager({ io: null, db: {}, debugManager: null }); + manager.enabled = true; + const order = []; + manager._persistFinish = async () => { order.push('persist'); }; + manager.matchPayoutService.payoutWinner = async () => { order.push('payout'); }; + manager.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); }; + const room = cryptoRoom(); + room.winnerId = 'a'; + room.status = 'finished'; + + await manager._finalize(room); + + expect(order.slice(0, 2)).toEqual(['persist', 'payout']); + manager.shutdown(); + }); + + test('a future-block-frozen match with an ambiguous collect acknowledgement continues only when the accepted snapshot exists', async () => { + const entries = [ + { userId: 1, socketId: 'a', sessionToken: 'ta', queueEntryId: 11 }, + { userId: 2, socketId: 'b', sessionToken: 'tb', queueEntryId: 12 } + ]; + const matchQueue = { + refundEntries: jest.fn() + }; + const payoutService = { + collectEntryTickets: jest.fn().mockRejectedValue(new Error('COMMIT acknowledgement lost')), + getAcceptedLiability: jest.fn().mockResolvedValue({ + entry_fee_atomic: '10000', + pot_atomic: '20000', + house_fee_atomic: '1000', + house_fee_percent: '5', + payout_liability_amount_atomic: '19000', + payout_liability_cap_atomic: '1000000', + payout_liability_terms: { version: 2 } + }) + }; + const matchManager = { + db: null, + gameModeManager: gameModeManager(), + matchPayoutService: payoutService, + setEngine: jest.fn(), + attach: jest.fn(), + onTick: jest.fn(), + onFinish: jest.fn() + }; + const scheduler = new MatchScheduler({ + matchQueue, + matchManager, + blockCountProvider: async () => 902, + blockEntropyProvider: async height => ({ + block_header: { height, hash: 'ab'.repeat(32) } + }) + }); + const freeze = buildPaidEntrantFreeze({ + freezeBlockHeight: 899, + targetBlockHeight: 900, + economy: 'crypto_race', + rulesetId: 'race', + queueEntryIds: entries.map(entry => entry.queueEntryId) + }); + const seedProof = deriveFutureBlockMatchSeed({ + blockHash: 'ab'.repeat(32), + blockHeight: 900, + freeze + }); + + await scheduler._startEntries({ + entries, + economy: 'crypto_race', + blockHeight: 900, + seedProof, + frozen: { + id: '11111111-1111-4111-8111-111111111111', + freeze, + freezeCommitment: freeze.freezeCommitment, + entries + } + }); + + expect(payoutService.collectEntryTickets).toHaveBeenCalledTimes(1); + expect(payoutService.getAcceptedLiability).toHaveBeenCalledTimes(1); + expect(matchQueue.refundEntries).not.toHaveBeenCalled(); + expect(matchManager.attach).toHaveBeenCalledTimes(1); + const room = matchManager.attach.mock.calls[0][0]; + expect(room.payoutLiabilityAmountAtomic).toBe('19000'); + scheduler.shutdown(); + }); +}); diff --git a/test/matchPayoutService.test.js b/test/matchPayoutService.test.js index 2a4b500..8ffcc3b 100644 --- a/test/matchPayoutService.test.js +++ b/test/matchPayoutService.test.js @@ -2,33 +2,84 @@ const MatchPayoutService = require('../src/network/matchPayoutService'); const MatchRoom = require('../src/multiplayer/MatchRoom'); describe('MatchPayoutService', () => { - function makeDb() { + const enabledEnv = (overrides = {}) => ({ + MATCH_ENABLED: 'true', + MATCH_CRYPTO_RACE_ENABLED: 'true', + MATCH_PAYOUTS_ENABLED: 'true', + MATCH_PAYOUT_MAX: '100000000000000000', + MATCH_ENTRY_FEE_ATOMIC: '10000', + MATCH_HOUSE_FEE_PERCENT: '5', + PAYOUTS_ENABLED: 'true', + PAYMENTS_ENABLED: 'true', + MATCH_RULESET_ID: 'race', + MATCH_MAX_PLAYERS: '4', + CRYPTO_TYPE: 'XMR', + MONERO_NETWORK: 'stagenet', + ...overrides + }); + + function makeDb({ address = 'WALLET_ADDRESS', accepted = true, amount = '19000', cap = '1000000', existing = null } = {}) { const captured = []; return { captured, - async query(sql, params) { - if (sql.includes('SELECT pot_atomic')) return { rows: [{ pot_atomic: '20000', house_fee_atomic: '1000' }] }; - if (sql.includes('SELECT id, payout_address')) return { rows: [{ id: 1, payout_address: 'WALLET_ADDRESS' }] }; + async query(sql) { + if (sql.includes('SELECT m.id')) return { rows: [] }; return { rows: [], rowCount: 0 }; }, async withTransaction(fn) { const client = { async query(sql, params) { captured.push({ sql, params }); - if (sql.includes('SELECT id FROM payouts')) return { rows: [] }; + if (sql.includes('FROM match_queue_entries') && sql.includes('FOR UPDATE')) { + const ids = params[0]; + return { + rows: ids.map((id, index) => ({ + id, + user_id: index + 1, + race_entry_lot_id: 100 + index, + escrow_amount: '1', + escrow_value_atomic: '10000' + })), + rowCount: ids.length + }; + } + if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) { + return { rows: [{ + id: params[0], economy: 'crypto_race', status: 'finished', winner_user_id: 1, + payout_liability_amount_atomic: amount, + payout_liability_cap_atomic: cap, + payout_liability_accepted_at: accepted ? new Date().toISOString() : null + }], rowCount: 1 }; + } + if (sql.includes('FROM payouts') && sql.includes('LIMIT 1')) { + return { rows: existing ? [existing] : [], rowCount: existing ? 1 : 0 }; + } + if (sql.includes('FROM users') && sql.includes('payout_address')) { + return { rows: [{ id: 1, payout_address: address }], rowCount: 1 }; + } return { rows: [{ id: 99 }], rowCount: 1 }; } }; - return await fn(client); + return fn(client); } }; } + function manager(overrides = {}) { + return { + payoutsEnabled: true, + directModeEnabled: true, + creditsModeEnabled: false, + _scheduleBatchPayout: jest.fn(), + ...overrides + }; + } + function makeRoom() { const room = new MatchRoom({ economy: 'crypto_race', entrants: { a: { userId: 1 }, b: { userId: 2 } }, - seed: '0000000000000000000000000000000000000000000000000000000000000000' + seed: '0'.repeat(64) }); room.start(); room.playerStates.get('a').escaped = true; @@ -38,30 +89,77 @@ describe('MatchPayoutService', () => { return room; } - test('collectEntryTickets locks pot on match row', async () => { + test('collectEntryTickets atomically locks exact pot and accepted payout terms', async () => { const db = makeDb(); - const service = new MatchPayoutService({ db }); + const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() }); const room = makeRoom(); + await service.collectEntryTickets(room, [ - { userId: 1, socketId: 'a' }, - { userId: 2, socketId: 'b' } + { userId: 1, socketId: 'a', queueEntryId: 11 }, + { userId: 2, socketId: 'b', queueEntryId: 12 } ]); - const updateMatch = db.captured.find(q => q.sql.includes('UPDATE matches')); + + const updateMatch = db.captured.find(q => q.sql.includes('payout_liability_accepted_at = NOW()')); expect(updateMatch).toBeTruthy(); - expect(room.potAtomic).toBeGreaterThan(0); + expect(updateMatch.params.slice(0, 6)).toEqual([ + '10000', '20000', '1000', 5, '19000', '100000000000000000' + ]); + expect(JSON.parse(updateMatch.params[6])).toEqual(expect.objectContaining({ + playerCount: 2, + payoutAmountAtomic: '19000', + rulesetId: 'race' + })); + expect(room.potAtomic).toBe('20000'); }); - test('payoutWinner inserts one pending payout for winner', async () => { + test('accepted ON -> OFF liability still creates a pending payout', async () => { const db = makeDb(); - const service = new MatchPayoutService({ db }); - const room = makeRoom(); - room.entryFeeAtomic = 10000; - room.potAtomic = 20000; - room.houseFeeAtomic = 1000; - await service.payoutWinner(room); + const gmm = manager({ payoutsEnabled: false }); + const service = new MatchPayoutService({ + db, + env: enabledEnv({ MATCH_PAYOUTS_ENABLED: 'false', PAYOUTS_ENABLED: 'false' }), + gameModeManager: gmm + }); + + const result = await service.payoutWinner(makeRoom()); + const insert = db.captured.find(q => q.sql.includes('INSERT INTO payouts')); - expect(insert).toBeTruthy(); - expect(insert.params[3]).toBe('19000'); // winner amount + expect(result).toEqual(expect.objectContaining({ created: true, amount: '19000' })); + expect(insert.params[3]).toBe('19000'); expect(insert.params[5]).toBe('match_winner'); + // Global off pauses dispatch, but does not suppress recording the obligation. + expect(gmm._scheduleBatchPayout).toHaveBeenCalledTimes(1); + }); + + test('OFF -> ON never creates a retroactive match liability', async () => { + const db = makeDb({ accepted: false }); + const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() }); + + const result = await service.payoutWinner(makeRoom()); + + expect(result).toEqual({ created: false, reason: 'liability_not_accepted' }); + expect(db.captured.some(q => q.sql.includes('INSERT INTO payouts'))).toBe(false); + }); + + test('needs_review payout identity blocks a replacement row', async () => { + const db = makeDb({ existing: { id: 77, status: 'needs_review' } }); + const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() }); + + const result = await service.payoutWinner(makeRoom()); + + expect(result).toEqual({ created: false, reason: 'payout_exists', payoutId: 77 }); + expect(db.captured.some(q => q.sql.includes('INSERT INTO payouts'))).toBe(false); + }); + + test('winner with no address records a claimable needs_review liability', async () => { + const db = makeDb({ address: null }); + const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() }); + + await service.payoutWinner(makeRoom()); + + const insert = db.captured.find(q => q.sql.includes('INSERT INTO payouts')); + expect(insert.params[2]).toBe('PENDING_NO_ADDRESS'); + expect(insert.params[5]).toBe('match_winner_no_address'); + expect(insert.params[6]).toBe('needs_review'); }); }); diff --git a/test/matchQueue.test.js b/test/matchQueue.test.js index ad35282..4f5a797 100644 --- a/test/matchQueue.test.js +++ b/test/matchQueue.test.js @@ -1,7 +1,10 @@ const MatchQueue = require('../src/network/matchQueue'); +const SocketHandlers = require('../src/network/socketHandlers'); describe('MatchQueue', () => { function makeDb() { + let nextQueueId = 1; + const queueUsers = new Map(); return { queries: [], lastParams: null, @@ -18,9 +21,13 @@ describe('MatchQueue', () => { this.queries.push({ sql, params }); let rows = [{ id: 1, created_at: new Date().toISOString(), credits: 100, race_entries: 1 }]; let rowCount = 1; - if (sql.includes('UPDATE match_queue_entries') && sql.includes('user_id = ANY')) { + if (sql.includes('INSERT INTO match_queue_entries')) { + const id = nextQueueId++; + queueUsers.set(String(id), params[0]); + rows = [{ id, created_at: new Date().toISOString() }]; + } else if (sql.includes('UPDATE match_queue_entries') && sql.includes('id = ANY')) { const ids = params[0] || []; - rows = ids.map((uid, i) => ({ id: i + 10, user_id: uid })); + rows = ids.map(id => ({ id, user_id: queueUsers.get(String(id)) })); rowCount = ids.length; } return { rows, rowCount }; @@ -45,6 +52,21 @@ describe('MatchQueue', () => { delete process.env.MATCH_ENABLED; }); + test('rejects a syntactically valid economy that the server has disabled', async () => { + process.env.MATCH_ENABLED = 'true'; + const db = makeDb(); + const gameModeManager = { _getMatchEconomies: () => ({ free: true }) }; + const q = new MatchQueue({ db, gameModeManager }); + q.initialized = true; + + const result = await q.join({ userId: 1, socketId: 's', sessionToken: 't', economy: 'crypto_race' }); + + expect(result).toEqual({ success: false, reason: 'economy_disabled' }); + expect(q.length('crypto_race')).toBe(0); + expect(db.queries).toHaveLength(0); + delete process.env.MATCH_ENABLED; + }); + test('free join adds to memory queue', async () => { process.env.MATCH_ENABLED = 'true'; const db = makeDb(); @@ -60,6 +82,7 @@ describe('MatchQueue', () => { process.env.MATCH_CREDITS_COST = '5'; const db = makeDb(); const q = new MatchQueue({ db }); + q.initialized = true; const result = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' }); expect(result.success).toBe(true); expect(q.length('credits_prestige')).toBe(1); @@ -68,6 +91,83 @@ describe('MatchQueue', () => { delete process.env.MATCH_CREDITS_COST; }); + test.each([ + ['credits_prestige', 'credits'], + ['crypto_race', 'race_entries'] + ])('concurrent duplicate %s joins hold escrow exactly once', async (economy, debitColumn) => { + process.env.MATCH_ENABLED = 'true'; + if (economy === 'crypto_race') { + process.env.MATCH_CRYPTO_RACE_ENABLED = 'true'; + process.env.MATCH_PAYOUTS_ENABLED = 'true'; + process.env.MATCH_PAYOUT_MAX = '1000000'; + process.env.MATCH_ENTRY_FEE_ATOMIC = '10000'; + process.env.MATCH_HOUSE_FEE_PERCENT = '5'; + process.env.MATCH_MAX_PLAYERS = '4'; + } + let queued = false; + let debits = 0; + let joinLedgerRows = 0; + const createdAt = new Date().toISOString(); + const db = { + async withTransaction(fn) { + return fn({ + async query(sql) { + if (sql.includes('INSERT INTO match_queue_entries')) { + if (queued) return { rows: [], rowCount: 0 }; + queued = true; + return { rows: [{ id: 77, created_at: createdAt }], rowCount: 1 }; + } + if (sql.includes('UPDATE match_queue_entries') && sql.includes("status = 'queued'")) { + return { rows: [{ id: 77, created_at: createdAt }], rowCount: 1 }; + } + if (sql.includes('UPDATE users') && sql.includes(`${debitColumn} = ${debitColumn} -`)) { + debits += 1; + return { + rows: [{ credits: 9, race_entries: 0 }], + rowCount: 1 + }; + } + if (sql.includes('SELECT id, unit_value_atomic') && sql.includes('FROM race_entry_lots')) { + return { rows: [{ id: 501, unit_value_atomic: '10000' }], rowCount: 1 }; + } + if (sql.includes('UPDATE race_entry_lots')) { + return { rows: [{ id: 501 }], rowCount: 1 }; + } + if (sql.includes('INSERT INTO credit_transactions') || sql.includes('INSERT INTO race_entry_transactions')) { + joinLedgerRows += 1; + return { rows: [], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + } + }); + } + }; + const gameModeManager = economy === 'crypto_race' ? { + payoutsEnabled: true, + directModeEnabled: true, + creditsModeEnabled: false, + _getMatchEconomies: () => ({ free: true, crypto_race: true }) + } : null; + const q = new MatchQueue({ db, gameModeManager }); + q.initialized = true; + const request = () => q.join({ userId: 9, socketId: 's9', sessionToken: 't9', economy }); + + const results = await Promise.all([request(), request()]); + + expect(results.every(result => result.success)).toBe(true); + expect(results.filter(result => result.alreadyQueued).length).toBe(1); + expect(debits).toBe(1); + expect(joinLedgerRows).toBe(1); + expect(q.length(economy)).toBe(1); + delete process.env.MATCH_ENABLED; + delete process.env.MATCH_CRYPTO_RACE_ENABLED; + delete process.env.MATCH_PAYOUTS_ENABLED; + delete process.env.MATCH_PAYOUT_MAX; + delete process.env.MATCH_ENTRY_FEE_ATOMIC; + delete process.env.MATCH_HOUSE_FEE_PERCENT; + delete process.env.MATCH_MAX_PLAYERS; + }); + test('drain requires at least two queued players', async () => { process.env.MATCH_ENABLED = 'true'; const db = makeDb(); @@ -97,6 +197,7 @@ describe('MatchQueue', () => { process.env.MATCH_CREDITS_COST = '3'; const db = makeDb(); const q = new MatchQueue({ db }); + q.initialized = true; await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' }); const leave = await q.leave(1, 'credits_prestige'); expect(leave.success).toBe(true); @@ -105,3 +206,36 @@ describe('MatchQueue', () => { delete process.env.MATCH_CREDITS_COST; }); }); + +describe('SocketHandlers match queue acknowledgements', () => { + test('emits the documented joined and left results to the client', async () => { + const emitted = []; + const socket = { id: 'sock', emit: (event, payload) => emitted.push({ event, payload }) }; + const ctx = { + activeGames: new Map(), + matchQueue: { + isEnabled: () => true, + enqueue: jest.fn().mockResolvedValue({ success: true, position: 2 }), + leave: jest.fn().mockResolvedValue({ success: true }) + }, + rateLimiter: { + checkLimit: jest.fn().mockResolvedValue({ allowed: true, retryAfter: 0 }), + recordAttempt: jest.fn().mockResolvedValue(undefined) + }, + _resolveMatchSession: jest.fn().mockResolvedValue({ userId: 1, socketId: 'sock', sessionToken: 't' }), + debugManager: { CONSOLE_LOGGING: false } + }; + + await SocketHandlers.prototype._handleMatchQueue.call(ctx, socket, { action: 'join', economy: 'free' }); + await SocketHandlers.prototype._handleMatchQueue.call(ctx, socket, { action: 'leave', economy: 'free' }); + + expect(emitted).toContainEqual({ + event: 'match_queue_joined', + payload: { success: true, position: 2, economy: 'free' } + }); + expect(emitted).toContainEqual({ + event: 'match_queue_left', + payload: { success: true, economy: 'free' } + }); + }); +}); diff --git a/test/matchRework_ultra.test.js b/test/matchRework_ultra.test.js index 407e2c4..4a64402 100644 --- a/test/matchRework_ultra.test.js +++ b/test/matchRework_ultra.test.js @@ -116,6 +116,48 @@ describe('match rework — crash + countdown (MatchManager)', () => { // Second call must be a benign no-op (no double finalize, no throw). expect(() => m.expire(room.id, 'admin')).not.toThrow(); }); + + test('block deadline expires on the first later header after the active-play floor', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-21T12:00:00.000Z')); + const m = mgr(); + const room = new MatchRoom({ + economy: 'free', startBlockHeight: 100, + entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED + }); + m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }], + { countdownMs: NEVER, minDurationMs: 20000, hardCeilingMs: NEVER }); + room.start(); + + expect(m.expireBlockDeadlines(100)).toBe(0); // same header can never end its room + jest.advanceTimersByTime(19999); + expect(m.expireBlockDeadlines(101)).toBe(0); // later header, but floor not met + jest.advanceTimersByTime(1); + expect(m.expireBlockDeadlines(101)).toBe(1); + expect(room.status).toBe('finished'); + expect(room.endReason).toBe('block_deadline'); + expect(room.endBlockHeight).toBe(101); + expect(m.expireBlockDeadlines(102)).toBe(0); // terminal transition is idempotent + }); + + test('a ruleset can explicitly disable the block deadline', () => { + const m = mgr(); + const room = new MatchRoom({ + economy: 'free', startBlockHeight: 100, + entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED, + ruleset: { + id: 'no-block-clock', mode: 'race', players: { min: 2, max: 2 }, + entities: { monster: false }, winCondition: { type: 'high-score' }, + timing: { blockDeadline: false, minDurationMs: 0 } + } + }); + m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }], + { countdownMs: NEVER, minDurationMs: 0, hardCeilingMs: NEVER }); + room.start(); + + expect(m.expireBlockDeadlines(101)).toBe(0); + expect(room.status).toBe('active'); + }); }); describe('match rework — reconnect re-keying + ownership (MP-H1/H2)', () => { @@ -166,6 +208,42 @@ describe('match rework — reconnect re-keying + ownership (MP-H1/H2)', () => { expect(room.occupants.has('evil')).toBe(false); expect(m.socketToMatch.has('evil')).toBe(false); }); + + test('a finished-room reconnect receives the terminal result immediately and once', () => { + const { m, room } = activeMatch(); + room.expire('hard_ceiling'); + room.finalize(); + room._finalized = true; // durable finish retained during the normal cleanup grace + + const sock = makeSocket('s1new'); + expect(m.handleReconnect(sock, { userId: 1 })).toBe(true); + expect(m.handleReconnect(sock, { userId: 1 })).toBe(true); + + const terminal = sock.emitted.filter(e => e.ev === 'match_end'); + expect(terminal).toHaveLength(1); + expect(terminal[0].p).toEqual(expect.objectContaining({ + matchId: room.id, + reason: 'hard_ceiling', + winnerId: room.winnerId + })); + expect(terminal[0].p.players.find(p => p.id === 's1new')).toEqual(expect.objectContaining({ + placement: expect.any(Number), + score: expect.any(Number) + })); + expect(terminal[0].p.fairness.seed).toBe(SEED); + }); + + test('a reconnect during nondurable finish gets pending, never a premature winner', () => { + const { m, room } = activeMatch(); + room.expire('hard_ceiling'); + room.finalize(); + room._finalized = false; + + const sock = makeSocket('s1new'); + expect(m.handleReconnect(sock, { userId: 1 })).toBe(true); + expect(sock.emitted.some(e => e.ev === 'match_settlement_pending')).toBe(true); + expect(sock.emitted.some(e => e.ev === 'match_end')).toBe(false); + }); }); describe('match rework — winner consistency across end paths', () => { @@ -194,18 +272,24 @@ describe('match rework — winner consistency across end paths', () => { describe('match rework — payout economics (MP-C4/H5)', () => { function capturingDb({ address = 'WALLET', pot = '20000', fee = '1000' } = {}) { const captured = []; + const amount = (BigInt(pot) - BigInt(fee)).toString(); return { captured, - async query(sql) { - if (sql.includes('SELECT pot_atomic')) return { rows: [{ pot_atomic: pot, house_fee_atomic: fee }] }; - if (sql.includes('SELECT id, payout_address')) return { rows: [{ id: 1, payout_address: address }] }; - return { rows: [], rowCount: 0 }; - }, + async query() { return { rows: [], rowCount: 0 }; }, async withTransaction(fn) { const client = { async query(sql, params) { captured.push({ sql, params }); - if (sql.includes('SELECT id FROM payouts')) return { rows: [] }; + if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) { + return { rows: [{ + id: params[0], economy: 'crypto_race', status: 'finished', winner_user_id: 1, + payout_liability_amount_atomic: amount, + payout_liability_cap_atomic: amount, + payout_liability_accepted_at: new Date().toISOString() + }], rowCount: 1 }; + } + if (sql.includes('FROM payouts')) return { rows: [], rowCount: 0 }; + if (sql.includes('FROM users')) return { rows: [{ id: 1, payout_address: address }], rowCount: 1 }; return { rows: [{ id: 1 }], rowCount: 1 }; } }; @@ -237,28 +321,60 @@ describe('match rework — payout economics (MP-C4/H5)', () => { }); test('BigInt exactness: a pot beyond 2^53 loses no precision', async () => { - const db = capturingDb({ address: 'WALLET', pot: '90071992547409920001', fee: '1' }); + const db = capturingDb({ address: 'WALLET', pot: '900719925474099201', fee: '1' }); const svc = new MatchPayoutService({ db }); await svc.payoutWinner(makeCryptoFinishedRoom()); const ins = db.captured.find(q => q.sql.includes('INSERT INTO payouts')); - expect(ins.params[3]).toBe('90071992547409920000'); // exact; a float would round this + expect(ins.params[3]).toBe('900719925474099200'); // exact; a float would round this }); test('collectEntryTickets uses integer basis-point math for a fractional house fee', async () => { + process.env.MATCH_ENABLED = 'true'; + process.env.MATCH_CRYPTO_RACE_ENABLED = 'true'; + process.env.MATCH_PAYOUTS_ENABLED = 'true'; + process.env.MATCH_PAYOUT_MAX = '1000000000000'; + process.env.MATCH_MAX_PLAYERS = '4'; + process.env.PAYOUTS_ENABLED = 'true'; + process.env.PAYMENTS_ENABLED = 'true'; process.env.MATCH_ENTRY_FEE_ATOMIC = '10000000000'; process.env.MATCH_HOUSE_FEE_PERCENT = '5.5'; const captured = []; - const db = { async withTransaction(fn) { return fn({ async query(sql, params) { captured.push({ sql, params }); return { rows: [{ race_entries: 1 }], rowCount: 1 }; } }); } }; + const db = { async withTransaction(fn) { return fn({ async query(sql, params) { + captured.push({ sql, params }); + if (sql.includes('FROM match_queue_entries') && sql.includes('FOR UPDATE')) { + return { + rows: params[0].map((id, index) => ({ + id, + user_id: index + 1, + race_entry_lot_id: 100 + index, + escrow_amount: '1', + escrow_value_atomic: '10000000000' + })), + rowCount: params[0].length + }; + } + return { rows: [{ race_entries: 1, id: 1 }], rowCount: 1 }; + } }); } }; const svc = new MatchPayoutService({ db }); const room = new MatchRoom({ economy: 'crypto_race', entrants: { a: { userId: 1 }, b: { userId: 2 } }, seed: SEED }); - await svc.collectEntryTickets(room, [{ userId: 1 }, { userId: 2 }]); + await svc.collectEntryTickets(room, [ + { userId: 1, queueEntryId: 11 }, + { userId: 2, queueEntryId: 12 } + ]); const upd = captured.find(q => q.sql.includes('UPDATE matches')); expect(upd.params[0]).toBe('10000000000'); // entry fee expect(upd.params[1]).toBe('20000000000'); // pot = fee * 2 expect(upd.params[2]).toBe('1100000000'); // house fee = pot * 550bp / 10000 (floor) - expect(room.potAtomic).toBeGreaterThan(0); + expect(room.potAtomic).toBe('20000000000'); delete process.env.MATCH_ENTRY_FEE_ATOMIC; delete process.env.MATCH_HOUSE_FEE_PERCENT; + delete process.env.MATCH_CRYPTO_RACE_ENABLED; + delete process.env.MATCH_PAYOUTS_ENABLED; + delete process.env.MATCH_PAYOUT_MAX; + delete process.env.MATCH_MAX_PLAYERS; + delete process.env.PAYOUTS_ENABLED; + delete process.env.PAYMENTS_ENABLED; + delete process.env.MATCH_ENABLED; }); }); @@ -283,7 +399,7 @@ describe('match rework — refunds on every cancellation path (MP-H3/H4)', () => const q = new MatchQueue({ db }); await q.refundEntries([{ userId: 1 }], 'crypto_race', 'match_cancel'); expect(captured.some(c => c.sql.includes("status = 'cancelled'"))).toBe(true); - expect(captured.some(c => c.sql.includes('race_entries = race_entries + 1'))).toBe(true); + expect(captured.some(c => c.sql.includes('race_entries = race_entries + $1'))).toBe(true); expect(captured.some(c => c.sql.includes('INSERT INTO race_entry_transactions'))).toBe(true); }); @@ -331,10 +447,10 @@ describe('match rework — refunds on every cancellation path (MP-H3/H4)', () => }); }); -describe('match rework — payout-first isolated finalize (MP-C5)', () => { +describe('match rework — durable-finish isolated finalize (MP-C5)', () => { afterEach(() => { delete process.env.MATCH_ENABLED; }); - test('the winner payout runs FIRST and the match_end broadcast still fires when later steps throw', async () => { + test('a nondurable winner creates no payout, leaderboard row, or final announcement', async () => { const io = makeIo(); const throwingDb = { async withTransaction() { throw new Error('persist boom'); } }; const m = new MatchManager({ io, db: throwingDb, debugManager: null }); @@ -342,15 +458,44 @@ describe('match rework — payout-first isolated finalize (MP-C5)', () => { const order = []; m.matchPayoutService.payoutWinner = async () => { order.push('payout'); }; - m.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); throw new Error('lb boom'); }; + m.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); }; const room = makeCryptoFinishedRoom(); m.rooms.set(room.id, room); await m._finalize(room); - expect(order[0]).toBe('payout'); // payout is attempted before persist/leaderboard - // Persist threw and leaderboard threw, yet the result was still broadcast to clients. - expect(io.broadcasts.some(b => b.event === 'match_end')).toBe(true); + expect(order).not.toContain('payout'); // no DB winner means no reconstructable payout target + expect(order).not.toContain('leaderboard'); // no synthetic result without a durable match + expect(room._finalized).not.toBe(true); // remains retryable in memory + expect(io.broadcasts.some(b => b.event === 'match_end')).toBe(false); + expect(io.broadcasts.some(b => b.event === 'match_settlement_pending')).toBe(true); m.shutdown(); }); + + test('a transient finish failure retries and announces only after durability succeeds', async () => { + jest.useFakeTimers(); + const io = makeIo(); + const m = new MatchManager({ io, db: {}, debugManager: null }); + m.enabled = true; + m._persistFinish = jest.fn() + .mockRejectedValueOnce(new Error('temporary database outage')) + .mockResolvedValueOnce(); + m.matchPayoutService.payoutWinner = jest.fn().mockResolvedValue(); + m.matchLeaderboard.postMatch = jest.fn().mockResolvedValue(); + + const room = makeCryptoFinishedRoom(); + m.rooms.set(room.id, room); + await m._finalize(room); + expect(room._finalized).not.toBe(true); + expect(io.broadcasts.some(b => b.event === 'match_end')).toBe(false); + + await jest.advanceTimersByTimeAsync(5000); + expect(room._finalized).toBe(true); + expect(m._persistFinish).toHaveBeenCalledTimes(2); + expect(m.matchPayoutService.payoutWinner).toHaveBeenCalledTimes(1); + expect(m.matchLeaderboard.postMatch).toHaveBeenCalledTimes(1); + expect(io.broadcasts.filter(b => b.event === 'match_end')).toHaveLength(1); + m.shutdown(); + jest.useRealTimers(); + }); }); diff --git a/test/matchRoom.test.js b/test/matchRoom.test.js index 1cc4c8e..5c4a249 100644 --- a/test/matchRoom.test.js +++ b/test/matchRoom.test.js @@ -194,6 +194,23 @@ describe('MatchRoom', () => { expect(room.playerStates.get('a').alive).toBe(false); expect(result.events.some(e => e.type === 'player_death')).toBe(true); }); + + test('fractional monster speed accumulates across ticks and zero stays stationary', () => { + const room = makeRoom(); + room.start(); + room.monster.hasLineOfSight = jest.fn(() => true); + room.monster.moveTowardPlayer = jest.fn(() => true); + + room.difficultyConfig.monster.movesPerPlayerMove = 0.5; + expect(room._moveMonster().filter(e => e.type === 'monster_move')).toHaveLength(0); + expect(room._moveMonster().filter(e => e.type === 'monster_move')).toHaveLength(1); + expect(room.monster.moveTowardPlayer).toHaveBeenCalledTimes(1); + + room.difficultyConfig.monster.movesPerPlayerMove = 0; + room._moveMonster(); + room._moveMonster(); + expect(room.monster.moveTowardPlayer).toHaveBeenCalledTimes(1); + }); }); describe('expiration / rankings', () => { diff --git a/test/matchScheduler.test.js b/test/matchScheduler.test.js index 6692e24..949e92f 100644 --- a/test/matchScheduler.test.js +++ b/test/matchScheduler.test.js @@ -1,4 +1,5 @@ const MatchScheduler = require('../src/network/matchScheduler'); +const { deriveBlockMatchSeed } = MatchScheduler; const MatchQueue = require('../src/network/matchQueue'); describe('MatchScheduler', () => { @@ -6,10 +7,7 @@ describe('MatchScheduler', () => { let manager = null; afterEach(() => { - // The scheduler starts long-lived timers when it drains a queue: a hard-ceiling - // setTimeout (owned by the scheduler) and, per match, an engine tick interval (owned - // by the manager via setEngine). Stop both so Jest can exit instead of leaking handles - // (an unstopped ceiling timer later fires manager.expire() and crashes the process). + // Stop the scheduler tail and every handed-off engine so Jest cannot leak handles. if (scheduler) scheduler.shutdown(); if (manager) for (const engine of manager.engines.values()) engine.stop(); scheduler = null; @@ -50,6 +48,7 @@ describe('MatchScheduler', () => { this.attached.push({ room, entrants, opts }); }, setEngine(id, engine) { engines.set(id, engine); }, + expireBlockDeadlines: jest.fn(), onTick() {}, onFinish() {}, expire() {} @@ -106,4 +105,74 @@ describe('MatchScheduler', () => { expect(manager.engines.size).toBe(1); expect(room.status).toBe('starting'); }); + + test('operator-selected last-alive ruleset creates a real PvP match', async () => { + process.env.MATCH_ENABLED = 'true'; + const queue = makeQueue(); + manager = makeManager(); + scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager, rulesetId: 'last-alive' }); + await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' }); + await queue.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' }); + + await scheduler.onBlock(100); + + const { room } = manager.attached[0]; + expect(room.ruleset.id).toBe('last-alive'); + expect(room.pvpCombat).toBe(true); + expect(room.variant).toBe('pvp'); + expect(room.snapshot().ruleset).toEqual(expect.objectContaining({ + id: 'last-alive', + winCondition: 'last-alive' + })); + }); + + test('block-derived match seeds are reproducible and lock the durable entrant set', () => { + const input = { + blockHash: 'ab'.repeat(32), + blockHeight: 123, + economy: 'crypto_race', + rulesetId: 'race', + queueEntryIds: [12, 3] + }; + const first = deriveBlockMatchSeed(input); + const reordered = deriveBlockMatchSeed({ ...input, queueEntryIds: [3, 12] }); + const changedEntrant = deriveBlockMatchSeed({ ...input, queueEntryIds: [3, 13] }); + + expect(first).toEqual(reordered); + expect(first.seed).toMatch(/^[0-9a-f]{64}$/); + expect(first.derivation.queueEntryIds).toEqual(['3', '12']); + expect(changedEntrant.seed).not.toBe(first.seed); + }); + + test('production paid queues do not drain without a canonical block hash', async () => { + const oldNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + process.env.MATCH_ENABLED = 'true'; + const queue = makeQueue(); + const drain = jest.spyOn(queue, 'drain'); + manager = makeManager(); + scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager }); + try { + await scheduler._drainEconomy('credits_prestige', 100, null); + expect(drain).not.toHaveBeenCalled(); + expect(manager.attached).toHaveLength(0); + } finally { + if (oldNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = oldNodeEnv; + } + }); + + test('each advancing block is offered once to active-room deadline processing', async () => { + process.env.MATCH_ENABLED = 'true'; + const queue = makeQueue(); + manager = makeManager(); + scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager }); + + await scheduler.onBlock(100); + await scheduler.onBlock(100); + await scheduler.onBlock(101); + + // getblockcount is normalized to its zero-based canonical header exactly once. + expect(manager.expireBlockDeadlines.mock.calls).toEqual([[99], [100]]); + }); }); diff --git a/test/noStore.test.js b/test/noStore.test.js new file mode 100644 index 0000000..d1e1d08 --- /dev/null +++ b/test/noStore.test.js @@ -0,0 +1,20 @@ +const noStore = require('../src/middleware/noStore'); + +describe('authenticated API cache policy', () => { + test('sets explicit no-store headers before continuing', () => { + const headers = {}; + const response = { + setHeader: jest.fn((name, value) => { headers[name] = value; }) + }; + const next = jest.fn(); + + noStore({}, response, next); + + expect(headers).toEqual({ + 'Cache-Control': 'no-store, max-age=0', + Pragma: 'no-cache', + Expires: '0' + }); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/orphanGameRecovery.test.js b/test/orphanGameRecovery.test.js new file mode 100644 index 0000000..0639617 --- /dev/null +++ b/test/orphanGameRecovery.test.js @@ -0,0 +1,195 @@ +const SessionManager = require('../src/network/sessionManager'); + +function makeDb(initialGames, initialCredits = 0) { + const games = new Map(initialGames.map(game => [game.id, { ...game }])); + const users = new Map([[7, initialCredits]]); + const refundReasons = new Set(); + + const query = jest.fn(async (sql, params = []) => { + if (/SELECT id FROM games WHERE status = 'active'/i.test(sql)) { + return { + rows: Array.from(games.values()) + .filter(game => game.status === 'active') + .map(game => ({ id: game.id })) + }; + } + if (/FROM games WHERE id = \$1 FOR UPDATE/i.test(sql)) { + const game = games.get(params[0]); + return { rows: game ? [{ ...game }] : [] }; + } + if (/SELECT 1 FROM credit_transactions/i.test(sql)) { + return { rows: refundReasons.has(params[1]) ? [{}] : [] }; + } + if (/UPDATE users SET credits = credits \+/i.test(sql)) { + const next = (users.get(params[1]) || 0) + Number(params[0]); + users.set(params[1], next); + return { rows: [{ credits: next }], rowCount: 1 }; + } + if (/INSERT INTO credit_transactions/i.test(sql)) { + refundReasons.add(params[2]); + return { rows: [], rowCount: 1 }; + } + if (/UPDATE games SET status = \$1/i.test(sql)) { + const game = games.get(params[1]); + if (game) game.status = params[0]; + return { rows: [], rowCount: game ? 1 : 0 }; + } + return { rows: [], rowCount: 0 }; + }); + + return { + query, + withTransaction: jest.fn(async callback => callback({ query })), + credits: userId => users.get(userId), + game: id => games.get(id), + refundReasons + }; +} + +function manager(db, currentConfiguredCost = 99) { + return new SessionManager({ + db, + debugManager: { CONSOLE_LOGGING: false }, + gameModeManager: { creditsPerGameCost: currentConfiguredCost } + }); +} + +describe('orphan game recovery debit evidence', () => { + test('does not mint a refund for a pre-created active row when entry charging never committed', async () => { + const db = makeDb([{ + id: 1, + user_id: 7, + game_mode: 'PAID_CREDITS', + payment_id: null, + status: 'active', + entry_consumed_at: null, + entry_credits_spent: 5 + }], 10); + + const result = await manager(db).recoverOrphanedGames(); + + expect(result).toEqual({ + ok: true, + scanned: 1, + finalized: 1, + refunded: 0, + creditsRefunded: 0, + unresolved: [] + }); + expect(db.credits(7)).toBe(10); + expect(db.game(1).status).toBe('expired'); + expect(db.refundReasons.size).toBe(0); + }); + + test('restores the immutable debit snapshot, not the current configured entry cost', async () => { + const db = makeDb([{ + id: 2, + user_id: 7, + game_mode: 'PAID_CREDITS', + payment_id: null, + status: 'active', + entry_consumed_at: new Date('2026-01-01T00:00:00Z'), + entry_credits_spent: 3 + }], 4); + const recovery = manager(db, 99); + + const first = await recovery.recoverOrphanedGames(); + const second = await recovery.recoverOrphanedGames(); + + expect(first).toEqual({ + ok: true, + scanned: 1, + finalized: 1, + refunded: 1, + creditsRefunded: 3, + unresolved: [] + }); + expect(second).toEqual({ + ok: true, + scanned: 0, + finalized: 0, + refunded: 0, + creditsRefunded: 0, + unresolved: [] + }); + expect(db.credits(7)).toBe(7); + expect(db.game(2).status).toBe('refunded'); + expect(db.refundReasons).toEqual(new Set(['orphan_game_refunded:2'])); + }); + + test('does not silently convert a linked direct payment into credits', async () => { + const db = makeDb([{ + id: 3, + user_id: 7, + game_mode: 'PAID_SINGLE', + payment_id: 77, + status: 'active', + entry_consumed_at: new Date('2026-01-01T00:00:00Z'), + entry_credits_spent: null + }], 1); + + const result = await manager(db).recoverOrphanedGames(); + + expect(result.refunded).toBe(0); + expect(db.credits(7)).toBe(1); + expect(db.game(3).status).toBe('expired'); + }); + + test('fails closed with explicit scan semantics when the orphan query fails', async () => { + const db = { + query: jest.fn().mockRejectedValue(new Error('database unavailable')), + withTransaction: jest.fn() + }; + + await expect(manager(db).recoverOrphanedGames()).rejects.toMatchObject({ + code: 'FINANCIAL_RECOVERY_INCOMPLETE', + recovery: { + ok: false, + scope: 'orphaned_solo_games', + scanFailed: true, + scanned: 0, + resolved: 0, + unresolved: [] + } + }); + expect(db.withTransaction).not.toHaveBeenCalled(); + }); + + test('finishes other rows but rejects startup while any orphan remains unresolved', async () => { + const db = makeDb([{ + id: 4, + user_id: 7, + game_mode: 'PAID_CREDITS', + status: 'active', + entry_consumed_at: new Date('2026-01-01T00:00:00Z'), + entry_credits_spent: 2 + }, { + id: 5, + user_id: 7, + game_mode: 'FREE', + status: 'active', + entry_consumed_at: null, + entry_credits_spent: null + }], 0); + const baseTransaction = db.withTransaction; + let attempts = 0; + db.withTransaction = jest.fn(async callback => { + attempts += 1; + if (attempts === 1) throw new Error('refund transaction failed'); + return baseTransaction(callback); + }); + + await expect(manager(db).recoverOrphanedGames()).rejects.toMatchObject({ + code: 'FINANCIAL_RECOVERY_INCOMPLETE', + recovery: { + scope: 'orphaned_solo_games', + scanFailed: false, + scanned: 2, + resolved: 1, + unresolved: [{ type: 'game', id: '4' }] + } + }); + expect(db.game(4).status).toBe('active'); + expect(db.game(5).status).toBe('expired'); + }); +}); diff --git a/test/paidAcknowledgementFlows.test.js b/test/paidAcknowledgementFlows.test.js new file mode 100644 index 0000000..3345506 --- /dev/null +++ b/test/paidAcknowledgementFlows.test.js @@ -0,0 +1,332 @@ +const SocketHandlers = require('../src/network/socketHandlers'); +const PaymentHandlers = require('../src/network/paymentHandlers'); +const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy'); + +const ENV_KEYS = [ + 'NODE_ENV', + 'LEGAL_POLICY_VERSION', + 'PAID_ACKNOWLEDGEMENT_REQUIRED', + 'MATCH_ENABLED', + 'MATCH_CRYPTO_RACE_ENABLED', + 'MATCH_PAYOUTS_ENABLED' +]; + +function manager(overrides = {}) { + return { + cryptoType: 'WOW', + currencyLabel: 'WOW', + network: 'mainnet', + isTestNetwork: false, + paymentsEnabled: false, + directModeEnabled: false, + creditsModeEnabled: false, + freePlayEnabled: true, + payoutsEnabled: false, + gameMode: 'PAID_CREDITS', + isPayoutEnabledForMode: () => false, + ...overrides + }; +} + +function acknowledgement(gameModeManager, overrides = {}) { + const disclosure = buildCommerceDisclosure(gameModeManager, process.env); + return { + policyVersion: disclosure.policyVersion, + ageEligible: true, + termsRead: true, + riskAccepted: true, + testnetUnderstood: false, + ...overrides + }; +} + +function socket() { + return { + id: 'socket-paid-flow', + handshake: { address: '127.0.0.1', headers: {} }, + emit: jest.fn() + }; +} + +function baseContext(gameModeManager = manager()) { + return { + gameModeManager, + sessionManager: null, + broadcastManager: { sendStatusUpdate: jest.fn() }, + rateLimiter: { + checkLimit: jest.fn().mockResolvedValue({ allowed: true, retryAfter: 0 }), + recordAttempt: jest.fn().mockResolvedValue(undefined) + }, + _consumeFairnessAttempt: jest.fn().mockReturnValue({ proofVersion: 2 }), + activeGames: new Map(), + connectionHandler: { getUserBySocket: jest.fn().mockReturnValue({ serverId: 'socket-paid-flow' }) }, + debugManager: { getCurrentBlockHeight: jest.fn().mockReturnValue(100), CONSOLE_LOGGING: false }, + io: { to: jest.fn(() => ({ emit: jest.fn() })) } + }; +} + +describe('current paid acknowledgement gates every value-consuming socket flow', () => { + let previous; + + beforeEach(() => { + previous = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]])); + process.env.NODE_ENV = 'test'; + process.env.LEGAL_POLICY_VERSION = 'paid-flow-current-v1'; + process.env.PAID_ACKNOWLEDGEMENT_REQUIRED = 'false'; + process.env.MATCH_ENABLED = 'true'; + delete process.env.MATCH_CRYPTO_RACE_ENABLED; + delete process.env.MATCH_PAYOUTS_ENABLED; + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + }); + + test('invoice request rejects a stale policy before fairness, wallet, or database work', async () => { + const client = socket(); + const ctx = baseContext(); + ctx.paymentHandlers = { handlePaymentRequest: jest.fn() }; + + await SocketHandlers.prototype.handlePaymentRequest.call(ctx, client, { + type: 'credits_package', + legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' }) + }); + + expect(ctx.paymentHandlers.handlePaymentRequest).not.toHaveBeenCalled(); + expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled(); + expect(client.emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({ + code: 'PAID_ACK_VERSION', policyVersion: 'paid-flow-current-v1' + })); + }); + + test('invoice request accepts the current policy even with intake state reported off', async () => { + const client = socket(); + const ctx = baseContext(); + ctx.paymentHandlers = { handlePaymentRequest: jest.fn().mockResolvedValue(undefined) }; + const ack = acknowledgement(ctx.gameModeManager); + + await SocketHandlers.prototype.handlePaymentRequest.call(ctx, client, { + type: 'credits_package', legalAcknowledgement: ack + }); + + expect(ctx.paymentHandlers.handlePaymentRequest).toHaveBeenCalledWith(client, { + type: 'credits_package', legalAcknowledgement: ack + }); + }); + + test('auto-start rejects stale policy before consuming a fairness offer or credit', async () => { + const client = socket(); + const ctx = baseContext(); + ctx.gameManager = { createGameForUser: jest.fn() }; + + await SocketHandlers.prototype.handleAutoStart.call(ctx, client, { + legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' }) + }); + + expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled(); + expect(ctx.gameManager.createGameForUser).not.toHaveBeenCalled(); + expect(client.emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({ code: 'PAID_ACK_VERSION' })); + }); + + test('auto-start with current policy reaches existing-credit consumption while intake is off', async () => { + const client = socket(); + const gameModeManager = manager({ + canUserStartGame: jest.fn().mockResolvedValue({ + allowed: true, + effectiveMode: 'PAID_CREDITS', + useCredits: true + }), + requiresPayoutAddressForMode: jest.fn().mockReturnValue(false), + processGameStart: jest.fn().mockResolvedValue({ success: true, creditsRemaining: 4 }) + }); + const ctx = baseContext(gameModeManager); + const game = { + id: 'game-1', + dbId: null, + getState: jest.fn().mockReturnValue({}), + getProofCommitment: jest.fn().mockReturnValue('commitment') + }; + ctx.gameManager = { createGameForUser: jest.fn().mockResolvedValue(game) }; + + await SocketHandlers.prototype.handleAutoStart.call(ctx, client, { + legalAcknowledgement: acknowledgement(gameModeManager) + }); + + expect(gameModeManager.canUserStartGame).toHaveBeenCalledWith(client.id); + expect(gameModeManager.processGameStart).toHaveBeenCalledWith(client.id, game.id, { forceFree: false }); + }); + + test('queued-credit entry rejects stale policy before queue eligibility or debit', async () => { + const client = socket(); + const ctx = baseContext(); + ctx.queueHandler = { handleGameQueue: jest.fn() }; + + await SocketHandlers.prototype.handleJoinQueue.call(ctx, client, { + legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' }) + }); + + expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled(); + expect(ctx.queueHandler.handleGameQueue).not.toHaveBeenCalled(); + }); + + test('queued-credit entry accepts the current policy while invoice intake is off', async () => { + const client = socket(); + const ctx = baseContext(); + ctx.queueHandler = { handleGameQueue: jest.fn().mockResolvedValue(undefined) }; + ctx.queueManager = { getPlayerIndex: jest.fn().mockReturnValue(-1) }; + + await SocketHandlers.prototype.handleJoinQueue.call(ctx, client, { + legalAcknowledgement: acknowledgement(ctx.gameModeManager) + }); + + expect(ctx.queueHandler.handleGameQueue).toHaveBeenCalledWith( + client, + expect.any(Function), + expect.objectContaining({ fairnessProof: { proofVersion: 2 } }) + ); + }); + + test('early entry rejects stale policy before queue eligibility or credit debit', async () => { + const client = socket(); + const ctx = baseContext(); + ctx.queueHandler = { handleEarlyEntry: jest.fn() }; + + await SocketHandlers.prototype.handleEarlyEntry.call(ctx, client, { + legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' }) + }); + + expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled(); + expect(ctx.queueHandler.handleEarlyEntry).not.toHaveBeenCalled(); + expect(client.emit).toHaveBeenCalledWith('early_entry_error', expect.objectContaining({ code: 'PAID_ACK_VERSION' })); + }); + + test('early entry accepts current policy while invoice intake is off', async () => { + const client = socket(); + const ctx = baseContext(); + ctx.queueHandler = { handleEarlyEntry: jest.fn().mockResolvedValue({ success: true }) }; + + await SocketHandlers.prototype.handleEarlyEntry.call(ctx, client, { + legalAcknowledgement: acknowledgement(ctx.gameModeManager) + }); + + expect(ctx.queueHandler.handleEarlyEntry).toHaveBeenCalledWith( + client, + expect.any(Function), + { fairnessProof: { proofVersion: 2 } } + ); + }); + + test('paid PvP rejects stale policy before a credit or ticket is escrowed', async () => { + const client = socket(); + const ctx = baseContext(); + ctx.matchQueue = { + isEnabled: jest.fn().mockReturnValue(true), + enqueue: jest.fn(), + leave: jest.fn() + }; + ctx._resolveMatchSession = jest.fn().mockResolvedValue({ + userId: 1, socketId: client.id, sessionToken: 'session' + }); + + await SocketHandlers.prototype._handleMatchQueue.call(ctx, client, { + action: 'join', economy: 'crypto_race', + legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' }) + }); + + expect(ctx.matchQueue.enqueue).not.toHaveBeenCalled(); + expect(client.emit).toHaveBeenCalledWith('match_error', expect.objectContaining({ code: 'PAID_ACK_VERSION' })); + }); + + test.each(['credits_prestige', 'crypto_race'])( + 'paid PvP %s accepts the current policy while new invoice intake is off', + async economy => { + const client = socket(); + const ctx = baseContext(); + ctx.matchQueue = { + isEnabled: jest.fn().mockReturnValue(true), + enqueue: jest.fn().mockResolvedValue({ success: true, position: 1 }), + leave: jest.fn() + }; + ctx._resolveMatchSession = jest.fn().mockResolvedValue({ + userId: 1, socketId: client.id, sessionToken: 'session' + }); + + await SocketHandlers.prototype._handleMatchQueue.call(ctx, client, { + action: 'join', economy, + legalAcknowledgement: acknowledgement(ctx.gameModeManager) + }); + + expect(ctx.matchQueue.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ userId: 1, economy }), + { economy } + ); + } + ); + + test('continuation cache is canonical, capped, expires, and clears on failed selection', async () => { + const gameModeManager = manager({ + gameMode: 'FREE', + getCosmeticProduct: jest.fn().mockReturnValue(null) + }); + const handler = new PaymentHandlers({ + io: { to: () => ({ emit: jest.fn() }) }, + gameModeManager, + walletService: {}, + debugManager: { CONSOLE_LOGGING: false }, + queueManager: {}, + broadcastManager: { sendStatusUpdate: jest.fn() }, + sessionManager: null + }); + const ack = acknowledgement(gameModeManager); + try { + handler._rememberPendingCommerceAcknowledgement('canonical', { + ...ack, + ignored: 'never retained' + }); + expect(handler._getPendingCommerceAcknowledgement('canonical')).toEqual(ack); + expect(Object.keys(handler._getPendingCommerceAcknowledgement('canonical'))).toEqual([ + 'policyVersion', 'ageEligible', 'termsRead', 'riskAccepted', 'testnetUnderstood' + ]); + + for (let index = 0; index < 300; index += 1) { + handler._rememberPendingCommerceAcknowledgement(`socket-${index}`, ack); + } + expect(handler.pendingCommerceAcknowledgement.size).toBe(256); + + const expiresAt = handler.pendingCommerceAcknowledgement.get('socket-299').expiresAt; + const now = jest.spyOn(Date, 'now').mockReturnValue(expiresAt); + expect(handler._getPendingCommerceAcknowledgement('socket-299')).toBeNull(); + now.mockRestore(); + + handler._rememberPendingCommerceAcknowledgement('failed', ack); + handler.pendingEntryFairness.set('failed', { proofVersion: 2 }); + await handler.createAndShowPaymentRequest({ id: 'failed' }, { + paymentType: 'bogus', + legalAcknowledgement: ack + }); + expect(handler._getPendingCommerceAcknowledgement('failed')).toBeNull(); + expect(handler.pendingEntryFairness.has('failed')).toBe(false); + } finally { + handler.dispose(); + } + }); + + test('disconnect clears server-side acknowledgement and fairness continuation state', () => { + const client = socket(); + const clearPendingCommerceAcknowledgement = jest.fn(); + const pendingEntryFairness = new Map([[client.id, { proofVersion: 2 }]]); + const ctx = { + fairnessOffers: { discardSocket: jest.fn() }, + paymentHandlers: { clearPendingCommerceAcknowledgement, pendingEntryFairness }, + connectionHandler: { handleDisconnect: jest.fn() } + }; + + SocketHandlers.prototype.handleDisconnect.call(ctx, client); + + expect(clearPendingCommerceAcknowledgement).toHaveBeenCalledWith(client.id); + expect(pendingEntryFairness.has(client.id)).toBe(false); + }); +}); diff --git a/test/paidFairnessDurability.test.js b/test/paidFairnessDurability.test.js new file mode 100644 index 0000000..07b8311 --- /dev/null +++ b/test/paidFairnessDurability.test.js @@ -0,0 +1,233 @@ +const GameModeManager = require('../src/game/gameModeManager'); +const { hashSeed } = require('../src/game/provablyFair'); + +const ENV_KEYS = [ + 'NODE_ENV', 'REQUIRE_PAID_FAIRNESS_V2', 'GAME_MODE', 'SINGLE_GAME_PRICE', + 'CREDITS_PACKAGE_PRICE', 'CREDITS_PER_GAME', 'CREDITS_PAYOUT_ENABLED', + 'CREDITS_PAYOUTS_ENABLED' +]; + +function configManager() { + return { + getConfig: () => ({ + paymentsEnabled: true, + currency: { symbol: 'XMR', decimals: 12 }, + modes: { + direct: { enabled: true, price: 1000n }, + credits: { enabled: false, creditsPerGame: 1, packages: [] } + }, + payouts: { + enabled: false, + rules: { + direct: { enabled: false, multipliers: { escape: 2, escapeWithTreasure: 3 } }, + credits: { enabled: false, multipliers: { escape: 2, escapeWithTreasure: 3 } } + } + }, + preferences: { preferCreditsFirst: false } + }), + getLegacyGameMode: () => 'PAID_SINGLE', + eventBus: { on: jest.fn() } + }; +} + +function makeManager(db, provider = null) { + const providers = provider ? { getProvider: jest.fn(() => provider) } : { getProvider: jest.fn(() => null) }; + return new GameModeManager( + db, + { createPaymentRequest: jest.fn() }, + { CONSOLE_LOGGING: false }, + configManager(), + providers + ); +} + +describe('paid fairness invoice durability', () => { + let previousEnv; + let logSpy; + + beforeEach(() => { + previousEnv = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]])); + process.env.NODE_ENV = 'production'; + process.env.REQUIRE_PAID_FAIRNESS_V2 = 'false'; + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + for (const key of ENV_KEYS) { + if (previousEnv[key] === undefined) delete process.env[key]; + else process.env[key] = previousEnv[key]; + } + }); + + test('production cannot disable v2 and a new manager restores the invoice-bound proof', async () => { + const serverSeed = '12'.repeat(32); + const proof = { + proofVersion: 2, + offerId: 'offer-before-payment', + offerIssuedAt: 1700000000123, + serverSeed, + commitment: hashSeed(serverSeed), + clientSeed: '34'.repeat(32) + }; + let insertParams; + let insertSql; + const expiresAt = new Date('2030-01-01T00:00:00.000Z'); + const db = { + query: jest.fn(async (sql, params = []) => { + if (/SELECT \* FROM users WHERE id/i.test(sql)) return { rows: [{ id: 7 }] }; + if (/UPDATE payments SET status = 'expired'/i.test(sql)) return { rows: [] }; + if (/INSERT INTO payments/i.test(sql)) { + insertSql = sql; + insertParams = params; + return { rows: [{ id: 55, expires_at: expiresAt }] }; + } + return { rows: [] }; + }) + }; + const provider = { + createInvoice: jest.fn(async () => ({ + address: 'stagenet-address', + addressIndex: 9, + invoiceId: 'invoice-55', + expiresAt + })) + }; + const firstProcess = makeManager(db, provider); + + expect(firstProcess._requiresPaidFairnessV2()).toBe(true); + const request = await firstProcess.createPaymentRequest('old-socket', 'single_game', { + userId: 7, + reuseExisting: false, + fairnessProof: proof + }); + expect(request.fairnessProof).toEqual(proof); + // PostgreSQL's extended protocol rejects one placeholder when it is inferred as both + // VARCHAR (the destination column) and TEXT (a CASE expression). Keep both uses explicit + // and identical; this is exercised end-to-end by the stagenet invoice canary. + expect(insertSql).toMatch(/\$12::varchar\(64\)/i); + expect(insertSql).toMatch(/CASE WHEN \$12::varchar\(64\) IS NULL/i); + expect(insertParams.slice(10, 16)).toEqual([ + 2, + proof.offerId, + new Date(proof.offerIssuedAt), + proof.commitment, + proof.serverSeed, + proof.clientSeed + ]); + + // Simulate a process restart: only fields reconstructed from the payment row remain. + const durableRow = { + id: 55, + subaddress: 'stagenet-address', + expected_amount: '1000', + payment_type: 'single_game', + status: 'pending', + expires_at: expiresAt, + fairness_proof_version: insertParams[10], + fairness_offer_id: insertParams[11], + fairness_offer_issued_at: insertParams[12], + fairness_commitment: insertParams[13], + fairness_server_seed: insertParams[14], + fairness_client_seed: insertParams[15], + fairness_bound_at: new Date(), + fairness_consumed_at: null + }; + const restarted = makeManager({ query: jest.fn() }); + expect(restarted._mapPaymentRowToRequest(durableRow, 'single_game', null).fairnessProof) + .toEqual(proof); + }); + + test('entry claim atomically verifies and consumes the exact invoice proof', async () => { + const serverSeed = 'ab'.repeat(32); + const offerIssuedAt = 1700000000999; + const payment = { + id: 90, + user_id: 7, + fairness_proof_version: 2, + fairness_offer_id: 'restart-proof', + fairness_offer_issued_at: new Date(offerIssuedAt), + fairness_commitment: hashSeed(serverSeed), + fairness_server_seed: serverSeed, + fairness_client_seed: 'cd'.repeat(32), + fairness_bound_at: new Date(), + fairness_consumed_at: null + }; + const calls = []; + const client = { + query: jest.fn(async (sql, params = []) => { + calls.push({ sql, params }); + if (/SELECT \* FROM payments/i.test(sql)) return { rows: [payment] }; + if (/SELECT proof_version/i.test(sql)) { + return { rows: [{ + proof_version: 2, + fairness_offer_id: payment.fairness_offer_id, + fairness_offer_issued_at: payment.fairness_offer_issued_at, + proof_commitment: payment.fairness_commitment, + server_seed: payment.fairness_server_seed, + client_seed: payment.fairness_client_seed + }] }; + } + if (/UPDATE games/i.test(sql)) return { rows: [{ id: 1 }], rowCount: 1 }; + if (/UPDATE payments[\s\S]*fairness_consumed_at/i.test(sql)) { + return { rows: [{ id: payment.id }], rowCount: 1 }; + } + if (/UPDATE users/i.test(sql)) return { rows: [], rowCount: 1 }; + return { rows: [], rowCount: 0 }; + }) + }; + const db = { withTransaction: jest.fn(async callback => callback(client)) }; + const manager = makeManager(db); + + await expect(manager._processGameStartWithPayment( + { id: 7, payout_address: null }, + { id: payment.id }, + 'game-proof-seed' + )).resolves.toEqual(expect.objectContaining({ success: true, paymentId: payment.id })); + + const gameUpdate = calls.find(call => /UPDATE games/i.test(call.sql)); + const paymentConsume = calls.find(call => /UPDATE payments[\s\S]*fairness_consumed_at/i.test(call.sql)); + expect(gameUpdate.sql).toContain('entry_consumed_at = NOW()'); + expect(paymentConsume.sql).toContain('fairness_consumed_at IS NULL'); + }); + + test('tampering with the persisted game proof aborts before entry consumption', async () => { + const serverSeed = 'ef'.repeat(32); + const payment = { + id: 91, + user_id: 7, + fairness_proof_version: 2, + fairness_offer_id: 'tamper-proof', + fairness_offer_issued_at: new Date(1700000001234), + fairness_commitment: hashSeed(serverSeed), + fairness_server_seed: serverSeed, + fairness_client_seed: '01'.repeat(32), + fairness_bound_at: new Date(), + fairness_consumed_at: null + }; + const client = { + query: jest.fn(async sql => { + if (/SELECT \* FROM payments/i.test(sql)) return { rows: [payment] }; + if (/SELECT proof_version/i.test(sql)) { + return { rows: [{ + proof_version: 2, + fairness_offer_id: payment.fairness_offer_id, + fairness_offer_issued_at: payment.fairness_offer_issued_at, + proof_commitment: payment.fairness_commitment, + server_seed: payment.fairness_server_seed, + client_seed: 'ff'.repeat(32) + }] }; + } + return { rows: [], rowCount: 0 }; + }) + }; + const manager = makeManager({ withTransaction: jest.fn(async callback => callback(client)) }); + + await expect(manager._processGameStartWithPayment( + { id: 7, payout_address: null }, + { id: payment.id }, + 'tampered-game' + )).rejects.toMatchObject({ code: 'PAYMENT_FAIRNESS_MISMATCH' }); + expect(client.query.mock.calls.some(([sql]) => /UPDATE games/i.test(sql))).toBe(false); + }); +}); diff --git a/test/paidPvpFutureBlockFairness.test.js b/test/paidPvpFutureBlockFairness.test.js new file mode 100644 index 0000000..d762343 --- /dev/null +++ b/test/paidPvpFutureBlockFairness.test.js @@ -0,0 +1,642 @@ +'use strict'; + +const crypto = require('crypto'); +const MatchScheduler = require('../src/network/matchScheduler'); +const MatchQueue = require('../src/network/matchQueue'); +const SocketHandlers = require('../src/network/socketHandlers'); +const { + PAID_SEED_VERSION, + buildPaidEntrantFreeze, + deriveFutureBlockMatchSeed +} = require('../src/network/matchFairness'); + +const FUTURE_HASH = 'ab'.repeat(32); + +function entrant(queueEntryId, userId = queueEntryId) { + return { + queueEntryId, + userId, + socketId: `socket-${userId}`, + sessionToken: `session-${userId}`, + createdAt: queueEntryId + }; +} + +class DurablePaidQueueDouble { + constructor(entries = []) { + this.initialized = true; + this.queues = { + free: [], + credits_prestige: entries.slice(), + crypto_race: [] + }; + this.frozen = []; + this.freezeCalls = []; + this.cancelled = []; + } + + async refundUnavailableQueues() {} + isEconomyAvailable() { return true; } + isFinancialRecoveryReady() { return true; } + + async freezePaidMatch(spec) { + const queue = this.queues[spec.economy]; + if (!queue || queue.length < spec.minPlayers) return null; + const entries = queue.slice(0, spec.maxPlayers); + const freeze = buildPaidEntrantFreeze({ + freezeBlockHeight: spec.freezeBlockHeight, + targetBlockHeight: spec.targetBlockHeight, + economy: spec.economy, + rulesetId: spec.rulesetId, + queueEntryIds: entries.map(entry => entry.queueEntryId) + }); + const group = { + id: crypto.randomUUID(), + economy: spec.economy, + variant: spec.variant, + rulesetId: spec.rulesetId, + difficultyPreset: spec.difficultyPreset, + maxPlayers: spec.maxPlayers, + targetBlockHeight: spec.targetBlockHeight, + freezeCommitment: freeze.freezeCommitment, + precommitTipHeight: null, + precommitVerifiedAt: null, + freeze, + entries, + status: 'pending' + }; + this.freezeCalls.push(spec); + this.frozen.push(group); + this.queues[spec.economy] = queue.slice(entries.length); + return group; + } + + async verifyFrozenPrecommit(id, commitment, tip) { + const group = this.frozen.find(candidate => candidate.id === id + && candidate.status === 'pending' + && candidate.freezeCommitment === commitment + && candidate.targetBlockHeight > tip); + if (!group) return { verified: false }; + group.precommitTipHeight = tip; + group.precommitVerifiedAt = new Date().toISOString(); + return { + verified: true, + observedTipHeight: tip, + verifiedAt: group.precommitVerifiedAt + }; + } + + async listFrozenPaidMatches(throughBlockHeight) { + return this.frozen.filter(group => group.status === 'pending' + && (throughBlockHeight == null || group.targetBlockHeight <= throughBlockHeight)); + } + + noteFrozenStarted(id) { + const group = this.frozen.find(candidate => candidate.id === id); + if (group) group.status = 'starting'; + } + + async cancelFrozenMatch(id) { + const group = this.frozen.find(candidate => candidate.id === id && candidate.status === 'pending'); + if (!group) return { claimed: false, resolved: true, refunded: 0 }; + group.status = 'cancelled'; + this.cancelled.push(...group.entries.map(entry => entry.queueEntryId)); + return { claimed: true, resolved: true, refunded: group.entries.length }; + } + + async refundEntries(entries) { + const ids = new Set((entries || []).map(entry => String(entry.queueEntryId))); + const group = this.frozen.find(candidate => candidate.entries.some( + entry => ids.has(String(entry.queueEntryId)) + )); + if (group && group.status !== 'cancelled') { + group.status = 'cancelled'; + this.cancelled.push(...group.entries.map(entry => entry.queueEntryId)); + } + return { attempted: ids.size, resolved: ids.size, failed: 0 }; + } + + async drain(economy, maxPlayers, minPlayers) { + const queue = this.queues[economy] || []; + if (queue.length < minPlayers) return null; + const entries = queue.slice(0, maxPlayers); + this.queues[economy] = queue.slice(entries.length); + return { entries }; + } +} + +function managerDouble() { + return { + attached: [], + engines: new Map(), + db: null, + attach(room, entries) { this.attached.push({ room, entries }); }, + setEngine(id, engine) { this.engines.set(id, engine); }, + onTick() {}, + onFinish() {} + }; +} + +describe('paid PvP future-block entrant freeze', () => { + const schedulers = []; + + beforeEach(() => { + process.env.MATCH_ENABLED = 'true'; + }); + + afterEach(async () => { + await Promise.all(schedulers.splice(0).map(scheduler => scheduler.shutdown())); + delete process.env.MATCH_ENABLED; + }); + + function schedulerFor(queue, manager, provider, countProvider) { + const scheduler = new MatchScheduler({ + matchQueue: queue, + matchManager: manager, + blockEntropyProvider: provider, + blockCountProvider: countProvider, + paidEntropyDelayBlocks: 2, + paidEntropyConfirmations: 2, + rulesetId: 'last-alive', + maxPlayers: 2, + countdownMs: 10 ** 9 + }); + schedulers.push(scheduler); + return scheduler; + } + + test('delay=2/conf=2 freezes FIFO at H, reads no hash through target, then starts from the stable exact header', async () => { + const queue = new DurablePaidQueueDouble([entrant(12), entrant(3), entrant(99)]); + const manager = managerDouble(); + let daemonCount = 101; + const entropyCalls = []; + const scheduler = schedulerFor(queue, manager, async height => { + entropyCalls.push(height); + if (height === 100) throw new Error('the freeze-block hash must never be requested'); + return height === 102 + ? { block_header: { height, hash: FUTURE_HASH } } + : null; + }, async () => daemonCount); + + // DebugManager emits block count 101, whose actual canonical tip header is height 100. + await scheduler.onBlock(101); + + expect(entropyCalls).toEqual([]); + expect(queue.frozen).toHaveLength(1); + expect(queue.frozen[0].entries.map(entry => entry.queueEntryId)).toEqual([12, 3]); + expect(queue.frozen[0].freeze.queueEntryIds).toEqual(['3', '12']); + expect(manager.attached).toHaveLength(0); + + daemonCount = 102; + await scheduler.onBlock(102); // H+1 + daemonCount = 103; + await scheduler.onBlock(103); // target exists, but only confirmation one + expect(entropyCalls).toEqual([]); + expect(manager.attached).toHaveLength(0); + + daemonCount = 104; + await scheduler.onBlock(104); // target+1: confirmation two + + expect(entropyCalls).toEqual([102, 102, 102]); + expect(manager.attached).toHaveLength(1); + const { room, entries } = manager.attached[0]; + const expected = deriveFutureBlockMatchSeed({ + blockHash: FUTURE_HASH, + blockHeight: 102, + freeze: queue.frozen[0].freeze + }); + expect(entries.map(entry => entry.queueEntryId)).toEqual([12, 3]); + expect(room.seed).toBe(expected.seed); + expect(room.seedDerivation).toEqual(expect.objectContaining(expected.derivation)); + expect(room.seedDerivation.version).toBe(PAID_SEED_VERSION); + expect(room.seedDerivation.blockHash).toBe(FUTURE_HASH); + expect(room.seedDerivation).toEqual(expect.objectContaining({ + entropyDelayBlocks: 2, + entropyConfirmations: 2, + minimumConfirmedTipHeight: 103, + precommitTipHeight: 100 + })); + expect(room.fairnessProof(false)).toEqual(expect.objectContaining({ + blockHeight: 102, + blockHash: FUTURE_HASH, + freezeBlockHeight: 100, + targetBlockHeight: 102, + entropyDelayBlocks: 2, + entropyConfirmations: 2, + minimumConfirmedTipHeight: 103, + precommitTipHeight: 100 + })); + }); + + test('a delayed freeze commit that crosses its target performs zero hash/attach work and refunds once', async () => { + const queue = new DurablePaidQueueDouble([entrant(1), entrant(2)]); + const manager = managerDouble(); + manager.matchPayoutService = { collectEntryTickets: jest.fn() }; + const entropy = jest.fn(); + const scheduler = schedulerFor(queue, manager, entropy, async () => 203); + + await scheduler.onBlock(101); // freeze H=100, target=102, post-commit tip already 202 + + expect(manager.attached).toHaveLength(0); + expect(manager.matchPayoutService.collectEntryTickets).not.toHaveBeenCalled(); + expect(entropy).not.toHaveBeenCalled(); + expect(queue.freezeCalls).toHaveLength(1); + expect(queue.cancelled).toEqual([1, 2]); + await scheduler.onBlock(101); + expect(queue.cancelled).toEqual([1, 2]); + }); + + test('strict count failure cannot activate and the due unverified freeze is refunded without a hash read', async () => { + const queue = new DurablePaidQueueDouble([entrant(21), entrant(22)]); + const manager = managerDouble(); + const entropy = jest.fn(); + const scheduler = schedulerFor(queue, manager, entropy, async () => { + throw new Error('daemon unavailable'); + }); + + await scheduler.onBlock(301); + await scheduler.onBlock(302); + await scheduler.onBlock(303); + + expect(manager.attached).toHaveLength(0); + expect(entropy).not.toHaveBeenCalled(); + expect(queue.cancelled).toEqual([21, 22]); + }); + + test('a header reorg between the two pre-activation reads refunds without gameplay', async () => { + const queue = new DurablePaidQueueDouble([entrant(41), entrant(42)]); + const manager = managerDouble(); + let daemonCount = 201; + const hashes = [FUTURE_HASH, 'cd'.repeat(32)]; + const entropy = jest.fn(async height => ({ + block_header: { height, hash: hashes.shift() || hashes.at(-1) } + })); + const scheduler = schedulerFor(queue, manager, entropy, async () => daemonCount); + await scheduler.onBlock(201); // H=200, target=202 + daemonCount = 204; + await scheduler.onBlock(204); + + expect(manager.attached).toHaveLength(0); + expect(manager.engines.size).toBe(0); + expect(queue.cancelled).toEqual([41, 42]); + expect(entropy).toHaveBeenCalledTimes(2); + }); + + test('a daemon response for the wrong height cannot activate the paid freeze', async () => { + const queue = new DurablePaidQueueDouble([entrant(45), entrant(46)]); + const manager = managerDouble(); + let daemonCount = 211; + const entropy = jest.fn(async () => ({ + block_header: { height: 999, hash: FUTURE_HASH } + })); + const scheduler = schedulerFor(queue, manager, entropy, async () => daemonCount); + await scheduler.onBlock(211); // H=210, target=212 + daemonCount = 214; + await scheduler.onBlock(214); + + expect(manager.attached).toHaveLength(0); + expect(queue.frozen[0].status).toBe('pending'); + expect(entropy).toHaveBeenCalledTimes(1); + }); + + test('a post-activation reorg aborts/refunds before engine attach', async () => { + const queue = new DurablePaidQueueDouble([entrant(51), entrant(52)]); + const manager = managerDouble(); + manager.matchPayoutService = { collectEntryTickets: jest.fn() }; + let daemonCount = 301; + const hashes = [FUTURE_HASH, FUTURE_HASH, 'ef'.repeat(32)]; + const entropy = jest.fn(async height => ({ + block_header: { height, hash: hashes.shift() } + })); + const scheduler = schedulerFor(queue, manager, entropy, async () => daemonCount); + await scheduler.onBlock(301); + daemonCount = 304; + await scheduler.onBlock(304); + + expect(manager.attached).toHaveLength(0); + expect(manager.matchPayoutService.collectEntryTickets).not.toHaveBeenCalled(); + expect(manager.engines.size).toBe(0); + expect(queue.cancelled).toEqual([51, 52]); + expect(entropy).toHaveBeenCalledTimes(3); + }); + + test('legacy v1 pending freezes are cancelled before any target lookup', async () => { + const queue = new DurablePaidQueueDouble(); + const entries = [entrant(61), entrant(62)]; + queue.frozen.push({ + id: crypto.randomUUID(), + economy: 'credits_prestige', + rulesetId: 'last-alive', + targetBlockHeight: 401, + freezeCommitment: '0'.repeat(64), + freeze: { + version: 'future-block-freeze-v1', freezeBlockHeight: 400, + targetBlockHeight: 401, economy: 'credits_prestige', rulesetId: 'last-alive', + queueEntryIds: ['61', '62'], freezeCommitment: '0'.repeat(64) + }, + entries, + status: 'pending' + }); + const entropy = jest.fn(); + const scheduler = schedulerFor(queue, managerDouble(), entropy, async () => 403); + + await scheduler.onBlock(402); + + expect(entropy).not.toHaveBeenCalled(); + expect(queue.cancelled).toEqual([61, 62]); + }); + + test('the default provider fetches the exact canonical header height disclosed in proofs', async () => { + const rpc = { getBlockByHeight: jest.fn().mockResolvedValue({ hash: FUTURE_HASH }) }; + const scheduler = new MatchScheduler({ + matchQueue: new DurablePaidQueueDouble(), + matchManager: managerDouble(), + debugManager: { rpcService: rpc }, + rulesetId: 'last-alive' + }); + schedulers.push(scheduler); + + await scheduler.blockEntropyProvider(808); + + expect(rpc.getBlockByHeight).toHaveBeenCalledWith(808); + }); +}); + +describe('MatchQueue durable freeze/refund transactions', () => { + beforeEach(() => { process.env.MATCH_ENABLED = 'true'; }); + afterEach(() => { delete process.env.MATCH_ENABLED; }); + + test('writes pending match, exact queue links, and entrants in one freeze transaction', async () => { + const calls = []; + const entries = [entrant(61, 6), entrant(62, 7)]; + const db = { + async withTransaction(fn) { + return fn({ + async query(sql, params) { + calls.push({ sql, params }); + if (sql.includes('INSERT INTO matches')) return { rows: [{ id: params[0] }], rowCount: 1 }; + if (sql.includes('UPDATE match_queue_entries')) { + return { + rows: entries.map(entry => ({ id: entry.queueEntryId, user_id: entry.userId })), + rowCount: entries.length + }; + } + return { rows: [{ id: 1 }], rowCount: 1 }; + } + }); + } + }; + const queue = new MatchQueue({ db }); + queue._queues.credits_prestige = entries.slice(); + + const frozen = await queue.freezePaidMatch({ + economy: 'credits_prestige', + minPlayers: 2, + maxPlayers: 2, + freezeBlockHeight: 600, + targetBlockHeight: 601, + rulesetId: 'last-alive', + variant: 'pvp', + difficultyPreset: 'race' + }); + + expect(frozen.entries.map(entry => entry.queueEntryId)).toEqual([61, 62]); + expect(queue.length('credits_prestige')).toBe(0); + const pending = calls.find(call => call.sql.includes('INSERT INTO matches')); + const links = calls.find(call => call.sql.includes('UPDATE match_queue_entries')); + expect(pending.sql).toContain("'pending'"); + expect(pending.params[6]).toBe(frozen.freeze.freezeCommitment); + expect(pending.params[8]).toBe(601); + expect(links.params[1]).toEqual(['61', '62']); + expect(calls.filter(call => call.sql.includes('INSERT INTO match_entrants'))).toHaveLength(2); + }); + + test('records the fresh post-commit daemon tip only while the target is still future', async () => { + const calls = []; + const verifiedAt = new Date('2026-07-21T12:00:00Z'); + const db = { + async withTransaction(fn) { + return fn({ + async query(sql, params) { + calls.push({ sql, params }); + return { + rows: [{ + id: params[0], start_block_height: 502, + entropy_precommit_tip_height: params[2], + entropy_precommit_verified_at: verifiedAt + }], + rowCount: 1 + }; + } + }); + }, + query: jest.fn() + }; + const queue = new MatchQueue({ db }); + + const proof = await queue.verifyFrozenPrecommit( + crypto.randomUUID(), 'ab'.repeat(32), 500 + ); + + expect(proof).toEqual(expect.objectContaining({ + verified: true, targetBlockHeight: 502, observedTipHeight: 500 + })); + expect(calls[0].sql).toContain('start_block_height > $3'); + expect(calls[0].sql).toContain('entropy_precommit_tip_height IS NULL'); + expect(calls[0].sql).toContain("status = 'pending'"); + expect(db.query).not.toHaveBeenCalled(); + }); + + test('startup cancels v1 and crash-before-verification freezes; a failed cancellation retries', async () => { + const canonical = buildPaidEntrantFreeze({ + freezeBlockHeight: 800, + targetBlockHeight: 802, + economy: 'credits_prestige', + rulesetId: 'last-alive', + queueEntryIds: [81, 82] + }); + const queue = new MatchQueue({ db: {} }); + queue.listFrozenPaidMatches = jest.fn().mockResolvedValue([ + { + id: 'v2-unverified', freeze: canonical, + freezeCommitment: canonical.freezeCommitment, + precommitTipHeight: null, precommitVerifiedAt: null + }, + { + id: 'v1-legacy', freeze: { ...canonical, version: 'future-block-freeze-v1' }, + freezeCommitment: canonical.freezeCommitment, + precommitTipHeight: 800, precommitVerifiedAt: new Date() + } + ]); + const attempts = new Map(); + queue.cancelFrozenMatch = jest.fn(async id => { + attempts.set(id, (attempts.get(id) || 0) + 1); + if (id === 'v2-unverified' && attempts.get(id) === 1) { + return { resolved: false }; + } + return { resolved: true }; + }); + + const first = await queue.cancelUnverifiedPaidFreezes( + 'match_cancel', { failOnError: false } + ); + const second = await queue.cancelUnverifiedPaidFreezes( + 'match_cancel', { failOnError: false } + ); + + expect(first).toEqual(expect.objectContaining({ ok: false, failed: 1 })); + expect(second).toEqual(expect.objectContaining({ ok: true, failed: 0 })); + expect(attempts.get('v2-unverified')).toBe(2); + expect(attempts.get('v1-legacy')).toBe(2); + }); + + test('a lost freeze COMMIT acknowledgement adopts the exact durable envelope once', async () => { + const entries = [entrant(63, 9), entrant(64, 10)]; + let pendingParams = null; + const db = { + async withTransaction(fn) { + await fn({ + async query(sql, params) { + if (sql.includes('INSERT INTO matches')) { + pendingParams = params; + return { rows: [{ id: params[0] }], rowCount: 1 }; + } + if (sql.includes('UPDATE match_queue_entries')) { + return { + rows: entries.map(entry => ({ id: entry.queueEntryId, user_id: entry.userId })), + rowCount: entries.length + }; + } + return { rows: [{ id: 1 }], rowCount: 1 }; + } + }); + throw new Error('commit acknowledgement lost'); + }, + async query(sql) { + expect(sql).toContain("m.status = 'pending'"); + const dungeon = JSON.parse(pendingParams[7]); + return { + rows: entries.map(entry => ({ + id: pendingParams[0], + economy: 'credits_prestige', + variant: 'pvp', + ruleset_id: 'last-alive', + difficulty_preset: 'race', + max_players: 2, + seed_hash: pendingParams[6], + start_block_height: 611, + dungeon, + queue_entry_id: entry.queueEntryId, + user_id: entry.userId, + socket_id: entry.socketId, + session_token: entry.sessionToken, + created_at: new Date(entry.createdAt).toISOString() + })), + rowCount: entries.length + }; + } + }; + const queue = new MatchQueue({ db }); + queue._queues.credits_prestige = entries.slice(); + + const frozen = await queue.freezePaidMatch({ + economy: 'credits_prestige', + minPlayers: 2, + maxPlayers: 2, + freezeBlockHeight: 610, + targetBlockHeight: 611, + rulesetId: 'last-alive', + variant: 'pvp', + difficultyPreset: 'race' + }); + + expect(frozen).toEqual(expect.objectContaining({ + id: pendingParams[0], + freezeCommitment: pendingParams[6] + })); + expect(queue.length('credits_prestige')).toBe(0); + }); + + test('shutdown cancellation claims a pending freeze and refunds each anchor exactly once', async () => { + const freeze = buildPaidEntrantFreeze({ + freezeBlockHeight: 700, + targetBlockHeight: 701, + economy: 'credits_prestige', + rulesetId: 'last-alive', + queueEntryIds: [71, 72] + }); + let pending = true; + let creditsRefunded = 0; + let ledgerRows = 0; + const db = { + async withTransaction(fn) { + return fn({ + async query(sql) { + if (sql.includes('UPDATE matches')) { + if (!pending) return { rows: [], rowCount: 0 }; + pending = false; + return { + rows: [{ economy: 'credits_prestige', dungeon: { match_fairness_freeze: freeze } }], + rowCount: 1 + }; + } + if (sql.includes('UPDATE match_queue_entries')) { + return { + rows: [ + { id: 71, user_id: 7, escrow_amount: '1', race_entry_lot_id: null }, + { id: 72, user_id: 8, escrow_amount: '1', race_entry_lot_id: null } + ], + rowCount: 2 + }; + } + if (sql.includes('UPDATE users')) { + creditsRefunded += 1; + return { rows: [{ credits: 10 }], rowCount: 1 }; + } + if (sql.includes('INSERT INTO credit_transactions')) { + ledgerRows += 1; + return { rows: [], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + } + }); + } + }; + const queue = new MatchQueue({ db }); + const freezeId = crypto.randomUUID(); + queue.listFrozenPaidMatches = jest.fn().mockResolvedValue([{ + id: freezeId, economy: 'credits_prestige', entries: [entrant(71), entrant(72)] + }]); + + await queue.shutdown(); + await queue.shutdown(); + + expect(queue.listFrozenPaidMatches).toHaveBeenCalledTimes(2); + expect({ creditsRefunded, ledgerRows }).toEqual({ creditsRefunded: 2, ledgerRows: 2 }); + }); + + test('orderly server shutdown waits for scheduler, then refunds freezes, then stops matches', async () => { + const order = []; + const context = { + beginShutdown: jest.fn(), + debugManager: { CONSOLE_LOGGING: false }, + tavernManager: null, + matchScheduler: { shutdown: jest.fn(async () => { order.push('scheduler'); }) }, + matchQueue: { shutdown: jest.fn(async () => { order.push('queue'); }) }, + matchManager: { shutdown: jest.fn(() => { order.push('manager'); }) }, + spectatorManager: null, + chatHandler: null, + connectionHandler: null, + rateLimiter: null, + memoryManager: null, + sessionManager: null, + suspendedGameManager: null, + paymentHandlers: null, + activeGames: new Map() + }; + + await SocketHandlers.prototype.shutdown.call(context); + + expect(order).toEqual(['scheduler', 'queue', 'manager']); + }); +}); diff --git a/test/paymentConfigPrecision.test.js b/test/paymentConfigPrecision.test.js new file mode 100644 index 0000000..7ce9d0d --- /dev/null +++ b/test/paymentConfigPrecision.test.js @@ -0,0 +1,38 @@ +const PaymentConfigManager = require('../src/config/paymentConfig'); +const GameModeManager = require('../src/game/gameModeManager'); + +describe('PaymentConfigManager atomic-unit precision', () => { + const originalPrice = process.env.DIRECT_GAME_PRICE; + + afterEach(() => { + if (originalPrice === undefined) delete process.env.DIRECT_GAME_PRICE; + else process.env.DIRECT_GAME_PRICE = originalPrice; + }); + + test('parses integer environment values above Number.MAX_SAFE_INTEGER exactly', () => { + process.env.DIRECT_GAME_PRICE = '100000000000000001'; + + const config = new PaymentConfigManager({ + logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() } + }).getConfig(); + + expect(config.modes.direct.price).toBe(100000000000000001n); + }); + + test('game mode snapshot and public transport preserve large atomic prices exactly', () => { + process.env.DIRECT_GAME_PRICE = '100000000000000001'; + const manager = new PaymentConfigManager({ + logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() } + }); + const gmm = new GameModeManager( + { query: jest.fn() }, + {}, + { CONSOLE_LOGGING: false }, + manager + ); + + expect(gmm.singleGamePrice).toBe('100000000000000001'); + expect(gmm.getGameModeInfo().singleGamePrice).toBe('100000000000000001'); + expect(gmm.calculatePayout('PAID_SINGLE').amount).toBe('200000000000000002'); + }); +}); diff --git a/test/paymentHandlers.test.js b/test/paymentHandlers.test.js index 885cf0a..010f53c 100644 --- a/test/paymentHandlers.test.js +++ b/test/paymentHandlers.test.js @@ -1,4 +1,16 @@ const PaymentHandlers = require('../src/network/paymentHandlers'); +const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy'); + +function legalAcknowledgement(gameModeManager) { + const disclosure = buildCommerceDisclosure(gameModeManager, process.env); + return { + policyVersion: disclosure.policyVersion, + ageEligible: true, + termsRead: true, + riskAccepted: true, + testnetUnderstood: disclosure.service.isTestNetwork === true + }; +} function makeMocks() { const user = { id: 'user-1', clientId: 'client1', payout_address: 'wow1address' }; @@ -52,7 +64,9 @@ describe('PaymentHandlers basic', () => { const emitted = {}; mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } }); ph = new PaymentHandlers(mocks); - await ph.createAndShowPaymentRequest({ id: 'sock1' }); + await ph.createAndShowPaymentRequest({ id: 'sock1' }, { + legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager) + }); expect(emitted.payment_created).toBeDefined(); expect(emitted.payment_created.paymentId).toBe('pid123'); // No paymentProviders registry on the mock -> legacy fallback to direct wallet monitoring. @@ -65,9 +79,190 @@ describe('PaymentHandlers basic', () => { mocks.gameModeManager.paymentProviders = { getProvider: jest.fn(() => gateway) }; mocks.io.to = () => ({ emit: () => {} }); ph = new PaymentHandlers(mocks); - await ph.createAndShowPaymentRequest({ id: 'sock2' }); + await ph.createAndShowPaymentRequest({ id: 'sock2' }, { + legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager) + }); expect(mocks.gameModeManager.paymentProviders.getProvider).toHaveBeenCalledWith('WOW'); expect(gateway.startWatch).toHaveBeenCalled(); expect(mocks.walletService.startPaymentMonitoring).not.toHaveBeenCalled(); // routed away from native }); + + test('direct payout invoice is not created before a required payout address exists', async () => { + const mocks = makeMocks(); + const emitted = {}; + mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } }); + mocks.gameModeManager.getOrCreateUser.mockResolvedValue({ id: 'user-1', payout_address: null }); + mocks.gameModeManager.requiresPayoutAddressForMode = jest.fn(() => true); + mocks.gameModeManager.createPaymentRequest = jest.fn(); + ph = new PaymentHandlers(mocks); + + await ph.handlePaymentRequest({ id: 'sock3' }, { + type: 'single_game', + legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager) + }); + + expect(mocks.gameModeManager.createPaymentRequest).not.toHaveBeenCalled(); + expect(emitted.payment_error).toEqual(expect.objectContaining({ code: 'PAYOUT_ADDRESS_REQUIRED' })); + }); + + test.each(['single_game', 'credits_package'])( + 'payout-disabled %s invoice intake is not halted by an unknown/low wallet reserve', + async (paymentType) => { + const mocks = makeMocks(); + mocks.gameModeManager.isPayoutEnabledForMode = jest.fn(() => false); + mocks.gameModeManager.alertService = { + checkBalanceForGameStart: jest.fn().mockResolvedValue({ halted: true, reason: 'low' }) + }; + mocks.gameModeManager.createPaymentRequest = jest.fn(mocks.gameModeManager.createPaymentRequest); + ph = new PaymentHandlers(mocks); + + await ph.handlePaymentRequest({ id: `no-payout-${paymentType}` }, { + type: paymentType, + legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager) + }); + + expect(mocks.gameModeManager.alertService.checkBalanceForGameStart).not.toHaveBeenCalled(); + expect(mocks.gameModeManager.createPaymentRequest).toHaveBeenCalledTimes(1); + } + ); + + test('payout-bearing direct invoice remains fail closed under a critical reserve', async () => { + const mocks = makeMocks(); + const emitted = {}; + mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } }); + mocks.gameModeManager.isPayoutEnabledForMode = jest.fn(mode => mode === 'PAID_SINGLE'); + mocks.gameModeManager.alertService = { + checkBalanceForGameStart: jest.fn().mockResolvedValue({ halted: true, reason: 'critical' }) + }; + mocks.gameModeManager.createPaymentRequest = jest.fn(); + ph = new PaymentHandlers(mocks); + + await ph.handlePaymentRequest({ id: 'payout-stagenet' }, { + type: 'single_game', + legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager) + }); + + expect(mocks.gameModeManager.createPaymentRequest).not.toHaveBeenCalled(); + expect(emitted.balance_critical).toEqual(expect.objectContaining({ message: 'critical' })); + }); + + test('product grant transaction failure emits review-required and never confirmation success', async () => { + const mocks = makeMocks(); + const emitted = {}; + const socket = { id: 'product-failure', emit: (event, data) => { emitted[event] = data; } }; + mocks.gameModeManager.processProductPaymentConfirmation = jest.fn() + .mockRejectedValue(new Error('receipt insert failed')); + mocks.queueManager.removePlayer = jest.fn(); + ph = new PaymentHandlers(mocks); + ph.socketPaymentMap.set(socket.id, { + address: 'addr', paymentId: 'pid123', amount: 1000, + paymentType: 'credits_package', package: { credits: 10 } + }); + + await ph._handlePaymentStatus(socket, { id: 'pid123', address: 'addr', amount: 1000 }, null, { + confirmed: true, + complete: true, + amount: 1000, + confirmations: 2, + receipts: [] + }); + + expect(emitted.payment_review_required).toEqual(expect.objectContaining({ + code: 'PAYMENT_RECEIPT_REJECTED' + })); + expect(emitted.payment_confirmed).toBeUndefined(); + expect(mocks.queueManager.removePlayer).toHaveBeenCalledWith(socket.id); + }); + + test('single-game payment queue preserves the consumed two-phase fairness proof', async () => { + const mocks = makeMocks(); + let statusCallback; + mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => { + statusCallback = callback; + }); + const fairnessProof = { + proofVersion: 2, + offerId: 'offer-paid', + serverSeed: 'a'.repeat(64), + commitment: 'b'.repeat(64), + clientSeed: 'c'.repeat(64) + }; + const durableProof = { + ...fairnessProof, + offerId: 'offer-persisted-on-invoice', + offerIssuedAt: 1700000000000 + }; + mocks.gameModeManager.createPaymentRequest = jest.fn(async () => ({ + id: 'pid123', + address: 'addr', + amount: 1000, + amountFormatted: '0.001', + expiresAt: new Date(), + fairnessProof: durableProof + })); + ph = new PaymentHandlers(mocks); + + await ph.createAndShowPaymentRequest({ id: 'sock4', emit: jest.fn() }, { + fairnessProof, + legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager) + }); + await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 }); + + expect(mocks.queueManager.addPlayer).toHaveBeenCalledWith(expect.objectContaining({ + paymentId: 'pid123', + fairnessProof: durableProof + })); + }); + + test('production refuses to queue an unbound paid invoice', async () => { + const mocks = makeMocks(); + let statusCallback; + const emitted = {}; + mocks.gameModeManager._requiresPaidFairnessV2 = jest.fn(() => true); + mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => { + statusCallback = callback; + }); + const socket = { + id: 'sock-unbound', + emit: (event, data) => { emitted[event] = data; } + }; + ph = new PaymentHandlers(mocks); + + await ph.createAndShowPaymentRequest(socket, { + legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager) + }); + await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 }); + + expect(mocks.queueManager.addPlayer).not.toHaveBeenCalled(); + expect(emitted.payment_review_required).toEqual(expect.objectContaining({ + code: 'PAYMENT_FAIRNESS_UNBOUND' + })); + }); + + test('fairness proof survives a payout-address prompt before monitoring starts', async () => { + const mocks = makeMocks(); + let hasAddress = false; + let statusCallback; + mocks.gameModeManager.requiresPayoutAddressForMode = jest.fn(() => true); + mocks.gameModeManager.getOrCreateUser.mockImplementation(async () => ({ + id: 'user-1', + payout_address: hasAddress ? 'wow1address' : null + })); + mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => { + statusCallback = callback; + }); + const fairnessProof = { proofVersion: 2, offerId: 'address-offer', clientSeed: 'd'.repeat(64) }; + ph = new PaymentHandlers(mocks); + + await ph.createAndShowPaymentRequest({ id: 'sock5', emit: jest.fn() }, { + fairnessProof, + legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager) + }); + expect(statusCallback).toBeUndefined(); + hasAddress = true; + await ph.createAndShowPaymentRequest({ id: 'sock5', emit: jest.fn() }); + await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 }); + + expect(mocks.queueManager.addPlayer).toHaveBeenCalledWith(expect.objectContaining({ fairnessProof })); + }); }); diff --git a/test/paymentPolicy_ultra.test.js b/test/paymentPolicy_ultra.test.js new file mode 100644 index 0000000..5bffef9 --- /dev/null +++ b/test/paymentPolicy_ultra.test.js @@ -0,0 +1,117 @@ +const PaymentConfigManager = require('../src/config/paymentConfig'); +const GameModeManager = require('../src/game/gameModeManager'); + +const ENV_KEYS = [ + 'GAME_MODE', + 'PAYMENTS_ENABLED', + 'PAYMENT_MODES', + 'DIRECT_PAYMENT_ENABLED', + 'CREDITS_ENABLED', + 'FREE_PLAY_ENABLED', + 'PAYOUTS_ENABLED', + 'DIRECT_PAYOUTS_ENABLED', + 'CREDITS_PAYOUTS_ENABLED', + 'PAYOUT_MAX_PER_GAME', + 'CREDITS_PAYOUT_MAX' +]; + +describe('production payment policy wiring', () => { + let saved; + + beforeEach(() => { + saved = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]])); + for (const key of ENV_KEYS) delete process.env[key]; + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + }); + + test('PAYOUTS_ENABLED=false remains authoritative with both per-mode flags true', () => { + process.env.PAYMENTS_ENABLED = 'true'; + process.env.PAYMENT_MODES = 'direct,credits'; + process.env.PAYOUTS_ENABLED = 'false'; + process.env.DIRECT_PAYOUTS_ENABLED = 'true'; + process.env.CREDITS_PAYOUTS_ENABLED = 'true'; + process.env.FREE_PLAY_ENABLED = 'false'; + + const configManager = new PaymentConfigManager({ logger: { warn() {}, info() {} } }); + const manager = new GameModeManager( + { query: jest.fn(), withTransaction: jest.fn() }, + { validateAddress: jest.fn() }, + { CONSOLE_LOGGING: false }, + configManager + ); + + expect(configManager.getConfig().payouts.enabled).toBe(false); + expect(manager.payoutsEnabled).toBe(false); + expect(manager.isPayoutEnabledForMode('PAID_SINGLE')).toBe(false); + expect(manager.isPayoutEnabledForMode('PAID_CREDITS')).toBe(false); + expect(manager.getGameModeInfo()).toEqual(expect.objectContaining({ + payoutsEnabled: false, + directPayoutsEnabled: false, + creditsPayoutsEnabled: false, + freePlayEnabled: false + })); + }); + + test('PAYMENTS_ENABLED=false remains authoritative over every per-mode enable', () => { + process.env.PAYMENTS_ENABLED = 'false'; + process.env.PAYMENT_MODES = 'direct,credits'; + process.env.DIRECT_PAYMENT_ENABLED = 'true'; + process.env.CREDITS_ENABLED = 'true'; + + const config = new PaymentConfigManager({ logger: { warn() {}, info() {} } }).getConfig(); + + expect(config.paymentsEnabled).toBe(false); + expect(config.modes.direct.enabled).toBe(false); + expect(config.modes.credits.enabled).toBe(false); + }); + + test('invoice creation refuses before database or wallet work when the master switch is off', async () => { + process.env.PAYMENTS_ENABLED = 'false'; + process.env.PAYMENT_MODES = 'direct,credits'; + const configManager = new PaymentConfigManager({ logger: { warn() {}, info() {} } }); + const db = { query: jest.fn(), withTransaction: jest.fn() }; + const wallet = { createPaymentRequest: jest.fn(), validateAddress: jest.fn() }; + const manager = new GameModeManager(db, wallet, { CONSOLE_LOGGING: false }, configManager); + + await expect(manager.createPaymentRequest('socket-disabled', 'single_game')) + .rejects.toMatchObject({ code: 'PAYMENT_INTAKE_DISABLED' }); + await expect(manager.createPaymentRequest('socket-disabled', 'credits_package')) + .rejects.toMatchObject({ code: 'PAYMENT_INTAKE_DISABLED' }); + expect(db.query).not.toHaveBeenCalled(); + expect(wallet.createPaymentRequest).not.toHaveBeenCalled(); + }); + + test('per-product switches cannot be bypassed through the generic invoice method', async () => { + process.env.PAYMENTS_ENABLED = 'true'; + process.env.PAYMENT_MODES = 'direct'; + process.env.DIRECT_PAYMENT_ENABLED = 'true'; + process.env.CREDITS_ENABLED = 'false'; + const configManager = new PaymentConfigManager({ logger: { warn() {}, info() {} } }); + const db = { query: jest.fn(), withTransaction: jest.fn() }; + const wallet = { createPaymentRequest: jest.fn(), validateAddress: jest.fn() }; + const manager = new GameModeManager(db, wallet, { CONSOLE_LOGGING: false }, configManager); + + expect(manager.isPaymentIntakeEnabled('single_game')).toBe(true); + expect(manager.isPaymentIntakeEnabled('credits_package')).toBe(false); + await expect(manager.createPaymentRequest('socket-disabled', 'credits_package')) + .rejects.toMatchObject({ code: 'PAYMENT_INTAKE_DISABLED' }); + expect(db.query).not.toHaveBeenCalled(); + expect(wallet.createPaymentRequest).not.toHaveBeenCalled(); + }); + + test('global per-game payout cap is an outer bound for direct and credit wins', () => { + process.env.PAYMENT_MODES = 'direct,credits'; + process.env.PAYOUT_MAX_PER_GAME = '123456'; + + const config = new PaymentConfigManager({ logger: { warn() {}, info() {} } }).getConfig(); + + expect(config.payouts.rules.direct.maxPayout).toBe(123456n); + expect(config.payouts.rules.credits.maxPayout).toBe(123456n); + }); +}); diff --git a/test/paymentProviders_ultra.test.js b/test/paymentProviders_ultra.test.js index 8365467..866ec05 100644 --- a/test/paymentProviders_ultra.test.js +++ b/test/paymentProviders_ultra.test.js @@ -96,6 +96,16 @@ describe('BTCPayProvider (Greenfield)', () => { expect(st.amount).toBe(150000000000); // 1.5 * 1e11 }); + test('gateway atomic amounts above Number.MAX_SAFE_INTEGER remain exact strings', async () => { + const st = await mk('Settled', [{ + cryptoCode: 'XMR', + totalPaid: '10000.000000000001', + amount: '10000.000000000001' + }], ['XMR']).getWalletStatus('inv1'); + expect(st.amount).toBe('10000000000000001'); + expect(st.required).toBe('10000000000000001'); + }); + test('Processing -> in_mempool, not confirmed', async () => { const st = await mk('Processing', [{ cryptoCode: 'XMR', totalPaid: '0.5', amount: '1.5' }], ['XMR']).getWalletStatus('inv1'); expect(st.in_mempool).toBe(true); diff --git a/test/paymentReceiptEvidence.test.js b/test/paymentReceiptEvidence.test.js new file mode 100644 index 0000000..f7f835d --- /dev/null +++ b/test/paymentReceiptEvidence.test.js @@ -0,0 +1,128 @@ +const fs = require('fs'); +const path = require('path'); +const GameModeManager = require('../src/game/gameModeManager'); + +function chainReceipt(txHash, outputId, addressIndex, amount) { + return { + evidenceType: 'chain_output', + evidenceId: `${txHash}:${outputId}`, + providerId: 'native-monero', + txHash, + outputId, + addressIndex, + amount: String(amount), + confirmed: true + }; +} + +function receiptDb(rows) { + const payments = new Map(rows.map(row => [row.id, { provider_id: 'native-monero', status: 'pending', ...row }])); + const receipts = []; + const client = { + query: jest.fn(async (sql, params = []) => { + if (/SELECT id, status, expected_amount/i.test(sql)) { + const payment = payments.get(params[0]); + return { rows: payment ? [{ ...payment }] : [] }; + } + if (/INSERT INTO payment_receipts/i.test(sql)) { + const candidate = { + payment_id: params[0], provider_id: params[1], evidence_type: params[2], + evidence_id: params[3], tx_hash: params[4], output_id: params[5], + address_index: params[6], amount: params[7] + }; + const occupied = receipts.some(receipt => receipt.provider_id === candidate.provider_id + && receipt.evidence_id === candidate.evidence_id); + if (!occupied) receipts.push(candidate); + return { rows: [], rowCount: occupied ? 0 : 1 }; + } + if (/COALESCE\(SUM\(amount\)/i.test(sql)) { + const owned = receipts.filter(receipt => receipt.payment_id === params[0] + && receipt.provider_id === params[1]); + const total = owned.reduce((sum, receipt) => sum + BigInt(receipt.amount), 0n); + const hashes = owned.map(receipt => receipt.tx_hash).filter(Boolean).sort(); + return { rows: [{ total: total.toString(), primary_tx_hash: hashes[0] || null }] }; + } + if (/UPDATE payments/i.test(sql)) { + const payment = payments.get(params[0]); + if (!payment || payment.status !== 'pending') return { rows: [] }; + payment.status = 'confirmed'; + payment.received_amount = params[1]; + payment.tx_hash = params[2]; + return { rows: [{ id: payment.id }] }; + } + return { rows: [], rowCount: 0 }; + }) + }; + return { + withTransaction: jest.fn(async callback => callback(client)), + payments, + receipts + }; +} + +describe('durable incoming payment receipts', () => { + test('one Monero transaction can confirm distinct invoice subaddresses, not the same output twice', async () => { + const hash = 'a'.repeat(64); + const outputOne = '1'.repeat(64); + const outputTwo = '2'.repeat(64); + const db = receiptDb([ + { id: 1, expected_amount: '100', address_index: 5 }, + { id: 2, expected_amount: '100', address_index: 6 }, + { id: 3, expected_amount: '100', address_index: 5 } + ]); + const manager = Object.create(GameModeManager.prototype); + manager.db = db; + + await expect(manager.confirmSingleGamePayment(1, '100', [chainReceipt(hash, outputOne, 5, 100)])) + .resolves.toEqual({ updated: true }); + await expect(manager.confirmSingleGamePayment(2, '100', [chainReceipt(hash, outputTwo, 6, 100)])) + .resolves.toEqual({ updated: true }); + await expect(manager.confirmSingleGamePayment(3, '100', [chainReceipt(hash, outputOne, 5, 100)])) + .rejects.toThrow(/do not cover/i); + + expect(db.payments.get(1).status).toBe('confirmed'); + expect(db.payments.get(2).status).toBe('confirmed'); + expect(db.payments.get(3).status).toBe('pending'); + expect(db.receipts).toHaveLength(2); + }); + + test('several confirmed top-ups can exactly cover one invoice', async () => { + const db = receiptDb([{ id: 4, expected_amount: '100', address_index: 9 }]); + const manager = Object.create(GameModeManager.prototype); + manager.db = db; + const result = await manager.confirmSingleGamePayment(4, '100', [ + chainReceipt('b'.repeat(64), '3'.repeat(64), 9, 40), + chainReceipt('c'.repeat(64), '4'.repeat(64), 9, 60) + ]); + expect(result.updated).toBe(true); + expect(db.receipts).toHaveLength(2); + expect(db.payments.get(4).received_amount).toBe('100'); + }); + + test('distinct outputs in one transaction to one subaddress sum once despite replay', async () => { + const hash = 'd'.repeat(64); + const first = chainReceipt(hash, '5'.repeat(64), 12, 40); + const second = chainReceipt(hash, '6'.repeat(64), 12, 60); + const db = receiptDb([{ id: 5, expected_amount: '100', address_index: 12 }]); + const manager = Object.create(GameModeManager.prototype); + manager.db = db; + + const result = await manager.confirmSingleGamePayment(5, '140', [first, second, { ...first }]); + + expect(result).toEqual({ updated: true }); + expect(db.receipts).toHaveLength(2); + expect(db.payments.get(5).received_amount).toBe('100'); + }); + + test('migration removes txid-only uniqueness and installs the output-scoped forward guard', () => { + const sql = fs.readFileSync(path.join(__dirname, '../src/migrations/037_payment_receipt_evidence.sql'), 'utf8'); + expect(sql).toMatch(/DROP INDEX IF EXISTS idx_payments_tx_hash_unique/i); + expect(sql).not.toMatch(/CREATE\s+UNIQUE\s+INDEX[\s\S]*?ON\s+payments\s*\(\s*tx_hash\s*,\s*address_index\s*\)/i); + expect(sql).toMatch(/ON\s+payment_receipts\s*\(\s*provider_id\s*,\s*evidence_id\s*\)/i); + expect(sql).not.toMatch(/ON\s+payment_receipts\s*\(\s*tx_hash\s*,\s*address_index\s*\)/i); + expect(sql).not.toMatch(/UNIQUE\s*\(subaddress\)/i); + expect(sql).toMatch(/evidence_id\s*=\s*tx_hash\s*\|\|\s*':'\s*\|\|\s*output_id/i); + expect(sql).toMatch(/OLD\.status IS DISTINCT FROM 'confirmed'/i); + expect(sql).toMatch(/receipt_total < NEW\.expected_amount/i); + }); +}); diff --git a/test/paymentRefundService.test.js b/test/paymentRefundService.test.js new file mode 100644 index 0000000..95d15bf --- /dev/null +++ b/test/paymentRefundService.test.js @@ -0,0 +1,585 @@ +const PaymentRefundService = require('../src/services/paymentRefundService'); + +function clone(value) { + return value == null ? value : { ...value }; +} + +class Mutex { + constructor() { + this.locked = false; + this.waiters = []; + } + + async acquire() { + if (!this.locked) { + this.locked = true; + return () => this.release(); + } + return new Promise(resolve => this.waiters.push(resolve)); + } + + release() { + const next = this.waiters.shift(); + if (next) { + next(() => this.release()); + } else { + this.locked = false; + } + } +} + +/** Minimal transactional DB double implementing only the refund service's SQL. */ +class FakeRefundDb { + constructor({ payment, user, grant = null, raceLot = null, packEntitlements = [], refund = null }) { + this.payment = clone(payment); + this.user = clone(user); + this.grant = clone(grant); + this.raceLot = clone(raceLot); + this.packEntitlements = packEntitlements.map(clone); + this.raceTransactions = []; + this.refund = clone(refund); + this.creditTransactions = []; + this.paymentUpdateCount = 0; + this.nextRefundId = refund?.id ? Number(refund.id) + 1 : 1; + this.paymentLock = new Mutex(); + } + + async getClient() { + const db = this; + let unlock = null; + return { + async query(text, params = []) { + const sql = text.replace(/\s+/g, ' ').trim().toLowerCase(); + if (sql === 'begin') return { rows: [] }; + if (sql === 'commit' || sql === 'rollback') { + unlock?.(); + unlock = null; + return { rows: [] }; + } + if (sql.includes('select p.*') && sql.includes('from payments p')) { + unlock = await db.paymentLock.acquire(); + return { rows: db.payment && Number(db.payment.id) === Number(params[0]) ? [clone(db.payment)] : [] }; + } + if (sql.includes('from payment_refunds') && sql.includes('for update')) { + return { rows: db.refund ? [clone(db.refund)] : [] }; + } + if (sql.includes('select id, credits, race_entries') && sql.includes('from users')) { + return { rows: db.user && Number(db.user.id) === Number(params[0]) ? [clone(db.user)] : [] }; + } + if (sql.includes('from payment_entitlement_grants') && sql.includes('for update')) { + return { rows: db.grant ? [clone(db.grant)] : [] }; + } + if (sql.startsWith('select id from games')) return { rows: [] }; + if (sql.startsWith('update users') && sql.includes('credits = credits -')) { + db.user.credits = (BigInt(db.user.credits) - BigInt(params[0])).toString(); + db.user.total_credits_purchased = (BigInt(db.user.total_credits_purchased) - BigInt(params[1])).toString(); + db.user.race_entries -= Number(params[2]); + db.user.premium_level = params[3]; + return { rows: [clone(db.user)] }; + } + if (sql.startsWith('insert into credit_transactions')) { + db.creditTransactions.push({ + user_id: params[0], + amount: params[1], + reason: params[2], + balance_after: params[3], + transaction_type: 'refund' + }); + return { rows: [] }; + } + if (sql.startsWith('insert into race_entry_transactions')) { + db.raceTransactions.push({ delta: params[1], payment_id: params[3] }); + return { rows: [] }; + } + if (sql.startsWith('update payment_entitlement_grants')) { + if (sql.includes("status = 'needs_review'")) { + db.grant.status = 'needs_review'; + db.grant.needs_review_at = new Date(); + } else { + db.grant.status = 'reversed'; + db.grant.credits_reversed = params[1]; + db.grant.purchase_progress_reversed = params[2]; + db.grant.race_entries_reversed = params[3]; + db.grant.reversed_at = new Date(); + } + return { rows: [clone(db.grant)], rowCount: 1 }; + } + if (sql.includes('from race_entry_lots')) return { rows: db.raceLot ? [clone(db.raceLot)] : [] }; + if (sql.startsWith('update race_entry_lots')) { + if (!db.raceLot || db.raceLot.refunded_at != null + || db.raceLot.remaining_entries !== db.raceLot.original_entries) return { rows: [], rowCount: 0 }; + db.raceLot.remaining_entries = 0; + db.raceLot.refunded_at = new Date(); + return { rows: [{ id: db.raceLot.id }], rowCount: 1 }; + } + if (sql.includes('from user_pack_entitlements') && sql.includes('for update')) { + return { rows: db.packEntitlements.map(clone) }; + } + if (sql.startsWith('delete from user_pack_entitlements')) { + const wanted = new Set(params[1]); + const removed = db.packEntitlements.filter(row => wanted.has(row.pack_id) + && String(row.metadata?.paymentId) === String(params[2])); + db.packEntitlements = db.packEntitlements.filter(row => !removed.includes(row)); + return { rows: removed.map(row => ({ pack_id: row.pack_id })), rowCount: removed.length }; + } + if (sql.startsWith('update payments')) { + db.payment.status = 'refunded'; + db.paymentUpdateCount += 1; + return { rows: [] }; + } + if (sql.startsWith('insert into payment_refunds')) { + const needsReview = sql.includes("'needs_review'"); + db.refund = { + id: db.nextRefundId++, + payment_id: params[0], + user_id: params[1], + status: needsReview ? 'needs_review' : params[2], + amount: needsReview ? params[2] : params[3], + payout_address: needsReview ? params[3] : params[4], + credits_deducted: needsReview ? '0' : params[5], + purchase_progress_deducted: needsReview ? '0' : params[6], + race_entries_deducted: needsReview ? 0 : params[7], + packs_revoked: needsReview ? [] : JSON.parse(params[8]), + premium_level_restored: needsReview ? null : params[9], + reason: needsReview ? params[4] : params[10], + tx_hash: null, + error_message: needsReview ? params[5] : null, + requested_at: needsReview || params[2] === 'requested' ? new Date() : null, + processing_started_at: null, + completed_at: null, + needs_review_at: needsReview ? new Date() : null + }; + return { rows: [clone(db.refund)] }; + } + throw new Error(`Unexpected transaction SQL: ${sql}`); + }, + release() { + unlock?.(); + unlock = null; + } + }; + } + + async query(text, params = []) { + const sql = text.replace(/\s+/g, ' ').trim().toLowerCase(); + if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'processing'")) { + if (!this.refund || Number(this.refund.payment_id) !== Number(params[0]) || this.refund.status !== 'requested') { + return { rows: [] }; + } + this.refund.status = 'processing'; + this.refund.processing_started_at = new Date(); + this.refund.error_message = null; + return { rows: [clone(this.refund)] }; + } + if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'completed'")) { + if (!this.refund || Number(this.refund.id) !== Number(params[0]) || this.refund.status !== 'processing') { + return { rows: [] }; + } + this.refund.status = 'completed'; + this.refund.tx_hash = params[1]; + this.refund.completed_at = new Date(); + this.refund.error_message = null; + return { rows: [clone(this.refund)] }; + } + if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'needs_review'")) { + if (!this.refund || Number(this.refund.id) !== Number(params[0]) || this.refund.status !== 'processing') { + return { rows: [] }; + } + this.refund.status = 'needs_review'; + this.refund.tx_hash = params[1] || this.refund.tx_hash; + this.refund.error_message = params[2]; + this.refund.needs_review_at = new Date(); + return { rows: [clone(this.refund)] }; + } + if (sql.startsWith('select *') && sql.includes('from payment_refunds')) { + return { rows: this.refund && Number(this.refund.payment_id) === Number(params[0]) ? [clone(this.refund)] : [] }; + } + throw new Error(`Unexpected pool SQL: ${sql}`); + } +} + +function fixture(overrides = {}) { + return new FakeRefundDb({ + payment: { + id: 42, + user_id: 7, + expected_amount: '1000', + received_amount: '900', + credits_purchased: '6', + status: 'confirmed', + description: 'Credits purchase', + ...overrides.payment + }, + user: { + id: 7, + credits: '6', + race_entries: 0, + total_credits_purchased: '6', + premium_level: 'free', + payout_address: '5'.repeat(95), + ...overrides.user + }, + grant: overrides.grant === undefined ? { + payment_id: 42, + user_id: 7, + source: 'product_confirmation', + credits_granted: '6', + purchase_progress_granted: '6', + race_entries_granted: 0, + packs_granted: [], + premium_level_granted: null, + premium_level_previous: null, + status: 'active' + } : overrides.grant, + raceLot: overrides.raceLot || null, + packEntitlements: overrides.packEntitlements || [], + refund: overrides.refund || null + }); +} + +describe('PaymentRefundService', () => { + test('the global payout gate refuses an automatic refund before any durable or wallet side effect', async () => { + const db = fixture(); + const walletService = { processPayout: jest.fn() }; + const service = new PaymentRefundService({ + db, + walletService, + logger, + isSendEnabled: () => false + }); + + await expect(service.refundPayment({ paymentId: 42, sendFunds: true })) + .rejects.toMatchObject({ code: 'PAYOUT_DISPATCH_DISABLED', statusCode: 503 }); + expect(walletService.processPayout).not.toHaveBeenCalled(); + expect(db.refund).toBeNull(); + expect(db.paymentUpdateCount).toBe(0); + }); + + test('a kill switch raised after the refund claim prevents the wallet call and strands safely for review', async () => { + const db = fixture(); + const walletService = { processPayout: jest.fn() }; + const isSendEnabled = jest.fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + const service = new PaymentRefundService({ + db, + walletService, + logger, + isSendEnabled + }); + + const result = await service.refundPayment({ paymentId: 42, sendFunds: true }); + + expect(result.refund.status).toBe('needs_review'); + expect(walletService.processPayout).not.toHaveBeenCalled(); + expect(isSendEnabled).toHaveBeenCalledTimes(2); + }); + + const txHash = 'a'.repeat(64); + const logger = { error: jest.fn() }; + + beforeEach(() => logger.error.mockClear()); + + test('concurrent transfer requests deduct and broadcast exactly once', async () => { + const db = fixture(); + const walletService = { + processPayout: jest.fn().mockResolvedValue({ success: true, txHash }) + }; + const service = new PaymentRefundService({ db, walletService, logger }); + + const results = await Promise.all([ + service.refundPayment({ paymentId: 42, reason: 'duplicate request', sendFunds: true }), + service.refundPayment({ paymentId: 42, reason: 'duplicate request', sendFunds: true }) + ]); + + expect(walletService.processPayout).toHaveBeenCalledTimes(1); + expect(db.paymentUpdateCount).toBe(1); + expect(db.payment.description).toBe('Credits purchase'); + expect(db.user.credits).toBe('0'); + expect(db.user.total_credits_purchased).toBe('0'); + expect(db.creditTransactions).toHaveLength(1); + expect(db.creditTransactions[0].amount).toBe('-6'); + expect(db.refund.status).toBe('completed'); + expect(db.refund.amount).toBe('900'); + expect(db.refund.tx_hash).toBe(txHash); + expect(results.filter(result => result.existing)).toHaveLength(1); + }); + + test('reverses exact purchase progress and a premium tier granted solely by the payment', async () => { + const Entitlements = require('../src/multiplayer/entitlements'); + const db = fixture({ + user: { + credits: '6', + total_credits_purchased: '6', + premium_level: 'supporter' + }, + grant: { + payment_id: 42, + user_id: 7, + source: 'product_confirmation', + credits_granted: '6', + purchase_progress_granted: '6', + race_entries_granted: 0, + packs_granted: [], + premium_level_granted: 'supporter', + premium_level_previous: 'free', + status: 'active' + } + }); + const before = Entitlements.snapshotForUser(db.user); + expect(before.packs['generated-skins']).toBe(true); + const service = new PaymentRefundService({ db, walletService: null, logger }); + + const result = await service.refundPayment({ paymentId: 42, sendFunds: false }); + const after = Entitlements.snapshotForUser(db.user); + + expect(result.refund.status).toBe('recorded'); + expect(db.user.credits).toBe('0'); + expect(db.user.total_credits_purchased).toBe('0'); + expect(db.user.premium_level).toBe('free'); + expect(after.packs['generated-skins']).toBe(false); + expect(db.grant).toEqual(expect.objectContaining({ + status: 'reversed', + credits_reversed: '6', + purchase_progress_reversed: '6' + })); + expect(db.refund).toEqual(expect.objectContaining({ + credits_deducted: '6', + purchase_progress_deducted: '6', + premium_level_restored: 'free' + })); + }); + + test('changed purchase progress routes the whole refund to review without revoking benefits', async () => { + const db = fixture({ user: { credits: '6', total_credits_purchased: '5' } }); + const walletService = { processPayout: jest.fn() }; + const service = new PaymentRefundService({ db, walletService, logger }); + + const result = await service.refundPayment({ paymentId: 42, sendFunds: true }); + + expect(result.refund.status).toBe('needs_review'); + expect(result.refund.error_message).toContain('progression cannot be reversed safely'); + expect(db.user.credits).toBe('6'); + expect(db.user.total_credits_purchased).toBe('5'); + expect(db.payment.status).toBe('confirmed'); + expect(walletService.processPayout).not.toHaveBeenCalled(); + }); + + test('an unconsumed direct entry with unscoped purchase progress cannot auto-refund', async () => { + const db = fixture({ + payment: { payment_type: 'single_game', credits_purchased: '0' }, + user: { credits: '0', total_credits_purchased: '1' }, + grant: null + }); + const walletService = { processPayout: jest.fn() }; + const service = new PaymentRefundService({ db, walletService, logger }); + + const result = await service.refundPayment({ paymentId: 42, sendFunds: true }); + + expect(result.refund.status).toBe('needs_review'); + expect(result.refund.error_message).toContain('purchase progression requires manual reconciliation'); + expect(db.user.total_credits_purchased).toBe('1'); + expect(db.payment.status).toBe('confirmed'); + expect(walletService.processPayout).not.toHaveBeenCalled(); + }); + + test('record-only requests are durable, terminal, and idempotent', async () => { + const db = fixture(); + const walletService = { processPayout: jest.fn() }; + const service = new PaymentRefundService({ db, walletService, logger }); + + const first = await service.refundPayment({ paymentId: 42, sendFunds: false }); + const second = await service.refundPayment({ paymentId: 42, sendFunds: true }); + + expect(first.refund.status).toBe('recorded'); + expect(second.refund.status).toBe('recorded'); + expect(second.existing).toBe(true); + expect(walletService.processPayout).not.toHaveBeenCalled(); + expect(db.paymentUpdateCount).toBe(1); + expect(db.creditTransactions).toHaveLength(1); + }); + + test('ambiguous wallet failure moves to needs_review and is never retried', async () => { + const db = fixture(); + const walletService = { + processPayout: jest.fn().mockRejectedValue(new Error('timeout after transfer submission')) + }; + const service = new PaymentRefundService({ db, walletService, logger }); + + const first = await service.refundPayment({ paymentId: 42, sendFunds: true }); + const second = await service.refundPayment({ paymentId: 42, sendFunds: true }); + + expect(first.refund.status).toBe('needs_review'); + expect(first.refund.error_message).toContain('timeout'); + expect(second.refund.status).toBe('needs_review'); + expect(walletService.processPayout).toHaveBeenCalledTimes(1); + expect(db.paymentUpdateCount).toBe(1); + expect(db.creditTransactions).toHaveLength(1); + }); + + test('success without a valid transaction hash is treated as ambiguous', async () => { + const db = fixture(); + const walletService = { + processPayout: jest.fn().mockResolvedValue({ success: true }) + }; + const service = new PaymentRefundService({ db, walletService, logger }); + + const result = await service.refundPayment({ paymentId: 42, sendFunds: true }); + + expect(result.refund.status).toBe('needs_review'); + expect(result.refund.error_message).toContain('valid transaction hash'); + expect(walletService.processPayout).toHaveBeenCalledTimes(1); + }); + + test('consumed purchased credits require review and no wallet transfer', async () => { + const db = fixture({ user: { credits: '5' } }); + const walletService = { + processPayout: jest.fn().mockResolvedValue({ success: true, txHash }) + }; + const service = new PaymentRefundService({ db, walletService, logger }); + + const result = await service.refundPayment({ paymentId: 42, sendFunds: true }); + + expect(result.refund.status).toBe('needs_review'); + expect(result.refund.error_message).toContain('already been consumed'); + expect(walletService.processPayout).not.toHaveBeenCalled(); + expect(db.user.credits).toBe('5'); + expect(db.payment.status).toBe('confirmed'); + expect(db.creditTransactions).toHaveLength(0); + }); + + test('unused race tickets and payment-owned packs reverse exactly once', async () => { + const db = fixture({ + user: { credits: '0', total_credits_purchased: '0', race_entries: 2 }, + payment: { credits_purchased: '0', payment_type: 'cosmetic_pack' }, + grant: { + payment_id: 42, + user_id: 7, + source: 'product_confirmation', + credits_granted: '0', + purchase_progress_granted: '0', + race_entries_granted: 2, + packs_granted: [{ id: 'kenney-3d-characters' }], + premium_level_granted: null, + premium_level_previous: null, + status: 'active' + }, + raceLot: { id: 8, original_entries: 2, remaining_entries: 2, refunded_at: null }, + packEntitlements: [{ + pack_id: 'kenney-3d-characters', + source: 'product_purchase', + metadata: { paymentId: 42 } + }] + }); + const service = new PaymentRefundService({ db, walletService: null, logger }); + + const first = await service.refundPayment({ paymentId: 42, sendFunds: false }); + const second = await service.refundPayment({ paymentId: 42, sendFunds: false }); + + expect(first.refund.status).toBe('recorded'); + expect(second.existing).toBe(true); + expect(db.user.race_entries).toBe(0); + expect(db.raceLot.remaining_entries).toBe(0); + expect(db.raceLot.refunded_at).toBeTruthy(); + expect(db.packEntitlements).toHaveLength(0); + expect(db.raceTransactions).toHaveLength(1); + expect(db.grant.status).toBe('reversed'); + }); + + test('an escrowed race ticket makes the whole refund needs_review', async () => { + const db = fixture({ + user: { credits: '0', total_credits_purchased: '0', race_entries: 2 }, + payment: { credits_purchased: '0', payment_type: 'cosmetic_pack' }, + grant: { + payment_id: 42, + user_id: 7, + source: 'product_confirmation', + credits_granted: '0', + purchase_progress_granted: '0', + race_entries_granted: 2, + packs_granted: [], + premium_level_granted: null, + premium_level_previous: null, + status: 'active' + }, + raceLot: { id: 8, original_entries: 2, remaining_entries: 1, refunded_at: null } + }); + const walletService = { processPayout: jest.fn() }; + const service = new PaymentRefundService({ db, walletService, logger }); + + const result = await service.refundPayment({ paymentId: 42, sendFunds: true }); + + expect(result.refund.status).toBe('needs_review'); + expect(result.refund.error_message).toContain('consumed or escrowed'); + expect(walletService.processPayout).not.toHaveBeenCalled(); + expect(db.user.race_entries).toBe(2); + expect(db.raceLot.remaining_entries).toBe(1); + }); + + test('a pending unpaid invoice cannot deduct credits or send its expected amount', async () => { + const db = fixture({ + payment: { status: 'pending', received_amount: '0' } + }); + const walletService = { + processPayout: jest.fn().mockResolvedValue({ success: true, txHash }) + }; + const service = new PaymentRefundService({ db, walletService, logger }); + + const result = await service.refundPayment({ paymentId: 42, sendFunds: true }); + + expect(result.refund.status).toBe('needs_review'); + expect(result.refund.amount).toBe('0'); + expect(result.refund.error_message).toContain('confirmed payment'); + expect(walletService.processPayout).not.toHaveBeenCalled(); + expect(db.user.credits).toBe('6'); + expect(db.creditTransactions).toHaveLength(0); + }); + + test('a stranded processing row requires review and is never resent', async () => { + const db = fixture({ + payment: { status: 'refunded' }, + refund: { + id: 11, + payment_id: 42, + user_id: 7, + status: 'processing', + amount: '900', + payout_address: '5'.repeat(95), + credits_deducted: '4', + reason: 'Admin refund', + tx_hash: null, + error_message: null, + requested_at: new Date(), + processing_started_at: new Date() + } + }); + const walletService = { processPayout: jest.fn() }; + const service = new PaymentRefundService({ db, walletService, logger }); + + const result = await service.refundPayment({ paymentId: 42, sendFunds: true }); + + expect(result.refund.status).toBe('processing'); + expect(walletService.processPayout).not.toHaveBeenCalled(); + expect(db.paymentUpdateCount).toBe(0); + expect(db.creditTransactions).toHaveLength(0); + }); + + test('a legacy refunded payment is imported without repeating unknown side effects', async () => { + const db = fixture({ + payment: { status: 'refunded', received_amount: '900' } + }); + const walletService = { processPayout: jest.fn() }; + const service = new PaymentRefundService({ db, walletService, logger }); + + const result = await service.refundPayment({ paymentId: 42, sendFunds: true }); + + expect(result.legacyImported).toBe(true); + expect(result.existing).toBe(true); + expect(result.refund.status).toBe('recorded'); + expect(result.refund.amount).toBe('900'); + expect(walletService.processPayout).not.toHaveBeenCalled(); + expect(db.paymentUpdateCount).toBe(0); + expect(db.user.credits).toBe('6'); + expect(db.creditTransactions).toHaveLength(0); + }); +}); diff --git a/test/paymentSafety_ultra.test.js b/test/paymentSafety_ultra.test.js index 6588488..28aadbe 100644 --- a/test/paymentSafety_ultra.test.js +++ b/test/paymentSafety_ultra.test.js @@ -69,7 +69,8 @@ describe('M1 — single payout ambiguous failure', () => { const reviewCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'needs_review'); expect(reviewCall).toBeDefined(); - expect(reviewCall[1][2]).toBe(42); // payout id + expect(reviewCall[1][2]).toBeNull(); // no valid transaction hash was observed + expect(reviewCall[1][3]).toBe(42); // payout id const failedCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'failed'); expect(failedCall).toBeUndefined(); expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('single_payout_failed', expect.any(Object)); @@ -88,7 +89,7 @@ describe('M1 — single payout ambiguous failure', () => { await gmm._processPendingPayouts(); - const pendingCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'pending' && /id = \$3/i.test(c[0])); + const pendingCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'pending' && /id = \$4/i.test(c[0])); expect(pendingCall).toBeDefined(); expect(gmm.alertService.sendAlert).not.toHaveBeenCalled(); }); @@ -124,25 +125,30 @@ describe('M3 — atomic single_game payment claim', () => { }); }); -describe('M4 — completeGame never inserts an unattached payout', () => { - test('refuses payout when the paying user cannot be resolved from the game row', async () => { +describe('M4 — completeGame durably records every committed liability', () => { + test('quarantines a liability when the paying identity cannot be resolved', async () => { const { gmm, db } = buildGmm(); + db.withTransaction.mockImplementation(async (cb) => cb(db)); db.query .mockResolvedValueOnce({ rows: [{ id: 1, /* no user_id */ game_mode: 'PAID_SINGLE', payout_address: 'addr' }] }) // game record - .mockResolvedValue({ rows: [] }); // completion UPDATE + existing-payout check + .mockResolvedValueOnce({ rows: [] }) // completion UPDATE + .mockResolvedValueOnce({ rows: [] }) // existing-payout check + .mockResolvedValueOnce({ rows: [{ id: 88 }], rowCount: 1 }); // needs_review liability const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {}); - expect(result.reason).toBe('Paying user not resolved'); + expect(result.payout).toEqual(expect.objectContaining({ payoutId: 88, status: 'needs_review' })); const inserted = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0])); - expect(inserted).toBeUndefined(); - expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('payout_unresolved_user', expect.any(Object)); + expect(inserted).toBeDefined(); + expect(inserted[1]).toContain('solo_winner_identity_review'); + expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('payout_liability_needs_review', expect.any(Object)); }); }); describe('M5 — payout amount is clamped to configured bounds', () => { test('caps an over-max payout and alerts', async () => { const { gmm, db } = buildGmm(); + db.withTransaction.mockImplementation(async (cb) => cb(db)); db.query .mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'addr', @@ -164,6 +170,7 @@ describe('M5 — payout amount is clamped to configured bounds', () => { test('skips a below-min payout entirely (no insert)', async () => { const { gmm, db } = buildGmm(); + db.withTransaction.mockImplementation(async (cb) => cb(db)); db.query .mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'addr', diff --git a/test/payment_flow.integration.test.js b/test/payment_flow.integration.test.js index 7fde186..2f366ea 100644 --- a/test/payment_flow.integration.test.js +++ b/test/payment_flow.integration.test.js @@ -1,5 +1,17 @@ const QueueManager = require('../src/network/queueManager'); const PaymentHandlers = require('../src/network/paymentHandlers'); +const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy'); + +function legalAcknowledgement(gameModeManager) { + const disclosure = buildCommerceDisclosure(gameModeManager, process.env); + return { + policyVersion: disclosure.policyVersion, + ageEligible: true, + termsRead: true, + riskAccepted: true, + testnetUnderstood: disclosure.service.isTestNetwork === true + }; +} /** * Integration-style tests for the payment confirmation flow. @@ -88,16 +100,18 @@ function buildHarness() { const fakeSocket = { id: socketId, emit: (event, data) => events.push({ event, data }) }; return { - events, queueManager, paymentHandlers, fakeSocket, + events, queueManager, paymentHandlers, fakeSocket, gameModeManager, getMonitorCallback: () => monitorCallback }; } describe('Payment flow integration: confirm-after-block immediate start', () => { test('unconfirmed at block tick then confirmed triggers immediate game', async () => { - const { events, queueManager, paymentHandlers, fakeSocket, getMonitorCallback } = buildHarness(); + const { events, queueManager, paymentHandlers, fakeSocket, gameModeManager, getMonitorCallback } = buildHarness(); - await paymentHandlers.createAndShowPaymentRequest(fakeSocket); + await paymentHandlers.createAndShowPaymentRequest(fakeSocket, { + legalAcknowledgement: legalAcknowledgement(gameModeManager) + }); const monitorCallback = getMonitorCallback(); expect(monitorCallback).toBeDefined(); @@ -122,9 +136,11 @@ describe('Payment flow integration: confirm-after-block immediate start', () => }); test('confirmed but UNDERPAID does not start a game or grant anything', async () => { - const { events, queueManager, paymentHandlers, fakeSocket, getMonitorCallback } = buildHarness(); + const { events, queueManager, paymentHandlers, fakeSocket, gameModeManager, getMonitorCallback } = buildHarness(); - await paymentHandlers.createAndShowPaymentRequest(fakeSocket); + await paymentHandlers.createAndShowPaymentRequest(fakeSocket, { + legalAcknowledgement: legalAcknowledgement(gameModeManager) + }); const monitorCallback = getMonitorCallback(); expect(monitorCallback).toBeDefined(); diff --git a/test/payment_payout_comprehensive.test.js b/test/payment_payout_comprehensive.test.js index d17bfff..8f073fd 100644 --- a/test/payment_payout_comprehensive.test.js +++ b/test/payment_payout_comprehensive.test.js @@ -75,6 +75,10 @@ describe('Payment & Payout Comprehensive Tests', () => { gmm = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfig); }); + afterEach(() => { + gmm?.shutdown(); + }); + describe('Credit Deduction - Negative Balance Prevention', () => { test('should fail gracefully when user has insufficient credits', async () => { const user = { id: 1, credits: 0 }; @@ -242,9 +246,13 @@ describe('Payment & Payout Comprehensive Tests', () => { // Step 2: withTransaction — client.query calls: mockDb._mockClient.query - .mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed' RETURNING id - .mockResolvedValueOnce({ rows: [{ credits: 15 }] }) // UPDATE users SET credits = credits + 15 - .mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions + .mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] }) // snapshot product promise + .mockResolvedValueOnce({ rows: [{ id: 123 }] }) // mark payment confirmed + .mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] }) // lock user + .mockResolvedValueOnce({ rows: [{ id: 1, credits: 15, total_credits_purchased: 15 }] }) + .mockResolvedValueOnce({ rows: [] }) // credit transaction + .mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 }) // entitlement grant + .mockResolvedValueOnce({ rows: [] }); // active pack grants const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { credits: 10, bonus: 5 }); @@ -261,9 +269,13 @@ describe('Payment & Payout Comprehensive Tests', () => { // Step 2: withTransaction — client.query calls: mockDb._mockClient.query - .mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed' - .mockResolvedValueOnce({ rows: [{ credits: 30 }] }) // UPDATE users - .mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions + .mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] }) + .mockResolvedValueOnce({ rows: [{ id: 123 }] }) + .mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] }) + .mockResolvedValueOnce({ rows: [{ id: 1, credits: 25, total_credits_purchased: 25 }] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [] }); const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null); @@ -279,9 +291,13 @@ describe('Payment & Payout Comprehensive Tests', () => { // Step 2: withTransaction — client.query calls: mockDb._mockClient.query - .mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed' - .mockResolvedValueOnce({ rows: [{ credits: 10 }] }) // UPDATE users - .mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions + .mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] }) + .mockResolvedValueOnce({ rows: [{ id: 123 }] }) + .mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] }) + .mockResolvedValueOnce({ rows: [{ id: 1, credits: 10, total_credits_purchased: 10 }] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [] }); const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null); @@ -295,6 +311,9 @@ describe('Payment & Payout Comprehensive Tests', () => { gmm.gameMode = 'PAID_SINGLE'; gmm.singleGamePrice = 100000000000; gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; + // Completion owns a real transaction; route every statement through the ordered + // root query mock so these tests exercise completion + obligation atomically. + mockDb.withTransaction.mockImplementation(async (callback) => callback(mockDb)); }); test('processes winning game with payout correctly', async () => { @@ -371,12 +390,15 @@ describe('Payment & Payout Comprehensive Tests', () => { mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1, payout_address: null }] // No address }); + mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1001 }], rowCount: 1 }); // review liability const result = await gmm.completeGame('socket1', 'game123', true, false, {}); expect(result.success).toBe(true); - expect(result.payout).toBeNull(); - expect(result.reason).toBe('No payout address'); + expect(result.payout).toEqual(expect.objectContaining({ + payoutId: 1001, + status: 'needs_review' + })); expect(mockWalletService.processPayout).not.toHaveBeenCalled(); }); @@ -654,8 +676,13 @@ describe('Edge Cases and Error Handling', () => { gmm = new GameModeManager(mockDb, mockWalletService, createMockDebugManager(), createMockPaymentConfig()); }); + afterEach(() => { + gmm?.shutdown(); + }); + test('handles payout insert failure gracefully', async () => { gmm.gameMode = 'PAID_SINGLE'; + mockDb.withTransaction.mockImplementation(async (callback) => callback(mockDb)); mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }] }); mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update @@ -668,9 +695,11 @@ describe('Edge Cases and Error Handling', () => { const result = await gmm.completeGame('socket1', 'game123', true, false, {}); - expect(result.success).toBe(true); - expect(result.payout).toBeNull(); - expect(result.payoutError).toBeDefined(); + // The payout insert and game completion share one transaction. A failed obligation + // therefore fails/rolls back completion instead of leaving a completed unpaid win. + expect(result.success).toBe(false); + expect(result.payout).toBeUndefined(); + expect(result.error).toBeDefined(); }); test('handles database error during credits deduction', async () => { diff --git a/test/payoutAdmission.test.js b/test/payoutAdmission.test.js new file mode 100644 index 0000000..339f949 --- /dev/null +++ b/test/payoutAdmission.test.js @@ -0,0 +1,171 @@ +const GameModeManager = require('../src/game/gameModeManager'); +const { + PAYOUT_ADMISSION_LOCK_KEY, + reservePayoutCapacity +} = require('../src/services/payoutAdmissionService'); + +describe('serialized payout-liability admission', () => { + const oldCritical = process.env.BALANCE_CRITICAL; + beforeEach(() => { process.env.BALANCE_CRITICAL = '50'; }); + afterAll(() => { + if (oldCritical === undefined) delete process.env.BALANCE_CRITICAL; + else process.env.BALANCE_CRITICAL = oldCritical; + }); + + function capacityClient() { + const calls = []; + return { + calls, + query: jest.fn(async (sql, params) => { + calls.push({ sql, params }); + if (/AS payout_rows/i.test(sql)) { + return { rows: [{ payout_rows: '100', solo_commitments: '200', match_commitments: '300' }] }; + } + return { rows: [] }; + }) + }; + } + + test('requires unlocked balance for outstanding + new liability + reserve under one advisory lock', async () => { + const client = capacityClient(); + const walletService = { isHealthy: true, getBalance: jest.fn().mockResolvedValue({ unlocked_balance: '1050' }) }; + + const result = await reservePayoutCapacity({ + client, + walletService, + newLiability: '400', + gameModeManager: null + }); + + expect(result.required).toBe(1050n); + expect(result.outstanding).toBe(600n); + expect(client.calls[0]).toEqual(expect.objectContaining({ + params: [PAYOUT_ADMISSION_LOCK_KEY] + })); + expect(client.calls[0].sql).toMatch(/pg_advisory_xact_lock/i); + }); + + test('fails closed one atomic unit below exact committed coverage', async () => { + const client = capacityClient(); + await expect(reservePayoutCapacity({ + client, + walletService: { isHealthy: true, getBalance: async () => ({ unlocked_balance: '1049' }) }, + newLiability: '400' + })).rejects.toMatchObject({ code: 'PAYOUT_RESERVE_INSUFFICIENT' }); + }); + + test('counts only match liabilities that can still owe a payout', async () => { + const client = capacityClient(); + + await reservePayoutCapacity({ + client, + walletService: { isHealthy: true, getBalance: async () => ({ unlocked_balance: '1050' }) }, + newLiability: '400' + }); + + const liabilityQuery = client.calls.find(({ sql }) => /AS payout_rows/i.test(sql)).sql; + expect(liabilityQuery).toMatch( + /FROM matches m[\s\S]*m\.status IN \('starting', 'active', 'finished'\)[\s\S]*NOT EXISTS/i + ); + expect(liabilityQuery).not.toMatch(/m\.status IN \([^)]*'cancelled'/i); + }); + + function creditManager({ payoutEligible, getBalance }) { + const calls = []; + const client = { + query: jest.fn(async (sql) => { + calls.push(sql); + if (/AS payout_rows/i.test(sql)) { + return { rows: [{ payout_rows: '0', solo_commitments: '0', match_commitments: '0' }] }; + } + if (/UPDATE users[\s\S]*credits = credits -/i.test(sql)) { + return { rows: [{ credits: 1, total_credits_purchased: 2, payout_address: 'addr' }], rowCount: 1 }; + } + if (/UPDATE games SET game_mode = 'PAID_CREDITS'/i.test(sql)) { + return { rows: [{ id: 9 }], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + }) + }; + const manager = Object.create(GameModeManager.prototype); + manager.db = { withTransaction: async callback => callback(client) }; + manager.walletService = { isHealthy: true, getBalance }; + manager.creditsPerGameCost = 1; + manager.requiresPayoutAddressForMode = () => payoutEligible; + manager._computePayoutSnapshot = () => ({ + eligible: payoutEligible, + escapeAmount: '200', + treasureAmount: '300', + escapeMult: 2, + treasureMult: 3, + terms: { version: 1, eligible: payoutEligible } + }); + return { manager, calls }; + } + + test('existing-credit payout game is denied before debit when balance is unknown', async () => { + const { manager, calls } = creditManager({ + payoutEligible: true, + getBalance: jest.fn().mockRejectedValue(new Error('wallet offline')) + }); + + await expect(manager._processGameStartWithCredits( + { id: 1, credits: 2, payout_address: 'addr' }, 'sock', 'seed' + )).rejects.toMatchObject({ code: 'PAYOUT_RESERVE_UNVERIFIED' }); + expect(calls.some(sql => /credits = credits -/i.test(sql))).toBe(false); + }); + + test('payout-disabled prestige start stays open and never reads wallet balance', async () => { + const getBalance = jest.fn().mockRejectedValue(new Error('wallet offline')); + const { manager } = creditManager({ payoutEligible: false, getBalance }); + + const result = await manager._processGameStartWithCredits( + { id: 1, credits: 2, payout_address: null }, 'sock', 'seed' + ); + + expect(result.success).toBe(true); + expect(getBalance).not.toHaveBeenCalled(); + }); + + test('authoritative credit start rejects a missing required payout address before debit', async () => { + const { manager, calls } = creditManager({ payoutEligible: true, getBalance: async () => ({ unlocked_balance: '1000' }) }); + + const result = await manager._processGameStartWithCredits( + { id: 1, credits: 2, payout_address: null }, 'sock', 'seed' + ); + + expect(result).toEqual(expect.objectContaining({ success: false })); + expect(calls).toEqual([]); + }); + + test('authoritative direct-payment claim rechecks the locked user payout address', async () => { + const calls = []; + const client = { + query: jest.fn(async (sql) => { + calls.push(sql); + if (/SELECT \* FROM payments/i.test(sql)) { + return { rows: [{ id: 4, user_id: 1, status: 'confirmed' }], rowCount: 1 }; + } + if (/SELECT id, payout_address[\s\S]*FROM users/i.test(sql)) { + return { rows: [{ id: 1, payout_address: null }], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + }) + }; + const manager = Object.create(GameModeManager.prototype); + manager.db = { withTransaction: async callback => callback(client) }; + manager._computePayoutSnapshot = () => ({ + eligible: false, escapeAmount: '0', treasureAmount: '0', terms: {} + }); + manager._requiresPaidFairnessV2 = () => false; + manager.requiresPayoutAddressForMode = () => true; + + const result = await manager._processGameStartWithPayment( + { id: 1, payout_address: 'stale-client-cache' }, { id: 4 }, 'seed' + ); + + expect(result).toEqual(expect.objectContaining({ success: false })); + expect(calls.some(sql => /UPDATE games/i.test(sql))).toBe(false); + expect(calls.some(sql => /UPDATE payments/i.test(sql))).toBe(false); + }); +}); diff --git a/test/payoutDisclosureClient.test.js b/test/payoutDisclosureClient.test.js new file mode 100644 index 0000000..03793fa --- /dev/null +++ b/test/payoutDisclosureClient.test.js @@ -0,0 +1,20 @@ +const fs = require('fs'); +const path = require('path'); + +describe('browser payout disclosures', () => { + test('renders server-computed outcomes and never recreates multiplier amounts', () => { + const paymentUi = fs.readFileSync( + path.join(__dirname, '../html/js/ui/paymentUI.js'), + 'utf8' + ); + const help = fs.readFileSync( + path.join(__dirname, '../html/js/ui/helpModal.js'), + 'utf8' + ); + + expect(paymentUi).toContain('this.config?.payoutOutcomes?.[mode]'); + expect(paymentUi).toContain('configured cap applied'); + expect(paymentUi).not.toMatch(/parseFloat\([^\n]+\)\s*\*\s*[^\n]*multiplier/i); + expect(help).not.toMatch(/Escape pays[\s\S]{0,80}×/); + }); +}); diff --git a/test/payoutRetry.test.js b/test/payoutRetry.test.js index 726c740..41bdc35 100644 --- a/test/payoutRetry.test.js +++ b/test/payoutRetry.test.js @@ -70,18 +70,131 @@ describe('PayoutRetryService.retryPayout', () => { .mockResolvedValue({ rows: [] }); const wallet = { checkTransactionStatus: jest.fn(), - processPayout: jest.fn().mockResolvedValue({ success: true, txHash: 'txNEW', fee: 10 }) + processPayout: jest.fn().mockResolvedValue({ success: true, txHash: 'd'.repeat(64), fee: 10 }) }; - await svcWith(db, wallet).retryPayout({ id: 7, user_id: 1, amount: '100', payout_address: 'wow1addr', status: 'failed', retry_count: 0 }); + await svcWith(db, wallet).retryPayout({ + id: 7, + user_id: 1, + amount: '100', + payout_address: 'wow1addr', + status: 'processing', + retry_count: 0, + last_error: 'not enough unlocked money to transfer' + }); // tx_hash + completion happen in the SAME statement (atomic). const completion = findCall(client.query, /UPDATE payouts[\s\S]*tx_hash[\s\S]*status = 'completed'/i); expect(completion).toBeDefined(); - expect(completion[1]).toContain('txNEW'); + expect(completion[1]).toContain('d'.repeat(64)); // No standalone pre-transaction tx_hash write on db.query. expect(findCall(db.query, /UPDATE payouts SET tx_hash = \$1, fee = \$2 WHERE id/i)).toBeUndefined(); // Stats counted once. expect(findCall(client.query, /UPDATE users[\s\S]*total_amount_won/i)).toBeDefined(); }); + + test('ambiguous failure without a tx hash is never sent again automatically', async () => { + const { db } = makeHarness(); + const wallet = { checkTransactionStatus: jest.fn(), processPayout: jest.fn() }; + + await svcWith(db, wallet).retryPayout({ + id: 8, + user_id: 1, + amount: '100', + payout_address: 'wow1addr', + status: 'processing', + retry_count: 1, + last_error: 'RPC timeout after broadcast' + }); + + expect(wallet.processPayout).not.toHaveBeenCalled(); + const review = findCall(db.query, /SET status = 'needs_review'/i); + expect(review).toBeDefined(); + }); + + test('a recorded transaction hash that is temporarily not found is never resent', async () => { + const { db } = makeHarness(); + const wallet = { + checkTransactionStatus: jest.fn().mockResolvedValue({ exists: false }), + processPayout: jest.fn() + }; + + await svcWith(db, wallet).retryPayout({ + id: 11, + user_id: 1, + amount: '100', + payout_address: 'wow1addr', + tx_hash: 'possibly-broadcast-tx', + status: 'processing', + retry_count: 1 + }); + + expect(wallet.processPayout).not.toHaveBeenCalled(); + expect(findCall(db.query, /SET status = 'needs_review'/i)).toBeDefined(); + }); +}); + +describe('PayoutRetryService.processRetries', () => { + test('stale processing claims are quarantined and never selected for resend', async () => { + const db = { + query: jest.fn().mockResolvedValue({ rows: [{ id: 44 }], rowCount: 1 }), + withTransaction: jest.fn(async (fn) => fn({ + query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 }) + })) + }; + const wallet = { processPayout: jest.fn() }; + const service = new PayoutRetryService({ + db, + walletService: wallet, + staleProcessingMs: 60000, + debugManager: { CONSOLE_LOGGING: false } + }); + service.retryPayout = jest.fn(); + + await service.processRetries(); + + const recovery = findCall(db.query, /status = 'needs_review'[\s\S]*status = 'processing'/i); + expect(recovery).toBeDefined(); + expect(service.retryPayout).not.toHaveBeenCalled(); + expect(wallet.processPayout).not.toHaveBeenCalled(); + }); + + test('claims failed rows as processing in the same transaction before dispatch', async () => { + const candidate = { + id: 9, + user_id: 1, + payout_address: 'wow1addr', + amount: '100', + status: 'processing', + retry_count: 0, + last_error: 'insufficient unlocked balance' + }; + const client = { query: jest.fn() }; + client.query + .mockResolvedValueOnce({ rows: [{ ...candidate, status: 'failed' }] }) + .mockResolvedValueOnce({ rows: [candidate] }); + const db = { + query: jest.fn().mockResolvedValue({ rows: [] }), + withTransaction: jest.fn(async (fn) => fn(client)) + }; + const service = svcWith(db, { processPayout: jest.fn() }); + service.retryPayout = jest.fn().mockResolvedValue(); + + await service.processRetries(); + + expect(findCall(client.query, /SET status = 'processing'/i)).toBeDefined(); + expect(service.retryPayout).toHaveBeenCalledWith(expect.objectContaining({ id: 9, status: 'processing' })); + }); + + test('master predicate prevents both scan and dispatch', async () => { + const db = { query: jest.fn().mockResolvedValue({ rows: [] }), withTransaction: jest.fn() }; + const wallet = { processPayout: jest.fn() }; + const service = new PayoutRetryService({ db, walletService: wallet, isEnabled: () => false }); + + await service.processRetries(); + await service.retryPayout({ id: 10 }); + + expect(db.withTransaction).not.toHaveBeenCalled(); + expect(wallet.processPayout).not.toHaveBeenCalled(); + }); }); diff --git a/test/payoutSnapshot.test.js b/test/payoutSnapshot.test.js index b671b0e..4b575a7 100644 --- a/test/payoutSnapshot.test.js +++ b/test/payoutSnapshot.test.js @@ -8,12 +8,12 @@ const GameModeManager = require('../src/game/gameModeManager'); const createMockDb = () => { - const mockClient = { query: jest.fn().mockResolvedValue({ rows: [] }) }; - return { + const db = { query: jest.fn().mockResolvedValue({ rows: [] }), - withTransaction: jest.fn().mockImplementation(async (cb) => cb(mockClient)), - _mockClient: mockClient + withTransaction: jest.fn() }; + db.withTransaction.mockImplementation(async (cb) => cb(db)); + return db; }; const createMockPaymentConfig = () => ({ @@ -106,4 +106,75 @@ describe('Payout uses the snapshot recorded at game start', () => { const insertedPayout = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0])); expect(insertedPayout).toBeUndefined(); }); + + test('master payout switch prevents payout creation even when direct payouts are enabled', async () => { + const { gmm, db } = buildGmm(); + gmm.payoutsEnabled = false; + gmm.directPayoutEnabled = true; + + db.query + .mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] }) + .mockResolvedValue({ rows: [] }); + + const result = await gmm.completeGame('socket1', 'game-master-off', true, true, { moves: 10 }); + + expect(result.success).toBe(true); + expect(db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]))).toBeUndefined(); + expect(gmm._scheduleBatchPayout).not.toHaveBeenCalled(); + }); + + test('a start-time eligible liability survives a mid-game master kill switch', async () => { + const { gmm, db } = buildGmm(); + gmm.payoutsEnabled = false; // dispatch is paused after the game committed its terms + db.query + .mockResolvedValueOnce({ rows: [{ + id: 1, + user_id: 1, + status: 'active', + game_mode: 'PAID_SINGLE', + payout_address: 'wow1addr', + payout_eligible: true, + payout_terms: { + version: 1, + eligible: true, + escapeAmount: '222', + treasureAmount: '333', + escapeMultiplier: 2, + treasureMultiplier: 3, + minAmount: '1', + maxAmount: '1000' + } + }] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'wow1addr' }] }) + .mockResolvedValueOnce({ rows: [{ id: 901 }], rowCount: 1 }); + + const result = await gmm.completeGame('socket1', 'committed-game', true, false, {}); + + expect(result.payout).toEqual(expect.objectContaining({ payoutId: 901, amount: '222' })); + expect(db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]))).toBeDefined(); + }); + + test('a start-time ineligible game never gains a payout after policy is enabled', async () => { + const { gmm, db } = buildGmm(); + gmm.payoutsEnabled = true; + gmm.directPayoutEnabled = true; + db.query + .mockResolvedValueOnce({ rows: [{ + id: 2, + user_id: 1, + status: 'active', + game_mode: 'PAID_SINGLE', + payout_eligible: false, + payout_terms: { version: 1, mode: 'PAID_SINGLE', eligible: false } + }] }) + .mockResolvedValueOnce({ rows: [] }); + + const result = await gmm.completeGame('socket1', 'ineligible-game', true, true, {}); + + expect(result.success).toBe(true); + expect(result.payout).toBeNull(); + expect(db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]))).toBeUndefined(); + }); }); diff --git a/test/prestigeLeaderboard.test.js b/test/prestigeLeaderboard.test.js new file mode 100644 index 0000000..b682a12 --- /dev/null +++ b/test/prestigeLeaderboard.test.js @@ -0,0 +1,38 @@ +const { + normalizeLeaderboardPeriod, + prestigeCutoff, + queryPrestigeLeaderboard +} = require('../src/network/prestigeLeaderboard'); + +describe('prestige leaderboard periods', () => { + const now = new Date('2026-07-21T16:00:00.000Z'); + + test.each([ + ['week', '2026-07-14T16:00:00.000Z'], + ['month', '2026-06-21T16:00:00.000Z'], + ['all', null], + ['unexpected', null] + ])('%s resolves to the exact cutoff', (period, expected) => { + const cutoff = prestigeCutoff(period, now); + expect(cutoff?.toISOString() || null).toBe(expected); + }); + + test('unknown periods normalize to all', () => { + expect(normalizeLeaderboardPeriod('YEAR')).toBe('all'); + }); + + test.each(['week', 'month', 'all'])('queries match rows with a strict boundary for %s', async (period) => { + const db = { query: jest.fn().mockResolvedValue({ rows: [] }) }; + await queryPrestigeLeaderboard(db, { period, limit: 17, now }); + + const [sql, params] = db.query.mock.calls[0]; + expect(sql).toContain("m.economy = 'credits_prestige'"); + expect(sql).toContain('me.placement = 1'); + expect(sql).toContain('m.winner_user_id = me.user_id'); + expect(sql).toContain("m.ruleset_id <> 'coop-escape'"); + expect(sql).not.toContain('me.escaped'); + expect(sql).toContain('m.ended_at > $1::timestamptz'); + expect(params[0]?.toISOString() || null).toBe(prestigeCutoff(period, now)?.toISOString() || null); + expect(params[1]).toBe(17); + }); +}); diff --git a/test/pvpCaptureHarness.test.js b/test/pvpCaptureHarness.test.js new file mode 100644 index 0000000..e9ffacc --- /dev/null +++ b/test/pvpCaptureHarness.test.js @@ -0,0 +1,200 @@ +'use strict'; + +const { + CAPTURE_RULESET_IDS, + DEFAULT_SEED, + assertCaptureSafety, + assertCaptureStateEnvironment, + capturePageHtml, + capturePresentation, + captureResultSummary, + captureStateHash, + captureTraceHeader, + createCaptureRoom, + parseArgs, + planBotMove, + validateOptions +} = require('../src/scripts/pvp-capture'); + +describe('deterministic PvP capture harness', () => { + function options(extra = {}) { + return validateOptions(parseArgs([ + '--confirm-local-free-only', + '--no-video', + ...extra.args || [] + ])); + } + + test('requires an explicit acknowledgement and can never run in production', () => { + const safe = options(); + expect(() => assertCaptureSafety({ NODE_ENV: 'development' }, { ...safe, confirmed: false })) + .toThrow('--confirm-local-free-only'); + expect(() => assertCaptureSafety({ NODE_ENV: 'production' }, safe)) + .toThrow('NODE_ENV=development or NODE_ENV=test'); + expect(() => assertCaptureSafety({ NODE_ENV: 'development', PAYOUTS_ENABLED: 'true' }, safe)) + .toThrow('money-enabled environments'); + expect(() => assertCaptureSafety({ NODE_ENV: 'test', MATCH_CRYPTO_RACE_ENABLED: '1' }, safe)) + .toThrow('MATCH_CRYPTO_RACE_ENABLED'); + expect(() => assertCaptureSafety({ NODE_ENV: 'test', PAYMENTS_ENABLED: 'true' }, safe)) + .toThrow('PAYMENTS_ENABLED'); + expect(() => assertCaptureSafety({ NODE_ENV: 'test', CREDITS_PAYOUT_ENABLED: 'true' }, safe)) + .toThrow('CREDITS_PAYOUT_ENABLED'); + expect(() => assertCaptureSafety({ NODE_ENV: 'test', WALLET_RPC_URL: 'http://127.0.0.1:18083' }, safe)) + .toThrow('wallet-configured environments'); + expect(assertCaptureSafety({ NODE_ENV: 'test' }, safe)).toBe(true); + }); + + test('refuses inherited gameplay tuning so a seed has one authoritative replay', () => { + expect(() => assertCaptureStateEnvironment({ DIFFICULTY_PRESET: 'casino' })) + .toThrow('inherited gameplay tuning: DIFFICULTY_PRESET'); + expect(() => assertCaptureSafety({ NODE_ENV: 'test', DUNGEON_WIDTH: '70' }, options())) + .toThrow('inherited gameplay tuning: DUNGEON_WIDTH'); + expect(() => assertCaptureSafety({ NODE_ENV: 'development', MONSTER_SPEED: '2' }, options())) + .toThrow('inherited gameplay tuning: MONSTER_SPEED'); + expect(assertCaptureStateEnvironment({})).toBe(true); + }); + + test('accepts only local renderer/capture options and a fixed-width seed', () => { + expect(parseArgs(['--help']).help).toBe(true); + expect(() => validateOptions(parseArgs([ + '--confirm-local-free-only', '--mode', '3d' + ]))).toThrow('network-loaded renderers are forbidden'); + expect(() => validateOptions(parseArgs([ + '--confirm-local-free-only', '--seed', '1234' + ]))).toThrow('64 hexadecimal'); + expect(() => validateOptions(parseArgs([ + '--confirm-local-free-only', '--ruleset', 'crypto-race' + ]))).toThrow('--ruleset must be one of'); + expect(() => validateOptions(parseArgs([ + '--confirm-local-free-only', '--camera', 'wallet' + ]))).toThrow('--camera must be action or focus'); + expect(() => parseArgs(['--url', 'https://example.com'])).toThrow('Unknown option'); + }); + + test('creates every built-in multiplayer ruleset deterministically with no money state', () => { + const config = options(); + const first = createCaptureRoom(config); + const second = createCaptureRoom(config); + + expect(config.seed).toBe(DEFAULT_SEED); + expect(first.economy).toBe('free'); + expect(first.ruleset.id).toBe('race'); + expect(first.ruleset.economy.model).toBe('free'); + expect(first.entryFeeAtomic).toBe(0); + expect(first.potAtomic).toBe(0); + expect(first.houseFeeAtomic).toBe(0); + expect(Array.from(first.occupants.keys())).toEqual(['bot-1', 'bot-2', 'bot-3', 'bot-4']); + expect(first.seedHash).toBe(second.seedHash); + expect(first.dungeon.map).toEqual(second.dungeon.map); + expect(first.dungeon.exit).toEqual(second.dungeon.exit); + expect(first.dungeon.treasure).toEqual(second.dungeon.treasure); + + for (const rulesetId of CAPTURE_RULESET_IDS) { + const room = createCaptureRoom(options({ args: ['--ruleset', rulesetId] })); + expect(room.ruleset.id).toBe(rulesetId); + expect(room.economy).toBe('free'); + expect(room.ruleset.economy.model).toBe('free'); + expect(room.entryFeeAtomic).toBe(0); + expect(room.potAtomic).toBe(0); + expect(room.houseFeeAtomic).toBe(0); + } + }); + + test.each(CAPTURE_RULESET_IDS)('%s uses deterministic bot inputs and authoritative state', rulesetId => { + const config = options({ args: ['--ruleset', rulesetId, '--ticks', '30'] }); + const rooms = [createCaptureRoom(config), createCaptureRoom(config)]; + rooms.forEach(room => room.start()); + + for (let tick = 0; tick < 30; tick++) { + for (const room of rooms) { + const state = room.toGameState(); + for (const botId of room.occupants.keys()) { + const move = planBotMove(botId, state); + if (Math.abs(move.dx) + Math.abs(move.dy) === 1) { + room.queueMove(botId, move.dx, move.dy); + } + } + room.resolveTick(); + } + expect(captureStateHash(rooms[0].toGameState())) + .toBe(captureStateHash(rooms[1].toGameState())); + } + }); + + test('last-alive bots pursue a rival instead of following the race exit', () => { + const move = planBotMove('bot-1', { + tick: 1, + ruleset: { id: 'last-alive' }, + visibleTiles: [["'1", "'1", "'1"]], + exit: [0, 0], + players: [ + { id: 'bot-1', x: 0, y: 0, alive: true, finished: false }, + { id: 'bot-2', x: 2, y: 0, alive: true, finished: false } + ] + }); + expect(move).toEqual({ dx: 1, dy: 0 }); + }); + + test('presentation and final copy are ruleset-correct, including cooperative results', () => { + expect(capturePresentation('last-alive')).toEqual(expect.objectContaining({ + title: 'LAST ALIVE', activeNoun: 'STANDING' + })); + expect(captureResultSummary({ + ruleset: { id: 'score-attack' }, winnerId: 'bot-2', tickCount: 55 + }, [{ id: 'bot-2', name: 'Nyx', score: 912 }])).toEqual(expect.objectContaining({ + headline: 'NYX TOPS THE BOARD', detail: expect.stringContaining('912 POINTS') + })); + expect(captureResultSummary({ + ruleset: { id: 'coop-escape' }, winnerId: 'bot-1', tickCount: 40 + }, [ + { id: 'bot-1', escaped: true }, + { id: 'bot-2', escaped: true } + ])).toEqual(expect.objectContaining({ + headline: 'TEAM ESCAPED TOGETHER', cooperative: true + })); + expect(captureResultSummary({ + ruleset: { id: 'last-alive' }, winnerId: 'bot-2', tickCount: 80 + }, [ + { id: 'bot-1', name: 'Rook', alive: true, finished: false }, + { id: 'bot-2', name: 'Nyx', alive: true, finished: false } + ])).toEqual(expect.objectContaining({ headline: 'NYX LEADS AT THE BELL', winnerStatus: 'LEADER' })); + expect(captureResultSummary({ + ruleset: { id: 'race' }, winnerId: 'bot-1', tickCount: 10, endReason: 'capture_limit' + }, [{ id: 'bot-1', name: 'Rook' }])).toEqual(expect.objectContaining({ + headline: 'ROOK LEADS AT THE BELL', winnerStatus: 'LEADER' + })); + expect(captureResultSummary({ + ruleset: { id: 'last-alive' }, winnerId: 'bot-1', tickCount: 10, endReason: 'all_dead' + }, [{ id: 'bot-1', name: 'Rook', alive: false }])).toEqual(expect.objectContaining({ + headline: 'ROOK SURVIVED LONGEST', winnerStatus: 'LAST OUT' + })); + }); + + test('capture page uses local assets, a spectator focus camera, and no payment UI', () => { + const html = capturePageHtml(); + expect(html).toContain('focusPlayerId:cameraFocus'); + expect(html).toContain("cameraMode=query.get('camera')||'action'"); + expect(html).toContain('id="roster"'); + expect(html).toContain('actionFocusUntil'); + expect(html).toContain("if(entity.kind==='player')entity.label=null"); + expect(html).toContain('controls:false'); + expect(html).toContain('/socket.io/socket.io.js'); + expect(html).not.toMatch(/https?:\/\/(?!127\.0\.0\.1)/); + expect(html).not.toContain('crypto_race'); + expect(html).not.toContain('payment'); + }); + + test('parses the camera viewport as replay provenance', () => { + const config = options({ args: ['--viewport', '1080x1920', '--camera', 'focus'] }); + const room = createCaptureRoom(config); + const trace = captureTraceHeader(room, capturePresentation(room.ruleset), config); + expect(config.viewport).toEqual({ width: 1080, height: 1920 }); + expect(config.camera).toBe('focus'); + expect(trace.camera).toEqual({ + mode: 'focus', + focus: 'bot-1', + renderer: 'tiles', + viewport: { width: 1080, height: 1920 } + }); + }); +}); diff --git a/test/pvpUiPolish.test.js b/test/pvpUiPolish.test.js new file mode 100644 index 0000000..5d4b4cc --- /dev/null +++ b/test/pvpUiPolish.test.js @@ -0,0 +1,100 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +function loadMatchCopy() { + const context = {}; + context.window = context; + vm.createContext(context); + vm.runInContext( + fs.readFileSync(path.join(__dirname, '../html/js/ui/matchUiCopy.js'), 'utf8'), + context + ); + return context.MatchUiCopy; +} + +describe('multiplayer UI copy contract', () => { + const copy = loadMatchCopy(); + + test.each([ + ['race', 'racer', 'Reach the exit'], + ['last-alive', 'contender', 'Stay alive'], + ['score-attack', 'delver', 'highest score'], + ['coop-escape', 'teammate', 'Work together'] + ])('%s exposes its own objective and participant language', (id, participant, objective) => { + const mode = copy.normalize({ id }); + expect(mode.participant).toBe(participant); + expect(mode.objective).toContain(objective); + }); + + test.each([ + ['race', '🏆 You escaped first!'], + ['last-alive', '🏆 You are the last delver standing!'], + ['score-attack', '🏆 You set the high score!'] + ])('%s renders a mode-specific competitive victory', (id, expected) => { + const result = copy.finalResult({ + ruleset: { id }, + winnerId: 'me', + players: [{ id: 'me', placement: 1, score: 812 }] + }, 'me'); + expect(result.won).toBe(true); + expect(result.text).toContain(expected); + if (id === 'score-attack') expect(result.text).toContain('score 812'); + }); + + test('co-op reports one team outcome and never promotes placement #1 as an individual winner', () => { + const result = copy.finalResult({ + ruleset: { id: 'coop-escape' }, + reason: 'all_escaped', + winnerId: null, + players: [ + { id: 'me', placement: 2, escaped: true }, + { id: 'ally', placement: 1, escaped: true } + ] + }, 'me'); + + expect(result.won).toBe(true); + expect(result.text).toContain('🤝 Team escaped!'); + expect(result.text).toContain('you reached safety'); + expect(result.text).not.toMatch(/placed|winner/i); + }); + + test('live summaries use the active mode rather than race-only placement copy', () => { + expect(copy.liveSummary({ + ruleset: { id: 'last-alive' }, + players: [{ id: 'me', alive: true }, { id: 'out', alive: false }] + }, 'me')).toBe('1 contender still in'); + expect(copy.liveSummary({ + ruleset: { id: 'coop-escape' }, + players: [{ id: 'me', escaped: true, finished: true }, { id: 'ally', alive: true }] + }, 'me')).toBe('1/2 safe · 1 still exploring'); + }); +}); + +describe('public multiplayer controls and boards', () => { + const matchHtml = fs.readFileSync(path.join(__dirname, '../html/match.html'), 'utf8'); + const tavernHtml = fs.readFileSync(path.join(__dirname, '../html/tavern.html'), 'utf8'); + const matchClient = fs.readFileSync(path.join(__dirname, '../html/js/matchClient.js'), 'utf8'); + const leaderboard = fs.readFileSync(path.join(__dirname, '../html/js/ui/leaderboard.js'), 'utf8'); + const designDoc = fs.readFileSync(path.join(__dirname, '../docs/TAVERN_AND_MULTIPLAYER.md'), 'utf8'); + + test('match and Tavern D-pads are named groups activated through native click', () => { + expect(matchHtml).toMatch(/id="matchDpad"[^>]*role="group"[^>]*aria-label="Match movement controls"/); + expect(tavernHtml).toMatch(/id="tavernDpad"[^>]*role="group"[^>]*aria-label="Tavern movement controls"/); + expect(tavernHtml).toMatch(/id="spectatorDpad"[^>]*role="group"[^>]*aria-label="Match movement controls"/); + expect(matchClient).toContain("b.addEventListener('click'"); + expect(tavernHtml).toContain("b.addEventListener('click'"); + }); + + test('Prestige is a distinct public board with an explicit non-mixing disclosure', () => { + expect(leaderboard).toContain('data-board="prestige"'); + expect(leaderboard).toContain('Credit-entry competitive PvP scores only'); + expect(leaderboard).toContain('never mixed with Free or the Hall of Champions'); + }); + + test('operator docs require restart for mode/ruleset changes and do not promise future combat', () => { + expect(designDoc).toContain('restart the service'); + expect(designDoc).toContain('does not hot-reload match modes or rulesets'); + expect(designDoc).not.toMatch(/PvP combat (?:remains|is planned)/i); + }); +}); diff --git a/test/queueFairness.test.js b/test/queueFairness.test.js new file mode 100644 index 0000000..8f6a7dc --- /dev/null +++ b/test/queueFairness.test.js @@ -0,0 +1,48 @@ +const QueueManager = require('../src/network/queueManager'); + +function buildManager() { + const captured = []; + const game = { + id: 'game-id', + dbId: 1, + getState: () => ({}), + getProofCommitment: () => ({ commitment: 'c'.repeat(64) }) + }; + const user = { id: 'socket-a', clientId: 'client-a' }; + const io = { to: jest.fn().mockReturnValue({ emit: jest.fn() }) }; + const manager = new QueueManager({ + debugManager: { getCurrentBlockHeight: () => 10 }, + broadcastManager: {}, + io, + createGameForUser: jest.fn(async (_user, _type, options) => { captured.push(options); return game; }), + getUserBySocket: () => user, + activeGames: new Map(), + gameModeManager: { processGameStart: jest.fn().mockResolvedValue({ success: true }) }, + consoleLogging: false + }); + return { manager, captured, user }; +} + +describe('all queue start paths preserve the consumed fairness proof', () => { + const fairnessProof = { offerId: 'offer', serverSeed: 'a'.repeat(64), clientSeed: '01' }; + + test('next-block start', async () => { + const { manager, captured } = buildManager(); + manager.addPlayer({ serverId: 'socket-a', clientId: 'client-a', userId: 1, fairnessProof }); + await manager.startGamesForWaiting(11); + expect(captured[0].fairnessProof).toBe(fairnessProof); + }); + + test('confirmed-payment immediate start', async () => { + const { manager, captured } = buildManager(); + manager.addPlayer({ serverId: 'socket-a', clientId: 'client-a', userId: 1, fairnessProof }); + await manager.startGameImmediately('socket-a', 11); + expect(captured[0].fairnessProof).toBe(fairnessProof); + }); + + test('early entry start', async () => { + const { manager, captured, user } = buildManager(); + await manager.startEarlyGame('socket-a', user, 11, { fairnessProof }); + expect(captured[0]).toEqual(expect.objectContaining({ earlyEntry: true, fairnessProof })); + }); +}); diff --git a/test/rateLimit.test.js b/test/rateLimit.test.js index 67f9fce..856da9e 100644 --- a/test/rateLimit.test.js +++ b/test/rateLimit.test.js @@ -4,6 +4,19 @@ const RateLimiter = require('../src/network/rateLimiter'); const { clientIp, stableId } = require('../src/network/rateLimitContext'); +const SocketHandlers = require('../src/network/socketHandlers'); +const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy'); + +function legalAcknowledgement(gameModeManager = null) { + const disclosure = buildCommerceDisclosure(gameModeManager, process.env); + return { + policyVersion: disclosure.policyVersion, + ageEligible: true, + termsRead: true, + riskAccepted: true, + testnetUnderstood: disclosure.service.isTestNetwork === true + }; +} describe('rateLimitContext.stableId', () => { test('prefers the session stable user id over the socket id', () => { @@ -61,3 +74,83 @@ describe('reconnect cannot bypass the limit (stable-id keyed)', () => { rl.shutdown(); }); }); + +describe('payment creation limit is enforced at the socket boundary', () => { + test('blocks the fourth request before wallet or database work', async () => { + const rateLimiter = new RateLimiter({ + limits: { 'payment:create': { window: 60000, max: 3 } } + }); + const downstream = jest.fn().mockResolvedValue(undefined); + const socket = { + id: 'payment-socket', + handshake: { address: '192.0.2.20', headers: {} }, + emit: jest.fn() + }; + const context = { + rateLimiter, + sessionManager: { sessions: new Map([[socket.id, { id: 91 }]]) }, + paymentHandlers: { handlePaymentRequest: downstream } + }; + + for (let i = 0; i < 4; i += 1) { + await SocketHandlers.prototype.handlePaymentRequest.call( + context, + socket, + { + type: 'credits_package', + packageId: 'small', + legalAcknowledgement: legalAcknowledgement() + } + ); + } + + expect(downstream).toHaveBeenCalledTimes(3); + expect(socket.emit).toHaveBeenCalledWith('payment_error', expect.objectContaining({ + code: 'RATE_LIMITED' + })); + rateLimiter.shutdown(); + }); +}); + +describe('match queue join throttling', () => { + test('bounds joins but never blocks an escrow-releasing leave', async () => { + const rateLimiter = new RateLimiter({ + limits: { 'game:queue': { window: 60000, max: 1 } } + }); + const socket = { + id: 'match-socket', + handshake: { address: '192.0.2.30', headers: {} }, + emit: jest.fn() + }; + const enqueue = jest.fn().mockResolvedValue({ success: true }); + const leave = jest.fn().mockResolvedValue({ success: true }); + const context = { + rateLimiter, + sessionManager: { sessions: new Map([[socket.id, { id: 92 }]]) }, + matchQueue: { isEnabled: () => true, enqueue, leave }, + _resolveMatchSession: jest.fn().mockResolvedValue({ + userId: 92, + socketId: socket.id, + sessionToken: 'test-token' + }), + debugManager: { CONSOLE_LOGGING: false } + }; + + await SocketHandlers.prototype._handleMatchQueue.call(context, socket, { + action: 'join', economy: 'free' + }); + await SocketHandlers.prototype._handleMatchQueue.call(context, socket, { + action: 'join', economy: 'free' + }); + await SocketHandlers.prototype._handleMatchQueue.call(context, socket, { + action: 'leave', economy: 'free' + }); + + expect(enqueue).toHaveBeenCalledTimes(1); + expect(leave).toHaveBeenCalledTimes(1); + expect(socket.emit).toHaveBeenCalledWith('match_error', expect.objectContaining({ + code: 'RATE_LIMITED' + })); + rateLimiter.shutdown(); + }); +}); diff --git a/test/releaseArtifactBuilder.test.js b/test/releaseArtifactBuilder.test.js new file mode 100644 index 0000000..655acc6 --- /dev/null +++ b/test/releaseArtifactBuilder.test.js @@ -0,0 +1,284 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const sourceBuilder = path.join(__dirname, '..', 'scripts', 'deploy', 'build-release-artifact.sh'); + +function run(command, args, options = {}) { + return execFileSync(command, args, { + cwd: options.cwd, + encoding: 'utf8', + env: { ...process.env, TZ: 'UTC' }, + stdio: options.stdio || ['ignore', 'pipe', 'pipe'] + }); +} + +function write(root, relative, contents, mode = 0o644) { + const target = path.join(root, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents, { mode }); +} + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wowngeon-release-test-')); + write(root, 'scripts/deploy/build-release-artifact.sh', fs.readFileSync(sourceBuilder), 0o755); + write(root, 'src/index.js', "'use strict';\n"); + write(root, 'src/package.json', JSON.stringify({ + name: 'fixture', + version: '1.0.0', + scripts: { + start: 'node index.js', + preflight: 'node scripts/preflight.js', + 'db:migrate:clone': 'node scripts/migrate-disposable-clone.js', + test: 'jest', + 'capture:pvp': 'node scripts/pvp-capture.js', + 'canary:stagenet': 'node scripts/stagenet-financial-canary.js', + 'db:create': 'node scripts/setup_db.js' + } + }) + '\n'); + write(root, 'src/package-lock.json', '{"name":"fixture","lockfileVersion":3}\n'); + write(root, 'src/scripts/preflight.js', "'use strict';\n"); + write(root, 'src/scripts/migrate-disposable-clone.js', "'use strict';\n"); + write(root, 'src/scripts/pvp-capture.js', "throw new Error('source-only ad harness');\n"); + write(root, 'src/scripts/stagenet-financial-canary.js', "throw new Error('source-only transfer canary');\n"); + write(root, 'src/scripts/setup_db.js', "throw new Error('source-only database helper');\n"); + write(root, 'src/scripts/smoke.js', "throw new Error('source-only smoke helper');\n"); + write(root, 'src/jest.config.js', "module.exports = {};\n"); + write(root, 'src/sim/simulate.js', "throw new Error('source-only simulator');\n"); + write(root, 'src/.env.example', 'DB_PASSWORD=CHANGE_ME\n'); + write(root, 'src/.env.production.example', 'ADMIN_API_KEY=CHANGE_ME\n'); + write(root, 'html/index.html', '\n'); + write(root, 'LICENSE', 'fixture license\n'); + write(root, '.env.example', 'DB_PASSWORD=CHANGE_ME\n'); + write(root, 'README.md', 'source documentation only\n'); + write(root, 'docs/internal.md', 'operator documentation only\n'); + write(root, 'test/runtime-exclusion.test.js', "throw new Error('must not ship');\n"); + write(root, 'scripts/deploy/example.service', '[Service]\nExecStart=/bin/false\n'); + write(root, 'scripts/deploy/migrate-monero-stagenet-wallet.sh', '#!/bin/sh\nexit 1\n', 0o755); + write(root, '.gitignore', 'out*/\n'); + run('git', ['init', '-q'], { cwd: root }); + run('git', ['config', 'user.name', 'Release Test'], { cwd: root }); + run('git', ['config', 'user.email', 'release-test@example.invalid'], { cwd: root }); + run('git', ['add', '.'], { cwd: root }); + run('git', ['commit', '-qm', 'fixture'], { cwd: root }); + return root; +} + +function build(root, output, releaseId = 'candidate-1') { + return run(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [ + '--release-id', releaseId, + '--output-dir', output + ], { cwd: root }); +} + +function buildAsync(root, output, releaseId = 'candidate-1') { + return new Promise(resolve => { + const child = require('child_process').spawn( + path.join(root, 'scripts/deploy/build-release-artifact.sh'), + ['--release-id', releaseId, '--output-dir', output], + { + cwd: root, + env: { ...process.env, TZ: 'UTC' }, + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('close', status => resolve({ status, stdout, stderr })); + }); +} + +describe('release artifact builder', () => { + const roots = []; + + afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test('builds byte-identical, runtime-only artifacts with provenance markers', () => { + const root = fixture(); + roots.push(root); + const first = path.join(root, 'out-a'); + const second = path.join(root, 'out-b'); + + const output = build(root, first); + build(root, second); + + const artifactA = path.join(first, 'candidate-1.tar.gz'); + const artifactB = path.join(second, 'candidate-1.tar.gz'); + expect(fs.readFileSync(artifactA).equals(fs.readFileSync(artifactB))).toBe(true); + expect(output).toMatch(/^release_id=candidate-1$/m); + expect(output).toMatch(/^commit=[0-9a-f]{40}$/m); + expect(output).toMatch(/^source_sha256=[0-9a-f]{64}$/m); + expect(output).toMatch(/^artifact_sha256=[0-9a-f]{64}$/m); + + const members = run('tar', ['-tzf', artifactA]).trim().split('\n'); + expect(members).toContain('candidate-1/.release-source-sha256'); + expect(members).toContain('candidate-1/.release-commit'); + expect(members).toContain('candidate-1/.release-package-lock-sha256'); + expect(members).toContain('candidate-1/.release-files.sha256'); + expect(members).toContain('candidate-1/LICENSE'); + expect(members).toContain('candidate-1/src/index.js'); + expect(members).toContain('candidate-1/src/scripts/preflight.js'); + expect(members).toContain('candidate-1/src/scripts/migrate-disposable-clone.js'); + expect(members.some(member => member.includes('/.git/'))).toBe(false); + expect(members.some(member => member.includes('/node_modules/'))).toBe(false); + expect(members.some(member => /\/(?:\.env)$/.test(member))).toBe(false); + expect(members.some(member => member.startsWith('candidate-1/docs/'))).toBe(false); + expect(members.some(member => member.startsWith('candidate-1/test/'))).toBe(false); + expect(members.some(member => member.startsWith('candidate-1/scripts/'))).toBe(false); + expect(members.some(member => member.endsWith('.service'))).toBe(false); + expect(members).not.toContain('candidate-1/scripts/deploy/migrate-monero-stagenet-wallet.sh'); + expect(members.some(member => /^candidate-1\/src\/\.env(?:\.|$)/.test(member))).toBe(false); + expect(members.some(member => /\/\.env(?:\..*)?\.example$/.test(member))).toBe(false); + expect(members).not.toContain('candidate-1/src/scripts/pvp-capture.js'); + expect(members).not.toContain('candidate-1/src/scripts/stagenet-financial-canary.js'); + expect(members).not.toContain('candidate-1/src/scripts/setup_db.js'); + expect(members).not.toContain('candidate-1/src/scripts/smoke.js'); + expect(members).not.toContain('candidate-1/src/jest.config.js'); + expect(members.some(member => member.startsWith('candidate-1/src/sim/'))).toBe(false); + + const allowedTopLevel = new Set([ + '.release-commit', + '.release-files.sha256', + '.release-id', + '.release-package-lock-sha256', + '.release-source-sha256', + 'LICENSE', + 'html', + 'src' + ]); + const actualTopLevel = new Set( + members + .filter(member => member.startsWith('candidate-1/')) + .map(member => member.slice('candidate-1/'.length).split('/')[0]) + .filter(Boolean) + ); + expect(actualTopLevel).toEqual(allowedTopLevel); + + run('sha256sum', ['--check', path.join(first, 'candidate-1.tar.gz.sha256')], { cwd: first }); + const extracted = path.join(root, 'extracted'); + fs.mkdirSync(extracted); + run('tar', ['-xzf', artifactA, '-C', extracted]); + run('sha256sum', ['--check', '.release-files.sha256'], { + cwd: path.join(extracted, 'candidate-1') + }); + const runtimePackage = JSON.parse(fs.readFileSync( + path.join(extracted, 'candidate-1/src/package.json'), 'utf8' + )); + expect(runtimePackage.scripts).toEqual({ + start: 'node index.js', + preflight: 'node scripts/preflight.js', + 'db:migrate:clone': 'node scripts/migrate-disposable-clone.js' + }); + }); + + test.each([ + ['tracked modification', root => write(root, 'src/index.js', '// dirty\n')], + ['untracked file', root => write(root, 'untracked.txt', 'not reviewed\n')] + ])('refuses a dirty worktree: %s', (_label, dirty) => { + const root = fixture(); + roots.push(root); + dirty(root); + const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], { + cwd: root, + encoding: 'utf8' + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('worktree is dirty'); + }); + + test('refuses tracked symlinks', () => { + const root = fixture(); + roots.push(root); + fs.symlinkSync('index.js', path.join(root, 'src', 'linked.js')); + run('git', ['add', '.'], { cwd: root }); + run('git', ['commit', '-qm', 'add symlink'], { cwd: root }); + const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], { + cwd: root, + encoding: 'utf8' + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('tracked symlink, gitlink, or unsafe file mode'); + }); + + test('refuses tracked runtime environment files', () => { + const root = fixture(); + roots.push(root); + write(root, '.env.production', 'SECRET=value\n'); + run('git', ['add', '-f', '.env.production'], { cwd: root }); + run('git', ['commit', '-qm', 'add forbidden env'], { cwd: root }); + const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], { + cwd: root, + encoding: 'utf8' + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('tracked non-example environment file is forbidden'); + }); + + test('refuses unsafe release IDs and existing output', () => { + const root = fixture(); + roots.push(root); + const output = path.join(root, 'out'); + let result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [ + '--release-id', '../escape', '--output-dir', output + ], { cwd: root, encoding: 'utf8' }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('release ID must be'); + + build(root, output, 'candidate-1'); + result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [ + '--release-id', 'candidate-1', '--output-dir', output + ], { cwd: root, encoding: 'utf8' }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('refusing to overwrite'); + }); + + test.each([ + ['missing', scripts => { delete scripts.preflight; }], + ['empty', scripts => { scripts.preflight = ' '; }], + ['non-string', scripts => { scripts.preflight = { command: 'node scripts/preflight.js' }; }] + ])('refuses a %s required runtime package script', (_label, mutate) => { + const root = fixture(); + roots.push(root); + const manifestPath = path.join(root, 'src/package.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + mutate(manifest.scripts); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + run('git', ['add', 'src/package.json'], { cwd: root }); + run('git', ['commit', '-qm', 'break required runtime script'], { cwd: root }); + + const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], { + cwd: root, + encoding: 'utf8' + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('required runtime package script is absent or empty: preflight'); + }); + + test('a losing concurrent build cannot remove the winning same-ID publication', async () => { + const root = fixture(); + roots.push(root); + const output = path.join(root, 'out'); + + const results = await Promise.all([ + buildAsync(root, output), + buildAsync(root, output), + buildAsync(root, output), + buildAsync(root, output) + ]); + + expect(results.filter(result => result.status === 0)).toHaveLength(1); + expect(results.filter(result => result.status !== 0)).toHaveLength(3); + const artifact = path.join(output, 'candidate-1.tar.gz'); + const checksum = `${artifact}.sha256`; + expect(fs.existsSync(artifact)).toBe(true); + expect(fs.existsSync(checksum)).toBe(true); + run('sha256sum', ['--check', checksum], { cwd: output }); + }); +}); diff --git a/test/rendererCdnPolicy.test.js b/test/rendererCdnPolicy.test.js new file mode 100644 index 0000000..5979f58 --- /dev/null +++ b/test/rendererCdnPolicy.test.js @@ -0,0 +1,156 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const crypto = require('crypto'); + +function loadRenderModes(runtime, preloadedThree = null) { + const window = { + console, + WOWNGEON_RUNTIME: runtime, + localStorage: { + getItem: () => null, + setItem: () => {}, + }, + RK: { + TileRenderer: function TileRenderer() {}, + THREE: preloadedThree, + }, + }; + window.window = window; + + const source = fs.readFileSync( + path.join(__dirname, '../html/js/render/renderModes.js'), + 'utf8', + ); + vm.runInNewContext(source, { window, console }); + return window.RK; +} + +function fakeElement(tagName) { + const classes = new Set(); + const element = { + tagName, + children: [], + className: '', + textContent: '', + title: '', + attributes: {}, + appendChild(child) { this.children.push(child); return child; }, + setAttribute(name, value) { this.attributes[name] = String(value); }, + classList: { + add(name) { classes.add(name); }, + remove(name) { classes.delete(name); }, + contains(name) { return classes.has(name); }, + }, + }; + Object.defineProperty(element, 'innerHTML', { + set() { element.children = []; }, + }); + return element; +} + +function loadPickerWithRuntimeDisabled() { + const picker = fakeElement('div'); + const head = fakeElement('head'); + const document = { + head, + createElement: fakeElement, + getElementById(id) { + if (id === 'renderPicker') return picker; + if (id === 'rp-style') return head.children.find(child => child.id === 'rp-style') || null; + return null; + }, + addEventListener() {}, + }; + const window = { + console, + document, + WOWNGEON_RUNTIME: { rendererCdnEnabled: false }, + localStorage: { getItem: () => null, setItem: () => {} }, + RK: { TileRenderer: function TileRenderer() {} }, + }; + window.window = window; + const context = { window, document, console }; + vm.runInNewContext( + fs.readFileSync(path.join(__dirname, '../html/js/render/renderModes.js'), 'utf8'), + context, + ); + window.RK.entitlements.premium = true; + window.RK.SPGame = { mode: () => 'tiles', setMode: () => true }; + vm.runInNewContext( + fs.readFileSync(path.join(__dirname, '../html/js/ui/spRenderPicker.js'), 'utf8'), + context, + ); + window.RK.rebuildRenderPicker(); + return { picker, RK: window.RK }; +} + +describe('optional renderer CDN policy', () => { + test('external renderer loading defaults closed', () => { + const RK = loadRenderModes(undefined); + + expect(RK.externalRenderersEnabled()).toBe(false); + expect(RK.isModeRuntimeAvailable('3d')).toBe(false); + expect(RK.modeAvailability('3d')).toMatchObject({ + usable: false, + runtimeAvailable: false, + reason: 'runtime_unavailable', + }); + expect(RK.canUseMode('3d')).toBe(false); + + const callback = jest.fn(); + RK.ensureThree(callback); + expect(callback).toHaveBeenCalledWith(false); + }); + + test('a locally preloaded renderer remains usable with CDN loading disabled', () => { + const localThree = { + THREE: { REVISION: 'local-test' }, + GLTFLoader: function GLTFLoader() {}, + }; + const RK = loadRenderModes({ rendererCdnEnabled: false }, localThree); + RK.entitlements.premium = true; + + expect(RK.canUseMode('3d')).toBe(true); + + const callback = jest.fn(); + RK.ensureThree(callback); + expect(callback).toHaveBeenCalledWith(true); + }); + + test('homepage picker labels policy-disabled 3D as unavailable, not purchasable', () => { + const { picker } = loadPickerWithRuntimeDisabled(); + const modeButtons = picker.children[0].children; + const threeButton = modeButtons.find(button => button.textContent.startsWith('3D')); + + expect(threeButton.textContent).toContain('⛔'); + expect(threeButton.textContent).not.toContain('🔒'); + expect(threeButton.title).toMatch(/unavailable on this server/i); + expect(threeButton.title).not.toMatch(/unlock with credits/i); + expect(threeButton.attributes['aria-disabled']).toBe('true'); + }); + + test('server CSP trusts jsDelivr only when the renderer CDN flag is enabled', () => { + const serverSource = fs.readFileSync( + path.join(__dirname, '../src/index.js'), + 'utf8', + ); + + expect(serverSource).toContain('RENDERER_CDN_ENABLED'); + expect(serverSource).toMatch(/if \(rendererCdnEnabled\) \{[\s\S]*scriptSources\.push\('https:\/\/cdn\.jsdelivr\.net'\)/); + expect(serverSource).not.toContain("script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net"); + }); + + test('browser pages use the pinned same-origin jQuery 3.7.1 asset', () => { + const asset = fs.readFileSync(path.join(__dirname, '../html/js/lib/jquery-3.7.1.min.js')); + const homepage = fs.readFileSync(path.join(__dirname, '../html/index.html'), 'utf8'); + const debugPage = fs.readFileSync(path.join(__dirname, '../html/debug.html'), 'utf8'); + + expect(asset.toString('utf8', 0, 100)).toContain('jQuery v3.7.1'); + expect(crypto.createHash('sha256').update(asset).digest('hex')) + .toBe('3289f31657004339ed678f16c4e626987e57303f2107577ed4973f6c9e105260'); + expect(homepage).toContain('js/lib/jquery-3.7.1.min.js'); + expect(debugPage).toContain('js/lib/jquery-3.7.1.min.js'); + expect(homepage + debugPage).not.toContain('jquery-3.4.1'); + }); +}); diff --git a/test/rpcNetworkIdentity.test.js b/test/rpcNetworkIdentity.test.js new file mode 100644 index 0000000..226ab73 --- /dev/null +++ b/test/rpcNetworkIdentity.test.js @@ -0,0 +1,155 @@ +const RpcService = require('../src/rpc/rpcService'); + +const { GENESIS_HASHES } = RpcService; + +function daemonFixture({ cryptoType, network, height = 123, overrides = {} }) { + const target = cryptoType === 'WOW' ? 300 : 120; + return { + count: { count: height }, + info: { + status: 'OK', + height, + target, + nettype: network, + offline: false, + untrusted: false, + synchronized: true, + ...overrides + }, + header: { + status: 'OK', + untrusted: false, + block_header: { hash: GENESIS_HASHES[`${cryptoType}:${network}`] } + } + }; +} + +function fixtureHttp(fixtures) { + return { + post: jest.fn(async (url, body) => { + const endpoint = url.replace(/\/json_rpc$/, ''); + const fixture = fixtures[endpoint]; + if (fixture instanceof Error) throw fixture; + if (!fixture) throw new Error('unknown endpoint'); + if (body.method === 'getblockcount') return { data: { result: fixture.count } }; + if (body.method === 'get_info') return { data: { result: fixture.info } }; + if (body.method === 'get_block_header_by_height') { + return { data: { result: fixture.header } }; + } + throw new Error(`unexpected method: ${body.method}`); + }) + }; +} + +describe('RPCService pinned production identity', () => { + test.each([ + ['XMR', 'mainnet'], + ['XMR', 'stagenet'], + ['XMR', 'testnet'], + ['WOW', 'mainnet'] + ])('accepts only the pinned %s %s genesis', async (cryptoType, network) => { + const endpoint = 'http://daemon.test'; + const service = new RpcService({ + http: fixtureHttp({ [endpoint]: daemonFixture({ cryptoType, network }) }), + primaryEndpoint: endpoint, + fallbackEndpoint: endpoint, + cryptoType, + network, + identityRequired: true + }); + + const health = await service.healthCheck(); + + expect(health.healthy).toBe(true); + expect(health.identity).toMatchObject({ + required: true, + verified: true, + expected: { cryptoType, network }, + actual: { cryptoType, network } + }); + expect(JSON.stringify(health.identity)).not.toContain(GENESIS_HASHES[`${cryptoType}:${network}`]); + }); + + test('cannot pass WOW identity with an XMR mainnet daemon', async () => { + const endpoint = 'http://daemon.test'; + const service = new RpcService({ + http: fixtureHttp({ [endpoint]: daemonFixture({ cryptoType: 'XMR', network: 'mainnet' }) }), + primaryEndpoint: endpoint, + fallbackEndpoint: endpoint, + cryptoType: 'WOW', + network: 'mainnet', + identityRequired: true + }); + + const health = await service.healthCheck(); + + expect(health.healthy).toBe(false); + expect(health.identity.verified).toBe(false); + expect(health.primary.identityVerified).toBe(false); + }); + + test('selects a correctly identified fallback when the primary is the wrong network', async () => { + const primary = 'http://primary.test'; + const fallback = 'http://fallback.test'; + const http = fixtureHttp({ + [primary]: daemonFixture({ cryptoType: 'XMR', network: 'mainnet' }), + [fallback]: daemonFixture({ cryptoType: 'XMR', network: 'stagenet' }) + }); + const service = new RpcService({ + http, + primaryEndpoint: primary, + fallbackEndpoint: fallback, + cryptoType: 'XMR', + network: 'stagenet', + identityRequired: true + }); + + const height = await service.getBlockHeight(); + + expect(height).toBe(123); + expect(service.currentEndpoint).toBe(fallback); + expect(service.getIdentityStatus()).toMatchObject({ + verified: true, + actual: { cryptoType: 'XMR', network: 'stagenet' } + }); + }); + + test.each([ + ['zero height', { height: 0 }], + ['offline daemon', { offline: true }], + ['untrusted response', { untrusted: true }], + ['unsynchronized daemon', { synchronized: false }], + ['non-OK status', { status: 'BUSY' }] + ])('fails closed for %s', async (_label, overrides) => { + const endpoint = 'http://daemon.test'; + const fixture = daemonFixture({ cryptoType: 'XMR', network: 'stagenet', overrides }); + if (overrides.height !== undefined) fixture.count.count = overrides.height; + const service = new RpcService({ + http: fixtureHttp({ [endpoint]: fixture }), + primaryEndpoint: endpoint, + fallbackEndpoint: endpoint, + cryptoType: 'XMR', + network: 'stagenet', + identityRequired: true + }); + + await expect(service.healthCheck()).resolves.toMatchObject({ healthy: false }); + }); + + test('treats an HTTP 200 JSON-RPC error as unhealthy', async () => { + const endpoint = 'http://daemon.test'; + const http = { post: jest.fn().mockResolvedValue({ + data: { error: { code: -1, message: 'daemon error' } } + }) }; + const service = new RpcService({ + http, + primaryEndpoint: endpoint, + fallbackEndpoint: endpoint, + cryptoType: 'XMR', + network: 'stagenet', + identityRequired: true + }); + + await expect(service.healthCheck()).resolves.toMatchObject({ healthy: false }); + }); +}); diff --git a/test/rpcServiceHealth.test.js b/test/rpcServiceHealth.test.js new file mode 100644 index 0000000..d3426c0 --- /dev/null +++ b/test/rpcServiceHealth.test.js @@ -0,0 +1,71 @@ +const RpcService = require('../src/rpc/rpcService'); + +describe('RPCService production health', () => { + const previous = {}; + + beforeAll(() => { + previous.PRIMARY_RPC_ENDPOINT = process.env.PRIMARY_RPC_ENDPOINT; + previous.FALLBACK_RPC_ENDPOINT = process.env.FALLBACK_RPC_ENDPOINT; + }); + + beforeEach(() => { + jest.clearAllMocks(); + process.env.PRIMARY_RPC_ENDPOINT = 'http://127.0.0.1:18081'; + process.env.FALLBACK_RPC_ENDPOINT = 'http://127.0.0.1:28081'; + }); + + afterAll(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + test('reports ready when the primary daemon responds', async () => { + const http = { post: jest.fn().mockResolvedValue({ data: { result: { count: 123 } } }) }; + + const health = await new RpcService({ http }).healthCheck(); + + expect(health).toMatchObject({ + healthy: true, + primary: { status: 'healthy' } + }); + }); + + test('reports ready when only the fallback daemon responds', async () => { + const http = { post: jest.fn() + .mockRejectedValueOnce(new Error('primary offline')) + .mockResolvedValueOnce({ data: { result: { count: 123 } } }) }; + + const health = await new RpcService({ http }).healthCheck(); + + expect(health).toMatchObject({ + healthy: true, + primary: { status: 'unhealthy' }, + fallback: { status: 'healthy' } + }); + }); + + test('reports not ready when every daemon endpoint is offline', async () => { + const http = { post: jest.fn().mockRejectedValue(new Error('offline')) }; + + const health = await new RpcService({ http }).healthCheck(); + + expect(health.healthy).toBe(false); + }); + + test('redacts credentials from endpoint labels', () => { + expect(RpcService.endpointLabel('https://rpc-user:rpc-secret@example.test/json_rpc')) + .toBe('https://example.test/json_rpc'); + }); + + test('strict block count never substitutes the cached UI height', async () => { + const service = new RpcService({ + http: { post: jest.fn().mockRejectedValue(new Error('offline')) } + }); + service.lastBlockHeight = 777; + + await expect(service.getBlockCountStrict()).rejects.toThrow(); + await expect(service.getBlockHeight()).resolves.toBe(777); + }); +}); diff --git a/test/rulesets_ultra.test.js b/test/rulesets_ultra.test.js index 4ccb4b6..5c77d12 100644 --- a/test/rulesets_ultra.test.js +++ b/test/rulesets_ultra.test.js @@ -59,6 +59,15 @@ function findAdjacentWalkable(room) { return null; } +function stageSequentialExit(room) { + room.dungeon.exit = [2, 2]; + room.walkable[2][1] = true; + room.walkable[2][2] = true; + room.walkable[3][2] = true; + room.occupants.get('a').moveTo(1, 2); + room.occupants.get('b').moveTo(2, 3); +} + describe('MatchRoom drives new rulesets', () => { test('last-alive PvP: stepping onto a rival kills them and ends the match with the survivor as winner', () => { const room = new MatchRoom({ @@ -87,6 +96,89 @@ describe('MatchRoom drives new rulesets', () => { expect(room.playerStates.get('b').placement).toBe(2); }); + test('same-tick PvP is independent of packet order and eliminated actors never retaliate', () => { + const run = (submissionOrder) => { + const room = new MatchRoom({ + ruleset: { id: 'ordered-pvp', winCondition: { type: WIN.LAST_ALIVE }, entities: { monster: false, pvpCombat: true }, players: { min: 2, max: 4 } }, + entrants: { a: { userId: 1 }, b: { userId: 2 }, c: { userId: 3 } }, + seed: SEED + }); + room.start(); + + const ids = ['a', 'b', 'c'].sort((left, right) => + room._tickActionPriority(left, room.tickCount + 1) + .localeCompare(room._tickActionPriority(right, room.tickCount + 1)) + ); + const [attacker, victim, downstream] = ids; + room.occupants.get(attacker).moveTo(2, 2); + room.occupants.get(victim).moveTo(3, 2); + room.occupants.get(downstream).moveTo(4, 2); + room.walkable[2][2] = true; + room.walkable[2][3] = true; + room.walkable[2][4] = true; + + const moves = new Map([ + [attacker, [1, 0]], // kills victim first by committed tick priority + [victim, [1, 0]] // would kill downstream if post-mortem actions ran + ]); + for (const id of submissionOrder(ids)) room.queueMove(id, ...moves.get(id)); + const result = room.resolveTick(); + return { + attacker, + victim, + downstream, + events: result.events, + status: room.status, + winnerId: room.winnerId, + players: Array.from(room.playerStates.entries()).map(([id, state]) => [id, { + alive: state.alive, + killedBy: state.killedBy, + moves: state.moves, + placement: state.placement + }]) + }; + }; + + const forward = run(ids => [ids[0], ids[1]]); + const reverse = run(ids => [ids[1], ids[0]]); + expect(reverse).toEqual(forward); + expect(forward.players.find(([id]) => id === forward.victim)[1].alive).toBe(false); + expect(forward.players.find(([id]) => id === forward.downstream)[1].alive).toBe(true); + expect(forward.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'move_failed', id: forward.victim, reason: 'eliminated_before_action' + }) + ])); + }); + + test('last-alive corpses remain visible but do not trap surviving players', () => { + const room = new MatchRoom({ + ruleset: { + id: 'corpse-pass-through', winCondition: { type: WIN.LAST_ALIVE }, + entities: { monster: false, pvpCombat: true }, players: { min: 2, max: 4 } + }, + entrants: { a: { userId: 1 }, b: { userId: 2 }, c: { userId: 3 } }, + seed: SEED + }); + room.start(); + room.walkable[2][1] = true; + room.walkable[2][2] = true; + room.walkable[2][3] = true; + room.occupants.get('a').moveTo(1, 2); + room.occupants.get('b').moveTo(2, 2); + room.occupants.get('c').moveTo(3, 2); + room._killPlayer('b', 'monster'); + + room.queueMove('a', 1, 0); + const result = room.resolveTick(); + + expect(room.occupants.get('b')).toBeDefined(); + expect(room.occupants.get('a')).toEqual(expect.objectContaining({ x: 2, y: 2 })); + expect(result.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'move', id: 'a' }) + ])); + }); + test('high-score: reaching the exit does NOT instantly win — the match keeps going', () => { const room = new MatchRoom({ ruleset: { id: 'test-score', winCondition: { type: WIN.HIGH_SCORE }, entities: { monster: false }, players: { min: 1, max: 4 } }, @@ -94,14 +186,50 @@ describe('MatchRoom drives new rulesets', () => { seed: SEED }); room.start(); - const exit = room.dungeon.exit; - room.occupants.get('a').moveTo(exit[0], exit[1]); + stageSequentialExit(room); + room.queueMove('a', 1, 0); const res = room.resolveTick(); expect(room.playerStates.get('a').escaped).toBe(true); expect(room.status).toBe('active'); // b is still in play — no instant win expect(room.winnerId).toBeNull(); expect(res.finished).toBe(false); + + room.queueMove('b', 0, -1); + const second = room.resolveTick(); + expect(room.playerStates.get('b').escaped).toBe(true); + expect(room.occupants.get('b')).toEqual(expect.objectContaining({ x: 2, y: 2 })); + expect(second.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'player_exit', id: 'b' }) + ])); + }); + + test('co-op success is collective and never invents an individual winner', () => { + const room = new MatchRoom({ + ruleset: { + id: 'test-coop', mode: 'coop', + winCondition: { type: WIN.ALL_ESCAPE }, + entities: { monster: false }, players: { min: 2, max: 2 } + }, + entrants: { a: { userId: 1 }, b: { userId: 2 } }, + seed: SEED + }); + room.start(); + stageSequentialExit(room); + room.queueMove('a', 1, 0); + room.resolveTick(); + expect(room.status).toBe('active'); + + room.queueMove('b', 0, -1); + const second = room.resolveTick(); + expect(room.endReason).toBe('all_escaped'); + expect(second.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'player_exit', id: 'b' }) + ])); + + room.finalize(); + expect(room.winnerId).toBeNull(); + expect([...room.playerStates.values()].map(s => s.placement).sort()).toEqual([1, 2]); }); test('race (default) still ends the moment someone escapes', () => { diff --git a/test/runtimePolicy.test.js b/test/runtimePolicy.test.js new file mode 100644 index 0000000..c6beed8 --- /dev/null +++ b/test/runtimePolicy.test.js @@ -0,0 +1,93 @@ +const { isPayoutProcessingEnabled, isWalletRequired } = require('../src/config/runtimePolicy'); + +const baseConfig = () => ({ + payouts: { + enabled: true, + rules: { + direct: { enabled: true }, + credits: { enabled: true } + } + }, + modes: { + direct: { enabled: true }, + credits: { enabled: true } + } +}); + +const baseManager = () => ({ + payoutsEnabled: true, + directPayoutEnabled: true, + creditsPayoutEnabled: true +}); + +describe('runtime payout worker policy', () => { + test('master config switch suppresses every per-mode payout worker', () => { + const config = baseConfig(); + config.payouts.enabled = false; + + expect(isPayoutProcessingEnabled(config, baseManager())).toBe(false); + }); + + test('manager emergency switch also suppresses workers after hot reload', () => { + const manager = baseManager(); + manager.payoutsEnabled = false; + + expect(isPayoutProcessingEnabled(baseConfig(), manager)).toBe(false); + }); + + test('disabled modes cannot accidentally activate a worker through stale payout flags', () => { + const config = baseConfig(); + config.modes.direct.enabled = false; + config.modes.credits.enabled = false; + + expect(isPayoutProcessingEnabled(config, baseManager())).toBe(false); + }); + + test('one explicitly active policy is enough to process its queued payouts', () => { + const config = baseConfig(); + config.modes.direct.enabled = false; + const manager = baseManager(); + manager.directPayoutEnabled = false; + + expect(isPayoutProcessingEnabled(config, manager)).toBe(true); + }); + + test('accepted durable liabilities keep the generic worker alive after admission is disabled', () => { + const config = baseConfig(); + config.modes.direct.enabled = false; + config.modes.credits.enabled = false; + const manager = baseManager(); + manager.directPayoutEnabled = false; + manager.creditsPayoutEnabled = false; + + expect(isPayoutProcessingEnabled(config, manager)).toBe(false); + expect(isPayoutProcessingEnabled(config, manager, { + settleAcceptedLiabilities: true + })).toBe(true); + + config.payouts.enabled = false; + expect(isPayoutProcessingEnabled(config, manager, { + settleAcceptedLiabilities: true + })).toBe(false); + }); + + test('wallet remains mandatory for payout-only accepted-liability settlement', () => { + const config = baseConfig(); + config.paymentsEnabled = false; + config.modes.direct.enabled = false; + config.modes.credits.enabled = false; + const manager = baseManager(); + manager.directPayoutEnabled = false; + manager.creditsPayoutEnabled = false; + + expect(isWalletRequired(config, manager)).toBe(false); + expect(isWalletRequired(config, manager, { + settleAcceptedLiabilities: true + })).toBe(true); + + config.payouts.enabled = false; + expect(isWalletRequired(config, manager, { + settleAcceptedLiabilities: true + })).toBe(false); + }); +}); diff --git a/test/security.test.js b/test/security.test.js index f67e8b3..b26dd71 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -47,6 +47,16 @@ function createMockDb() { users.set(id, newUser); return { rows: [newUser] }; } + if (text.includes('UPDATE users') && text.includes('SET socket_id = $1') && + text.includes('anon_token = $2') && text.includes('anon_token = $4')) { + const [socketId, newToken, userId, presentedToken] = params; + const user = users.get(userId); + if (!user || user.anon_token !== presentedToken) return { rows: [] }; + user.socket_id = socketId; + user.anon_token = newToken; + user.last_seen = new Date(); + return { rows: [{ ...user }] }; + } if (text.includes('UPDATE users SET socket_id')) { const socketId = params[0]; const userId = params[1]; @@ -282,6 +292,69 @@ describe('SQL Injection Prevention', () => { expect(replay.resumed).toBe(false); }); + test('concurrent resume is compare-and-swap: one wins, the loser is rejected, and stale live identity is disconnected', async () => { + const localDb = createMockDb(); + const staleDisconnect = jest.fn(); + const io = { + sockets: { + sockets: new Map([['cas-socket-old', { disconnect: staleDisconnect }]]) + } + }; + const manager = new SessionManager({ + db: localDb, + debugManager: { CONSOLE_LOGGING: false }, + gameModeManager: { creditsPerGameCost: 1 }, + io + }); + const created = await manager.resumeOrCreate({ + socketId: 'cas-socket-old', + ipAddress: '127.0.0.1', + resumeToken: null + }); + + // Hold both SELECT-by-token calls until they have each observed the old token, + // forcing the race to be decided by the conditional UPDATE rather than timing. + const baseQuery = localDb.query; + let waitingSelects = 0; + let releaseFirstSelect; + localDb.query = jest.fn(async (text, params = []) => { + const result = await baseQuery(text, params); + if (text.includes('SELECT * FROM users WHERE anon_token') && params[0] === created.token) { + waitingSelects += 1; + if (waitingSelects === 1) { + await new Promise(resolve => { releaseFirstSelect = resolve; }); + } else if (releaseFirstSelect) { + releaseFirstSelect(); + } + } + return result; + }); + + const attempts = await Promise.allSettled([ + manager.resumeOrCreate({ + socketId: 'cas-socket-a', + ipAddress: '127.0.0.1', + resumeToken: created.token + }), + manager.resumeOrCreate({ + socketId: 'cas-socket-b', + ipAddress: '127.0.0.1', + resumeToken: created.token + }) + ]); + + const fulfilled = attempts.filter(r => r.status === 'fulfilled'); + const rejected = attempts.filter(r => r.status === 'rejected'); + expect(fulfilled).toHaveLength(1); + expect(fulfilled[0].value.resumed).toBe(true); + expect(rejected).toHaveLength(1); + expect(rejected[0].reason.code).toBe('SESSION_TOKEN_REPLAY'); + + expect(manager.sessions.has('cas-socket-old')).toBe(false); + await expect(manager.getBySocket('cas-socket-old')).resolves.toBeNull(); + expect(staleDisconnect).toHaveBeenCalledWith(true); + }); + test('should safely handle malicious socket ID', async () => { const maliciousSocketId = "'; DROP TABLE users; --"; @@ -378,4 +451,4 @@ module.exports = { ); return result.rows[0]; } -}; \ No newline at end of file +}; diff --git a/test/singleGameRecovery.test.js b/test/singleGameRecovery.test.js index f3d434e..c7f53d0 100644 --- a/test/singleGameRecovery.test.js +++ b/test/singleGameRecovery.test.js @@ -9,21 +9,34 @@ const SessionManager = require('../src/network/sessionManager'); -function makeDb({ singleGamePayments = [], gamesForPayment = new Set(), recoveredReasons = new Set() }) { +function makeDb({ singleGamePayments = [], gamesForPayment = new Set(), recoveredReasons = new Set(), entitlementPaymentIds = new Set() }) { let credits = 0; const handler = async (text, params = []) => { // credits_package recovery queries (unprocessed + orphaned) — none in these tests if (/FROM payments[\s\S]*credits_package/i.test(text)) return { rows: [] }; + if (/FROM payments\s+WHERE id = \$1[\s\S]*FOR UPDATE/i.test(text)) { + const p = singleGamePayments.find(row => row.id === params[0]); + return { rows: p ? [{ id: p.id, user_id: 7, status: 'confirmed', payment_type: 'single_game' }] : [] }; + } // outer "unconsumed single_game" discovery query if (/payment_type = 'single_game'/i.test(text)) { return { rows: singleGamePayments - .filter(p => !gamesForPayment.has(p.id) && !recoveredReasons.has(`single_game_recovered:${p.id}`)) + .filter(p => !gamesForPayment.has(p.id) + && !entitlementPaymentIds.has(p.id) + && !recoveredReasons.has(`single_game_recovered:${p.id}`)) .map(p => ({ id: p.id, confirmed_at: p.confirmed_at })) }; } - if (/SELECT id FROM payments WHERE id = \$1 FOR UPDATE/i.test(text)) return { rows: [{ id: params[0] }] }; if (/SELECT 1 FROM games WHERE payment_id/i.test(text)) return { rows: gamesForPayment.has(params[0]) ? [{}] : [] }; + if (/SELECT payment_id FROM payment_entitlement_grants/i.test(text)) { + return { rows: entitlementPaymentIds.has(params[0]) ? [{ payment_id: params[0] }] : [] }; + } + if (/INSERT INTO payment_entitlement_grants/i.test(text)) { + if (entitlementPaymentIds.has(params[0])) return { rows: [], rowCount: 0 }; + entitlementPaymentIds.add(params[0]); + return { rows: [{ payment_id: params[0] }], rowCount: 1 }; + } if (/FROM credit_transactions WHERE user_id = \$1 AND reason = \$2/i.test(text)) { return { rows: recoveredReasons.has(params[1]) ? [{}] : [] }; } diff --git a/test/smirkAuthOwnership.test.js b/test/smirkAuthOwnership.test.js new file mode 100644 index 0000000..ae0080a --- /dev/null +++ b/test/smirkAuthOwnership.test.js @@ -0,0 +1,329 @@ +const express = require('express'); +const http = require('http'); +const nacl = require('../src/node_modules/tweetnacl'); +const createAuthRoutes = require('../src/routes/auth'); + +function normalized(sql) { + return sql.replace(/\s+/g, ' ').trim(); +} + +function createStateDb(initialUsers) { + const users = new Map(initialUsers.map(user => [user.id, { ...user }])); + const challenges = new Map(); + let challengeId = 1; + + async function query(sql, params = []) { + const text = normalized(sql); + + if (text.includes('SELECT id, socket_id, anon_token FROM users')) { + const row = Array.from(users.values()).find(user => + user.socket_id === params[0] && user.anon_token === params[1]); + return { rows: row ? [{ id: row.id, socket_id: row.socket_id, anon_token: row.anon_token }] : [] }; + } + + if (text.startsWith('INSERT INTO smirk_challenges')) { + const [challenge, socketId, userId, token] = params; + const user = users.get(userId); + if (!user || user.socket_id !== socketId || user.anon_token !== token) return { rows: [] }; + const row = { + id: challengeId++, challenge, socket_id: socketId, user_id: userId, + used: false, expires_at: Date.now() + 300000 + }; + challenges.set(challenge, row); + return { rows: [{ id: row.id }] }; + } + + if (text.startsWith('DELETE FROM smirk_challenges')) return { rows: [], rowCount: 0 }; + + if (text.startsWith('UPDATE smirk_challenges SET used = TRUE')) { + const [challenge, userId, socketId] = params; + const row = challenges.get(challenge); + if (!row || row.used || row.user_id !== userId || row.socket_id !== socketId + || row.expires_at <= Date.now()) return { rows: [] }; + row.used = true; + return { rows: [{ id: row.id }] }; + } + + if (text.includes('SELECT id FROM users WHERE id = $1 AND socket_id = $2 AND anon_token = $3 FOR UPDATE')) { + const user = users.get(params[0]); + return { + rows: user && user.socket_id === params[1] && user.anon_token === params[2] + ? [{ id: user.id }] + : [] + }; + } + + if (text.includes('SELECT id, socket_id, payout_address FROM users WHERE smirk_public_key')) { + const owner = Array.from(users.values()).find(user => + user.smirk_public_key === params[0] && user.id !== params[1]); + return { rows: owner ? [{ + id: owner.id, + socket_id: owner.socket_id, + payout_address: owner.payout_address + }] : [] }; + } + + if (text.startsWith('UPDATE users SET socket_id = NULL, anon_token = $1')) { + const [newToken, userId, socketId, oldToken] = params; + const user = users.get(userId); + if (!user || user.socket_id !== socketId || user.anon_token !== oldToken) return { rows: [] }; + user.socket_id = null; + user.anon_token = newToken; + return { rows: [{ id: user.id }] }; + } + + if (text.startsWith('UPDATE users SET socket_id = $1, anon_token = $2')) { + const [socketId, newToken, userId, provenKey] = params; + const user = users.get(userId); + if (!user || user.smirk_public_key !== provenKey) return { rows: [] }; + user.socket_id = socketId; + user.anon_token = newToken; + return { rows: [{ id: user.id, payout_address: user.payout_address }] }; + } + + if (text.includes('SELECT id FROM users WHERE socket_id = $1 FOR UPDATE')) { + return { + rows: Array.from(users.values()) + .filter(user => user.socket_id === params[0]) + .map(user => ({ id: user.id })) + }; + } + + if (text.startsWith('UPDATE users SET smirk_public_key = $1')) { + const [key, userId, socketId, token] = params; + const user = users.get(userId); + if (!user || user.socket_id !== socketId || user.anon_token !== token) return { rows: [] }; + user.smirk_public_key = key; + return { rows: [{ id: user.id }] }; + } + + if (text.includes('SELECT smirk_public_key, payout_address FROM users WHERE anon_token = $1')) { + const user = Array.from(users.values()).find(row => row.anon_token === params[0]); + return { rows: user ? [{ + smirk_public_key: user.smirk_public_key, + payout_address: user.payout_address + }] : [] }; + } + + return { rows: [] }; + } + + return { + users, + challenges, + query: jest.fn(query), + withTransaction: jest.fn(async callback => callback({ query })) + }; +} + +function makeSessionManager(tokens = []) { + let index = 0; + return { + generateSecureToken: jest.fn(() => tokens[index++] || `generated-token-${index}`), + disconnectUserSessions: jest.fn() + }; +} + +async function startServer(db, sessionManager) { + const app = express(); + app.use(express.json()); + app.use(createAuthRoutes({ db, sessionManager })); + app.use((err, _req, res, _next) => { + res.status(err.statusCode || 500).json({ + error: err.safeMessage || err.message, + code: err.code || 'ERROR' + }); + }); + return new Promise(resolve => { + const server = http.createServer(app); + server.listen(0, '127.0.0.1', () => resolve(server)); + }); +} + +async function request(server, path, { body, token } = {}) { + const { port } = server.address(); + const headers = { 'Content-Type': 'application/json' }; + if (token) headers['X-Session-Token'] = token; + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', headers, body: JSON.stringify(body || {}) + }); + return { + status: response.status, + headers: response.headers, + json: await response.json().catch(() => ({})) + }; +} + +function signedLegacyProof(challenge, keyPair = nacl.sign.keyPair()) { + const signature = nacl.sign.detached(Buffer.from(challenge, 'utf8'), keyPair.secretKey); + return { + keyPair, + publicKey: Buffer.from(keyPair.publicKey).toString('hex'), + signature: Buffer.from(signature).toString('hex') + }; +} + +describe('Smirk stable-session ownership', () => { + test('public victim socket id is insufficient: challenge requires its matching bearer and stores user_id', async () => { + const db = createStateDb([ + { id: 1, socket_id: 'victim-public-socket', anon_token: 'victim-secret' }, + { id: 2, socket_id: 'attacker-socket', anon_token: 'attacker-secret' } + ]); + const server = await startServer(db, makeSessionManager()); + try { + const missing = await request(server, '/api/auth/smirk/challenge', { + body: { socketId: 'victim-public-socket' } + }); + expect(missing.status).toBe(401); + + const stolenId = await request(server, '/api/auth/smirk/challenge', { + body: { socketId: 'victim-public-socket' }, token: 'attacker-secret' + }); + expect(stolenId.status).toBe(403); + expect(db.challenges.size).toBe(0); + + const legitimate = await request(server, '/api/auth/smirk/challenge', { + body: { socketId: 'victim-public-socket' }, token: 'victim-secret' + }); + expect(legitimate.status).toBe(200); + expect(db.challenges.get(legitimate.json.challenge)).toMatchObject({ + user_id: 1, + socket_id: 'victim-public-socket', + used: false + }); + } finally { + server.close(); + } + }); + + test('a challenge issued to one stable user cannot be verified through another session', async () => { + const db = createStateDb([ + { id: 1, socket_id: 'victim-socket', anon_token: 'victim-token', smirk_public_key: null }, + { id: 2, socket_id: 'attacker-socket', anon_token: 'attacker-token', smirk_public_key: null } + ]); + const server = await startServer(db, makeSessionManager()); + try { + const issued = await request(server, '/api/auth/smirk/challenge', { + body: { socketId: 'victim-socket' }, token: 'victim-token' + }); + const proof = signedLegacyProof(issued.json.challenge); + const crossed = await request(server, '/api/auth/smirk/verify', { + body: { + socketId: 'attacker-socket', challenge: issued.json.challenge, + publicKey: proof.publicKey, signature: proof.signature + }, + token: 'attacker-token' + }); + + expect(crossed.status).toBe(400); + expect(db.users.get(1).smirk_public_key).toBeNull(); + expect(db.users.get(2).smirk_public_key).toBeNull(); + } finally { + server.close(); + } + }); + + test('legitimate wallet linking still succeeds for the authenticated session', async () => { + const db = createStateDb([ + { id: 1, socket_id: 'owner-socket', anon_token: 'owner-token', smirk_public_key: null } + ]); + const server = await startServer(db, makeSessionManager()); + try { + const issued = await request(server, '/api/auth/smirk/challenge', { + body: { socketId: 'owner-socket' }, token: 'owner-token' + }); + const proof = signedLegacyProof(issued.json.challenge); + const linked = await request(server, '/api/auth/smirk/verify', { + body: { + socketId: 'owner-socket', challenge: issued.json.challenge, + publicKey: proof.publicKey, signature: proof.signature + }, + token: 'owner-token' + }); + + expect(linked.status).toBe(200); + expect(linked.json).toMatchObject({ success: true, linked: true }); + expect(db.users.get(1).smirk_public_key).toBe(proof.publicKey); + } finally { + server.close(); + } + }); + + test('wallet adoption atomically displaces the fresh row, rotates tokens, leaves one socket owner, and revokes live sessions', async () => { + const ownerKeyPair = nacl.sign.keyPair(); + const ownerKey = Buffer.from(ownerKeyPair.publicKey).toString('hex'); + const db = createStateDb([ + { + id: 1, socket_id: 'old-owner-socket', anon_token: 'old-owner-token', + smirk_public_key: ownerKey, payout_address: 'owner-address' + }, + { + id: 2, socket_id: 'fresh-public-socket', anon_token: 'fresh-token', + smirk_public_key: null, payout_address: null + } + ]); + const sessionManager = makeSessionManager(['new-owner-token', 'revoked-fresh-token']); + const server = await startServer(db, sessionManager); + try { + const issued = await request(server, '/api/auth/smirk/challenge', { + body: { socketId: 'fresh-public-socket' }, token: 'fresh-token' + }); + const proof = signedLegacyProof(issued.json.challenge, ownerKeyPair); + const adopted = await request(server, '/api/auth/smirk/verify', { + body: { + socketId: 'fresh-public-socket', challenge: issued.json.challenge, + publicKey: proof.publicKey, signature: proof.signature + }, + token: 'fresh-token' + }); + + expect(adopted.status).toBe(200); + expect(adopted.json).toMatchObject({ + success: true, + adopted: true, + sessionToken: 'new-owner-token', + address: 'owner-address' + }); + expect(db.users.get(1)).toMatchObject({ + socket_id: 'fresh-public-socket', anon_token: 'new-owner-token' + }); + expect(db.users.get(2)).toMatchObject({ + socket_id: null, anon_token: 'revoked-fresh-token' + }); + expect(Array.from(db.users.values()).filter(u => u.socket_id === 'fresh-public-socket')) + .toHaveLength(1); + expect(Array.from(db.users.values()).some(u => u.anon_token === 'old-owner-token')).toBe(false); + expect(Array.from(db.users.values()).some(u => u.anon_token === 'fresh-token')).toBe(false); + + await new Promise(resolve => setImmediate(resolve)); + expect(sessionManager.disconnectUserSessions).toHaveBeenCalledWith( + [2, 1], + ['fresh-public-socket', 'old-owner-socket'] + ); + } finally { + server.close(); + } + }); + + test('challenge issuance is bounded per IP', async () => { + const db = createStateDb([ + { id: 1, socket_id: 'rate-socket', anon_token: 'rate-token', smirk_public_key: null } + ]); + const server = await startServer(db, makeSessionManager()); + try { + for (let i = 0; i < 10; i += 1) { + const result = await request(server, '/api/auth/smirk/challenge', { + body: { socketId: 'rate-socket' }, token: 'rate-token' + }); + expect(result.status).toBe(200); + } + const limited = await request(server, '/api/auth/smirk/challenge', { + body: { socketId: 'rate-socket' }, token: 'rate-token' + }); + expect(limited.status).toBe(429); + expect(Number(limited.headers.get('retry-after'))).toBeGreaterThan(0); + } finally { + server.close(); + } + }); +}); diff --git a/test/smirkFeatureGate.test.js b/test/smirkFeatureGate.test.js new file mode 100644 index 0000000..bb16a5c --- /dev/null +++ b/test/smirkFeatureGate.test.js @@ -0,0 +1,75 @@ +const { isSmirkEnabled } = require('../src/auth/smirkPolicy'); +const GameModeManager = require('../src/game/gameModeManager'); +const ClientSocketHandlers = require('../html/js/network/socketHandlers'); + +function gameModeInfoFixture() { + const manager = Object.create(GameModeManager.prototype); + Object.assign(manager, { + creditsPayoutBaseValue: 0, + cryptoType: 'WOW', + network: 'mainnet', + isTestNetwork: false, + configSnapshot: { modes: { credits: { packages: [] } }, earlyEntry: {} }, + gameMode: 'FREE', + freePlayEnabled: true, + singleGamePrice: 0, + creditsPackagePrice: 0, + creditsPerGameCost: 1, + paymentsEnabled: false, + directModeEnabled: false, + creditsModeEnabled: false, + payoutsEnabled: false, + directPayoutMultipliers: {}, + creditPayoutMultipliers: {}, + formatAtomic: () => '0', + formatAtomicHuman: () => '0', + getCosmeticProducts: () => [], + isPayoutEnabledForMode: () => false, + _getMatchEconomies: () => [], + _getMatchRulesetInfo: () => ({}) + }); + return manager.getGameModeInfo(); +} + +describe('Smirk explicit opt-in policy', () => { + const original = {}; + + beforeEach(() => { + for (const key of ['SMIRK_ENABLED', 'MONERO_NETWORK']) original[key] = process.env[key]; + }); + + afterEach(() => { + for (const key of Object.keys(original)) { + if (original[key] === undefined) delete process.env[key]; + else process.env[key] = original[key]; + } + }); + + test('server policy is off for missing/truthy-ish values and every test network', () => { + expect(isSmirkEnabled({ MONERO_NETWORK: 'mainnet' })).toBe(false); + expect(isSmirkEnabled({ SMIRK_ENABLED: 'TRUE', MONERO_NETWORK: 'mainnet' })).toBe(false); + expect(isSmirkEnabled({ SMIRK_ENABLED: '1', MONERO_NETWORK: 'mainnet' })).toBe(false); + expect(isSmirkEnabled({ SMIRK_ENABLED: 'true', MONERO_NETWORK: 'stagenet' })).toBe(false); + expect(isSmirkEnabled({ SMIRK_ENABLED: 'true', MONERO_NETWORK: 'testnet' })).toBe(false); + expect(isSmirkEnabled({ SMIRK_ENABLED: 'true', MONERO_NETWORK: 'mainnet' })).toBe(true); + }); + + test('game-mode payload defaults Smirk off and advertises it only for exact mainnet opt-in', () => { + delete process.env.SMIRK_ENABLED; + process.env.MONERO_NETWORK = 'mainnet'; + expect(gameModeInfoFixture().smirkEnabled).toBe(false); + + process.env.SMIRK_ENABLED = 'true'; + expect(gameModeInfoFixture().smirkEnabled).toBe(true); + + process.env.MONERO_NETWORK = 'stagenet'; + expect(gameModeInfoFixture().smirkEnabled).toBe(false); + }); + + test('browser gate accepts only the explicit boolean true sent by the server', () => { + expect(ClientSocketHandlers._isSmirkExplicitlyEnabled({})).toBe(false); + expect(ClientSocketHandlers._isSmirkExplicitlyEnabled({ smirkEnabled: 'true' })).toBe(false); + expect(ClientSocketHandlers._isSmirkExplicitlyEnabled({ smirkEnabled: false })).toBe(false); + expect(ClientSocketHandlers._isSmirkExplicitlyEnabled({ smirkEnabled: true })).toBe(true); + }); +}); diff --git a/test/smirkNip98_ultra.test.js b/test/smirkNip98_ultra.test.js index deb6100..bb68216 100644 --- a/test/smirkNip98_ultra.test.js +++ b/test/smirkNip98_ultra.test.js @@ -230,39 +230,58 @@ describe('(ix) route layer — single-use challenge consume rejects replay', () const createAuthRoutes = require('../src/routes/auth'); // Mock DB modelling the atomic single-use UPDATE ... WHERE used=FALSE ... RETURNING. - function mockDb(challengeValue, socketId) { - const challenges = new Map([[challengeValue, { id: 1, socket_id: socketId, used: false }]]); - return { - challenges, - query: async (sql, params = []) => { - const s = sql.replace(/\s+/g, ' ').trim(); - if (/^UPDATE smirk_challenges SET used = TRUE/i.test(s)) { - const [ch, sock] = params; - const row = challenges.get(ch); - if (row && row.socket_id === sock && row.used === false) { - row.used = true; // atomic FALSE -> TRUE, exactly once - return { rows: [{ id: row.id }] }; - } - return { rows: [] }; - } - if (/SELECT id FROM users WHERE socket_id = \$1$/i.test(s)) { - return { rows: [{ id: 77 }] }; - } - if (/SELECT id FROM users WHERE smirk_public_key = \$1 AND id != \$2/i.test(s)) { - return { rows: [] }; // not linked elsewhere - } - if (/^UPDATE users SET smirk_public_key = \$1/i.test(s)) { - return { rows: [] }; + function mockDb(challengeValue, socketId, token) { + const user = { id: 77, socket_id: socketId, anon_token: token }; + const challenges = new Map([[ + challengeValue, + { id: 1, socket_id: socketId, user_id: user.id, used: false } + ]]); + const query = async (sql, params = []) => { + const s = sql.replace(/\s+/g, ' ').trim(); + if (/SELECT id, socket_id, anon_token FROM users/i.test(s)) { + return params[0] === user.socket_id && params[1] === user.anon_token + ? { rows: [user] } + : { rows: [] }; + } + if (/^UPDATE smirk_challenges SET used = TRUE/i.test(s)) { + const [ch, userId, sock] = params; + const row = challenges.get(ch); + if (row && row.user_id === userId && row.socket_id === sock && row.used === false) { + row.used = true; // atomic FALSE -> TRUE, exactly once + return { rows: [{ id: row.id }] }; } return { rows: [] }; - }, + } + if (/SELECT id FROM users WHERE id = \$1 AND socket_id = \$2 AND anon_token = \$3 FOR UPDATE/i.test(s)) { + return params[0] === user.id && params[1] === user.socket_id && params[2] === user.anon_token + ? { rows: [{ id: user.id }] } + : { rows: [] }; + } + if (/SELECT id, socket_id, payout_address FROM users WHERE smirk_public_key/i.test(s)) { + return { rows: [] }; // not linked elsewhere + } + if (/^UPDATE users SET smirk_public_key = \$1/i.test(s)) { + return { rows: [{ id: user.id }] }; + } + return { rows: [] }; + }; + return { + challenges, + query, + withTransaction: async (fn) => fn({ query }), }; } function startServer(db) { const app = express(); app.use(express.json()); - app.use(createAuthRoutes({ db })); + app.use(createAuthRoutes({ + db, + sessionManager: { + generateSecureToken: () => 'unused-test-token', + disconnectUserSessions: jest.fn() + } + })); // Minimal error handler mirroring the app's (ValidationError -> 400, etc.). app.use((err, req, res, _next) => { res.status(err.statusCode || 500).json({ error: err.message }); @@ -273,11 +292,11 @@ describe('(ix) route layer — single-use challenge consume rejects replay', () }); } - async function postJson(server, path, body) { + async function postJson(server, path, body, token) { const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-Session-Token': token }, body: JSON.stringify(body), }); const json = await res.json().catch(() => ({})); @@ -287,20 +306,21 @@ describe('(ix) route layer — single-use challenge consume rejects replay', () test('a valid NIP-98 event links once, then the identical replay is rejected', async () => { const sk = generateSecretKey(); const socketId = 'sock-xyz-1'; + const token = 'route-session-token'; const challenge = 'route-nonce-9f8e7d'; // created_at must be genuinely fresh — the route uses real Date.now(). const ev = buildEvent(sk, { created_at: Math.floor(Date.now() / 1000), challenge }); - const db = mockDb(challenge, socketId); + const db = mockDb(challenge, socketId, token); const server = await startServer(db); try { - const first = await postJson(server, PATH_SUFFIX, { socketId, event: ev }); + const first = await postJson(server, PATH_SUFFIX, { socketId, event: ev }, token); expect(first.status).toBe(200); expect(first.json).toMatchObject({ success: true, linked: true }); expect(db.challenges.get(challenge).used).toBe(true); // Replay the exact same event: the single-use consume now fails -> 400. - const second = await postJson(server, PATH_SUFFIX, { socketId, event: ev }); + const second = await postJson(server, PATH_SUFFIX, { socketId, event: ev }, token); expect(second.status).toBe(400); expect(second.json.error).toBeTruthy(); } finally { diff --git a/test/socketOriginPolicy.test.js b/test/socketOriginPolicy.test.js new file mode 100644 index 0000000..0e4ce7d --- /dev/null +++ b/test/socketOriginPolicy.test.js @@ -0,0 +1,84 @@ +const { + createSocketOriginAllowRequest, + normalizedConfiguredOrigin, + normalizedRequestOrigin +} = require('../src/network/socketOriginPolicy'); + +function allowed(env, headers) { + const allowRequest = createSocketOriginAllowRequest(env); + return new Promise((resolve, reject) => { + allowRequest({ headers }, (error, accepted) => { + if (error) reject(error); + else resolve(accepted); + }); + }); +} + +describe('Socket.IO production Origin policy', () => { + test('development preserves permissive behavior', async () => { + await expect(allowed({ NODE_ENV: 'development' }, { origin: 'not a URL' })) + .resolves.toBe(true); + await expect(allowed({}, { origin: 'https://cross-origin.example' })) + .resolves.toBe(true); + }); + + test('production allows an absent Origin only after HOSTED_BY validates', async () => { + const env = { NODE_ENV: 'production', HOSTED_BY: 'https://play.example.test' }; + await expect(allowed(env, {})).resolves.toBe(true); + await expect(allowed(env, undefined)).resolves.toBe(true); + }); + + test('allows only the exact normalized scheme and host', async () => { + const env = { + NODE_ENV: 'production', + HOSTED_BY: 'HTTPS://Play.Example.Test:443/public/path?ignored=yes' + }; + + await expect(allowed(env, { origin: 'https://play.example.test' })).resolves.toBe(true); + await expect(allowed(env, { origin: 'https://PLAY.EXAMPLE.TEST:443/' })).resolves.toBe(true); + await expect(allowed(env, { origin: 'http://play.example.test' })).resolves.toBe(false); + await expect(allowed(env, { origin: 'https://other.example.test' })).resolves.toBe(false); + await expect(allowed(env, { origin: 'https://play.example.test:444' })).resolves.toBe(false); + }); + + test.each([ + 'null', + 'not a URL', + 'ftp://play.example.test', + 'https://user:password@play.example.test', + 'https://play.example.test/socket/path', + 'https://play.example.test/./', + 'https://play.example.test?query=yes', + 'https://play.example.test#fragment', + '' + ])('rejects malformed browser Origin %p', async (origin) => { + await expect(allowed({ + NODE_ENV: 'production', + HOSTED_BY: 'https://play.example.test' + }, { origin })).resolves.toBe(false); + }); + + test('rejects a non-string or multi-value Origin header', async () => { + const env = { NODE_ENV: 'production', HOSTED_BY: 'https://play.example.test' }; + await expect(allowed(env, { origin: ['https://play.example.test'] })).resolves.toBe(false); + await expect(allowed(env, { origin: null })).resolves.toBe(false); + }); + + test.each([ + undefined, + '', + 'not a URL', + 'ftp://play.example.test', + 'https://user:password@play.example.test' + ])('fails closed for invalid production HOSTED_BY %p', async (hostedBy) => { + const env = { NODE_ENV: 'production', HOSTED_BY: hostedBy }; + await expect(allowed(env, {})).resolves.toBe(false); + await expect(allowed(env, { origin: 'https://play.example.test' })).resolves.toBe(false); + }); + + test('normalizers preserve default-port normalization and reject request paths', () => { + expect(normalizedConfiguredOrigin('https://EXAMPLE.test:443/path')).toBe('https://example.test'); + expect(normalizedRequestOrigin('https://EXAMPLE.test:443/')).toBe('https://example.test'); + expect(normalizedRequestOrigin('https://example.test/path')).toBeNull(); + }); +}); diff --git a/test/soloLiabilityAtomic.test.js b/test/soloLiabilityAtomic.test.js new file mode 100644 index 0000000..a949817 --- /dev/null +++ b/test/soloLiabilityAtomic.test.js @@ -0,0 +1,161 @@ +const fs = require('fs'); +const path = require('path'); +const GameModeManager = require('../src/game/gameModeManager'); + +function paymentConfig() { + return { + getConfig: () => ({ + paymentsEnabled: true, + currency: { symbol: 'WOW', decimals: 11 }, + modes: { + direct: { enabled: true, price: 100n, requiresAddress: true }, + credits: { enabled: true, creditsPerGame: 1, requiresAddress: true, packages: [{ id: 'p', credits: 1, price: 100n }] } + }, + payouts: { + enabled: true, + rules: { + direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, minPayout: 1n, maxPayout: 1000n }, + credits: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, baseValue: 100n, minPayout: 1n, maxPayout: 1000n } + } + }, + preferences: { preferCreditsFirst: true }, + earlyEntry: { enabled: false } + }), + getLegacyGameMode: () => 'PAID_SINGLE', + eventBus: { on() {} } + }; +} + +function managerWith(db) { + const manager = new GameModeManager( + db, + { processPayout: jest.fn(), processBatchPayout: jest.fn() }, + { CONSOLE_LOGGING: false }, + paymentConfig() + ); + manager._scheduleBatchPayout = jest.fn(); + return manager; +} + +describe('solo liability commitment', () => { + test('credit debit and immutable payout terms are written in the same transaction', async () => { + const calls = []; + const client = { + async query(sql, params) { + calls.push({ sql, params }); + if (/UPDATE users[\s\S]*credits = credits -/i.test(sql)) { + return { rows: [{ credits: 4, total_credits_purchased: 5 }], rowCount: 1 }; + } + if (/UPDATE games SET game_mode = 'PAID_CREDITS'/i.test(sql)) { + return { rows: [{ id: 10 }], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + } + }; + const db = { + query: jest.fn(), + withTransaction: jest.fn(async callback => callback(client)) + }; + const manager = managerWith(db); + + const result = await manager._processGameStartWithCredits({ id: 1, credits: 5, payout_address: 'addr' }, 'sock', 'seed'); + + expect(result.success).toBe(true); + expect(db.withTransaction).toHaveBeenCalledTimes(1); + const commitment = calls.find(call => /payout_committed_at = NOW\(\)/i.test(call.sql)); + expect(commitment).toBeDefined(); + expect(commitment.params[6]).toBe(true); + expect(JSON.parse(commitment.params[7])).toEqual(expect.objectContaining({ + version: 2, + mode: 'PAID_CREDITS', + eligible: true, + escapeAmount: '200', + treasureAmount: '300', + minAmount: '1', + maxAmount: '1000' + })); + }); + + test('a missing game row aborts before a credit ledger entry can be committed', async () => { + const calls = []; + const client = { + async query(sql) { + calls.push(sql); + if (/UPDATE users[\s\S]*credits = credits -/i.test(sql)) { + return { rows: [{ credits: 4 }], rowCount: 1 }; + } + if (/UPDATE games SET game_mode = 'PAID_CREDITS'/i.test(sql)) { + return { rows: [], rowCount: 0 }; + } + return { rows: [], rowCount: 1 }; + } + }; + const manager = managerWith({ query: jest.fn(), withTransaction: async callback => callback(client) }); + + await expect(manager._processGameStartWithCredits({ id: 1, credits: 5, payout_address: 'addr' }, 'sock', 'missing')) + .rejects.toMatchObject({ code: 'GAME_ROW_REQUIRED' }); + expect(calls.some(sql => /INSERT INTO credit_transactions/i.test(sql))).toBe(false); + }); + + test('completion rolls back if its payout obligation cannot be inserted', async () => { + const durable = { status: 'active', payout: false }; + const gameRow = { + id: 5, + user_id: 1, + game_mode: 'PAID_SINGLE', + status: 'active', + payout_address: 'addr', + payout_eligible: true, + payout_terms: { + version: 1, + eligible: true, + escapeAmount: '200', + treasureAmount: '300', + escapeMultiplier: 2, + treasureMultiplier: 3, + minAmount: '1', + maxAmount: '1000' + } + }; + const db = { + query: jest.fn(), + async withTransaction(callback) { + const tx = { status: durable.status, payout: durable.payout }; + const client = { + async query(sql) { + if (/FROM games[\s\S]*FOR UPDATE/i.test(sql)) return { rows: [gameRow], rowCount: 1 }; + if (/UPDATE games[\s\S]*completed_at = NOW/i.test(sql)) { + tx.status = 'won'; + return { rows: [], rowCount: 1 }; + } + if (/SELECT id, status FROM payouts/i.test(sql)) return { rows: [], rowCount: 0 }; + if (/SELECT id, payout_address FROM users/i.test(sql)) return { rows: [{ id: 1, payout_address: 'addr' }], rowCount: 1 }; + if (/INSERT INTO payouts/i.test(sql)) throw new Error('payout insert failed'); + return { rows: [], rowCount: 1 }; + } + }; + const value = await callback(client); + durable.status = tx.status; + durable.payout = tx.payout; + return value; + } + }; + const manager = managerWith(db); + + const result = await manager.completeGame('sock', 'seed', true, false, {}); + + expect(result.success).toBe(false); + expect(durable).toEqual({ status: 'active', payout: false }); + }); + + test('database identity guards cover every payout status and commitments are immutable', () => { + const migration = fs.readFileSync( + path.join(__dirname, '../src/migrations/032_solo_liability_invariants.sql'), + 'utf8' + ); + expect(migration).toMatch(/CREATE UNIQUE INDEX idx_payouts_one_per_game[\s\S]*WHERE game_id IS NOT NULL;/i); + expect(migration).toMatch(/CREATE UNIQUE INDEX idx_payouts_one_per_match[\s\S]*WHERE match_id IS NOT NULL;/i); + expect(migration).not.toMatch(/CREATE UNIQUE INDEX idx_payouts_one_per_(?:game|match)[\s\S]{0,160}status IN/i); + expect(migration).toMatch(/reject_game_payout_commitment_mutation/i); + }); +}); diff --git a/test/soloSettlementRetry.test.js b/test/soloSettlementRetry.test.js new file mode 100644 index 0000000..f9bd80c --- /dev/null +++ b/test/soloSettlementRetry.test.js @@ -0,0 +1,193 @@ +const GameManager = require('../src/game/gameManager'); +const MovementManager = require('../src/game/movementManager'); +const SuspendedGameManager = require('../src/network/suspendedGameManager'); + +function makeHarness(completions, { retryMs = 1000 } = {}) { + const targeted = []; + const io = { + to: jest.fn(socketId => ({ + emit: (event, payload) => targeted.push({ socketId, event, payload }) + })), + emit: jest.fn() + }; + const game = { + id: 'solo-terminal-seed', + socketId: 'socket-1', + dbUserId: 44, + player: { hasTreasure: true }, + moveCount: 17, + startedAt: Date.now() - 2400, + movePlayer: jest.fn(), + endGame: jest.fn(function endGame() { this.gameState = 'ended'; }), + getProofReveal: jest.fn(() => ({ commitment: 'proof', seed: 'revealed-after-terminal' })) + }; + const activeGames = new Map([['socket-1', game]]); + const gameModeManager = { + completeGame: jest.fn(), + db: { query: jest.fn().mockResolvedValue({ rows: [{ name: 'Alice' }] }) } + }; + for (const result of completions) gameModeManager.completeGame.mockResolvedValueOnce(result); + const manager = new GameManager({ + activeGames, + io, + broadcastManager: {}, + debugManager: { CONSOLE_LOGGING: false }, + gameModeManager, + settlementRetryBaseMs: retryMs, + settlementRetryMaxMs: retryMs + }); + return { manager, game, activeGames, gameModeManager, io, targeted }; +} + +describe('solo terminal settlement retry', () => { + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + test('retains one frozen result after success:false and publishes it once after retry succeeds', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-21T12:00:00Z')); + const harness = makeHarness([ + { success: false, error: 'Temporary database error' }, + { success: true, payout: { payoutId: 91, status: 'queued', amount: '300', multiplier: 3 } } + ]); + + const first = await harness.manager.handleGameOver( + { id: 'socket-1' }, + harness.game, + 'won', + 'escaped', + 'You escaped!', + 777 + ); + + expect(first.success).toBe(false); + expect(harness.activeGames.get('socket-1')).toBe(harness.game); + expect(harness.game.settlementPending).toBe(true); + expect(harness.game.endGame).toHaveBeenCalledTimes(1); + expect(harness.targeted.filter(event => event.event === 'game_over')).toHaveLength(0); + expect(harness.targeted.filter(event => event.event === 'game_settlement_pending')).toHaveLength(1); + expect(harness.io.emit).not.toHaveBeenCalledWith('win_feed', expect.anything()); + + // Retaining the game blocks both movement and replacement-game creation while the exact + // terminal transaction is unresolved. + const movement = new MovementManager({ + activeGames: harness.activeGames, + io: harness.io, + debugManager: {}, + moveCooldown: 0 + }); + movement.handleMove('socket-1', { direction: 'right' }); + expect(harness.game.movePlayer).not.toHaveBeenCalled(); + await expect(harness.manager.createGameForUser({ id: 'socket-1' })) + .rejects.toThrow('active or settlement-pending game'); + + // Even accidental object mutation cannot change the already-frozen DB arguments. + harness.game.moveCount = 999; + harness.game.player.hasTreasure = false; + + await jest.advanceTimersByTimeAsync(1000); + + expect(harness.gameModeManager.completeGame).toHaveBeenCalledTimes(2); + for (const call of harness.gameModeManager.completeGame.mock.calls) { + expect(call).toEqual([ + 'socket-1', + 'solo-terminal-seed', + true, + true, + { + moves: 17, + durationSeconds: 2, + score: 777, + reason: 'escaped', + outcome: 'escaped' + } + ]); + } + expect(harness.activeGames.has('socket-1')).toBe(false); + expect(harness.game.settlementPending).toBe(false); + expect(harness.game.settlementCommitted).toBe(true); + expect(harness.targeted.filter(event => event.event === 'game_over')).toHaveLength(1); + expect(harness.io.emit.mock.calls.filter(([event]) => event === 'leaderboard_update')).toHaveLength(1); + expect(harness.io.emit.mock.calls.filter(([event]) => event === 'win_feed')).toHaveLength(1); + + // A late duplicate terminal callback is a no-op after commit. + await harness.manager.handleGameOver( + { id: 'socket-1' }, harness.game, 'lost', 'timeout', 'Too late', 0 + ); + expect(harness.gameModeManager.completeGame).toHaveBeenCalledTimes(2); + expect(harness.targeted.filter(event => event.event === 'game_over')).toHaveLength(1); + }); + + test('graceful shutdown cancels timers and drains an unresolved terminal result before DB close', async () => { + jest.useFakeTimers(); + const harness = makeHarness([ + { success: false, error: 'Temporary database error' }, + { success: true, payout: null } + ], { retryMs: 60000 }); + + await harness.manager.handleGameOver( + { id: 'socket-1' }, harness.game, 'lost', 'monster', 'Caught', 0 + ); + const result = await harness.manager.shutdown({ timeoutMs: 1000, retryIntervalMs: 1 }); + + expect(result).toEqual({ initial: 1, settled: 1, pending: 0 }); + expect(harness.gameModeManager.completeGame).toHaveBeenCalledTimes(2); + expect(harness.activeGames.size).toBe(0); + expect(jest.getTimerCount()).toBe(0); + }); + + test('targets the reconnect socket and removes its re-keyed ownership after settlement', async () => { + jest.useFakeTimers(); + const harness = makeHarness([ + { success: false, error: 'Temporary database error' }, + { success: true, payout: null } + ]); + + await harness.manager.handleGameOver( + { id: 'socket-1' }, harness.game, 'won', 'escaped', 'Escaped', 500 + ); + + // Mirrors SuspendedGameManager.restoreGame(): the same object moves to the new socket key. + harness.activeGames.delete('socket-1'); + harness.game.socketId = 'socket-2'; + harness.activeGames.set('socket-2', harness.game); + + await jest.advanceTimersByTimeAsync(1000); + + expect(harness.gameModeManager.completeGame.mock.calls[1][0]).toBe('socket-2'); + expect(harness.targeted.filter(event => event.event === 'game_over')).toEqual([ + expect.objectContaining({ socketId: 'socket-2' }) + ]); + expect(harness.activeGames.has('socket-1')).toBe(false); + expect(harness.activeGames.has('socket-2')).toBe(false); + }); + + test('reconnect restores a pending terminal lock without re-counting it as a playable game', () => { + jest.useFakeTimers(); + const activeGames = new Map(); + const suspended = new SuspendedGameManager({ + debugManager: { CONSOLE_LOGGING: false }, + activeGames, + cleanupTimeoutMs: 60000 + }); + const game = { + id: 'pending-reconnect', + settlementPending: true, + player: { x: 1, y: 2, hasTreasure: true }, + moveCount: 9 + }; + const newUser = { joinGame: jest.fn() }; + + suspended.suspendGame(44, 'socket-old', game); + const restored = suspended.restoreGame(44, 'socket-new', newUser); + + expect(restored.game).toBe(game); + expect(activeGames.get('socket-new')).toBe(game); + expect(newUser.joinGame).not.toHaveBeenCalled(); + expect(game.user).toBe(newUser); + suspended.cleanup(); + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/test/spSceneAdapter_ultra.test.js b/test/spSceneAdapter_ultra.test.js index 31c8252..d3ba48d 100644 --- a/test/spSceneAdapter_ultra.test.js +++ b/test/spSceneAdapter_ultra.test.js @@ -54,4 +54,59 @@ describe('SP client-state → Scene adapter', () => { const scene = RK.sceneFromGameState(state, {}); expect(scene.grid[0][0]).toBe('floor'); }); + + test('multiplayer players[] become racers and an explicit socket id owns the camera', () => { + const state = { + visibleTiles: [[F, F, F], [F, F, F]], + exploredTiles: [[F, F, F], [F, F, F]], + players: [ + { id: 'rival', x: 0, y: 0, name: 'Rival', alive: true }, + { id: 'mine', x: 2, y: 1, name: 'Me', avatar: 'char-ranger', alive: true, placement: 2 } + ], + entrance: [0, 0], exit: [2, 1], monster: null, lighting: {} + }; + + // String second args were used by the original match client; keep them backward compatible. + const scene = RK.sceneFromGameState(state, 'mine'); + const racers = scene.entities.filter(e => e.kind === 'player'); + expect(racers).toHaveLength(2); + expect(racers.find(e => e.id === 'mine')).toMatchObject({ + x: 2, y: 1, label: 'Me', avatar: 'char-ranger', you: true, cameraTarget: true, placement: 2 + }); + expect(racers.find(e => e.id === 'rival')).toMatchObject({ you: false, cameraTarget: false }); + }); + + test('server you flag is honored and spectator camera falls back to a live racer', () => { + const base = { + visibleTiles: [[F, F, F]], exploredTiles: [[F, F, F]], + entrance: [0, 0], exit: [2, 0], monster: null, lighting: {} + }; + const marked = RK.sceneFromGameState({ + ...base, + players: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 1, y: 0, you: true }] + }, {}); + expect(marked.entities.find(e => e.id === 'b')).toMatchObject({ you: true, cameraTarget: true }); + + const spectator = RK.sceneFromGameState({ + ...base, + players: [ + { id: 'dead', x: 0, y: 0, alive: false }, + { id: 'live', x: 1, y: 0, alive: true }, + { id: 'done', x: 2, y: 0, alive: true, finished: true } + ] + }, { viewerId: 'spectator' }); + expect(spectator.entities.find(e => e.cameraTarget).id).toBe('live'); + expect(spectator.entities.some(e => e.you)).toBe(false); + }); + + test('match treasure object shape renders until it has a carrier', () => { + const base = { + visibleTiles: [[F, F]], exploredTiles: [[F, F]], + players: [{ id: 'a', x: 0, y: 0 }], monster: null, lighting: {} + }; + const loose = RK.sceneFromGameState({ ...base, treasure: { x: 1, y: 0, carrierId: null } }, {}); + expect(loose.entities.some(e => e.id === 'treasure')).toBe(true); + const carried = RK.sceneFromGameState({ ...base, treasure: { x: 1, y: 0, carrierId: 'a' } }, {}); + expect(carried.entities.some(e => e.id === 'treasure')).toBe(false); + }); }); diff --git a/test/sql/economic_invariants.sql b/test/sql/economic_invariants.sql new file mode 100644 index 0000000..77622f9 --- /dev/null +++ b/test/sql/economic_invariants.sql @@ -0,0 +1,269 @@ +-- Behavioral smoke test for migrations 032/035/037/038/039/040. +-- Run with psql -X -v ON_ERROR_STOP=1 after migrations. Everything rolls back. +BEGIN; + +DO $economic_invariants$ +DECLARE + owner_id INTEGER; + other_id INTEGER; + product_payment_id INTEGER; + solo_payment_id INTEGER; + game_row_id INTEGER; + payout_row_id INTEGER; + match_row_id UUID; + receipt_row_id BIGINT; + receipt_tx CHAR(64) := md5(random()::text) || md5(clock_timestamp()::text); + receipt_output CHAR(64) := md5(clock_timestamp()::text) || md5(random()::text); + saved_address TEXT := 'test-wallet-' || txid_current()::text; +BEGIN + INSERT INTO users (socket_id, username, payout_address) + VALUES ('economic-owner-' || txid_current()::text, 'economic-owner', saved_address) + RETURNING id INTO owner_id; + + INSERT INTO users (socket_id, username, payout_address) + VALUES ('economic-other-' || txid_current()::text, 'economic-other', 'test-wallet-other') + RETURNING id INTO other_id; + + INSERT INTO payments ( + user_id, socket_id, subaddress, address_index, expected_amount, payment_type, + payment_mode, credit_package_id, product_id, product_grants, description, + provider_id, provider_invoice_id, status, expires_at + ) VALUES ( + owner_id, 'economic-product', 'test-product-subaddress', 771, 100, + 'credits_package', 'credits', 'small', 'small', '{"credits":10}'::jsonb, + '10 credits', 'native-monero', 'test-product-invoice', 'pending', NOW() + INTERVAL '1 hour' + ) RETURNING id INTO product_payment_id; + + -- Legacy pending rows retain one pre-receipt path to snapshot their product promise. + UPDATE payments + SET product_grants = '{"credits":10,"bonus":1}'::jsonb + WHERE id = product_payment_id; + + INSERT INTO payment_receipts ( + payment_id, provider_id, evidence_type, evidence_id, tx_hash, output_id, + address_index, amount, confirmed + ) VALUES ( + product_payment_id, 'native-monero', 'chain_output', + receipt_tx || ':' || receipt_output, receipt_tx, receipt_output, 771, 100, TRUE + ) RETURNING id INTO receipt_row_id; + + BEGIN + UPDATE payments SET expected_amount = 1 WHERE id = product_payment_id; + RAISE EXCEPTION 'receipt-backed invoice price mutation was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + UPDATE payments SET product_grants = '{"credits":999}'::jsonb WHERE id = product_payment_id; + RAISE EXCEPTION 'receipt-backed product promise mutation was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + -- Settlement and refund state remain operationally mutable. + UPDATE payments + SET status = 'confirmed', received_amount = 100, confirmed_at = NOW() + WHERE id = product_payment_id; + UPDATE payments SET status = 'refunded' WHERE id = product_payment_id; + + BEGIN + UPDATE payments SET provider_invoice_id = 'rewritten' WHERE id = product_payment_id; + RAISE EXCEPTION 'refunded invoice provider identity mutation was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + INSERT INTO payments ( + user_id, socket_id, subaddress, address_index, expected_amount, payment_type, + product_id, product_grants, provider_id, provider_invoice_id, status + ) VALUES ( + owner_id, 'economic-expired', 'test-expired-subaddress', 773, 50, + 'credits_package', 'expired-small', '{"credits":5}'::jsonb, + 'native-monero', 'test-expired-invoice', 'expired' + ) RETURNING id INTO product_payment_id; + + BEGIN + UPDATE payments SET subaddress = 'rewritten-expired-address' WHERE id = product_payment_id; + RAISE EXCEPTION 'expired invoice destination mutation was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + UPDATE payment_receipts SET amount = 1 WHERE id = receipt_row_id; + RAISE EXCEPTION 'payment receipt update was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + DELETE FROM payment_receipts WHERE id = receipt_row_id; + RAISE EXCEPTION 'payment receipt deletion was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + INSERT INTO payments ( + user_id, socket_id, subaddress, address_index, expected_amount, payment_type, + provider_id, provider_invoice_id, status, fairness_proof_version, + fairness_offer_id, fairness_offer_issued_at, fairness_commitment, + fairness_server_seed, fairness_client_seed, fairness_bound_at + ) VALUES ( + owner_id, 'economic-solo', 'test-solo-subaddress', 772, 100, 'single_game', + 'native-monero', 'test-solo-invoice', 'pending', 2, + 'test-offer-' || txid_current()::text, NOW(), repeat('a', 64), + repeat('b', 64), 'client-seed', NOW() + ) RETURNING id INTO solo_payment_id; + + BEGIN + UPDATE payments SET user_id = other_id WHERE id = solo_payment_id; + RAISE EXCEPTION 'fairness-bound invoice owner mutation was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + INSERT INTO games ( + user_id, socket_id, game_mode, payment_id, status, dungeon_seed, + payout_escape_amount, payout_treasure_amount, payout_escape_mult, + payout_treasure_mult, payout_address, payout_eligible, payout_terms, + payout_committed_at + ) VALUES ( + owner_id, 'economic-game', 'PAID_SINGLE', solo_payment_id, 'active', + 'economic-seed-' || txid_current()::text, 200, 300, 2, 3, + saved_address, TRUE, '{"escape":200,"treasure":300}'::jsonb, NOW() + ) RETURNING id INTO game_row_id; + + BEGIN + UPDATE games SET user_id = other_id WHERE id = game_row_id; + RAISE EXCEPTION 'committed game owner mutation was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + UPDATE games SET moves_made = moves_made + 1 WHERE id = game_row_id; + + INSERT INTO payouts ( + user_id, game_id, payout_address, amount, multiplier, reason, status + ) VALUES ( + owner_id, game_row_id, 'PENDING_NO_ADDRESS', 200, 2, + 'solo_winner_no_address', 'needs_review' + ) RETURNING id INTO payout_row_id; + + -- The sole destination exception is the exact saved-address claim transition. + UPDATE payouts + SET payout_address = saved_address, status = 'pending' + WHERE id = payout_row_id; + UPDATE payouts SET retry_count = retry_count + 1 WHERE id = payout_row_id; + + BEGIN + UPDATE payouts SET payout_address = 'test-wallet-rewritten' WHERE id = payout_row_id; + RAISE EXCEPTION 'claimed payout destination mutation was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + UPDATE payouts SET amount = 1 WHERE id = payout_row_id; + RAISE EXCEPTION 'payout amount mutation was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + INSERT INTO payouts ( + user_id, payout_address, amount, multiplier, reason, status + ) VALUES ( + owner_id, 'PENDING_NO_ADDRESS', 1, 1, + 'solo_winner_no_address', 'pending' + ); + RAISE EXCEPTION 'invalid no-address payout shape was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + -- An unresolved paid winner remains recorded for manual review, but its missing owner means + -- the saved-address exception must never make it dispatchable automatically. + INSERT INTO payouts ( + user_id, payout_address, amount, multiplier, reason, status + ) VALUES ( + NULL, 'PENDING_NO_ADDRESS', 1, 1, + 'solo_winner_identity_review', 'needs_review' + ) RETURNING id INTO payout_row_id; + + BEGIN + UPDATE payouts + SET payout_address = saved_address, status = 'pending' + WHERE id = payout_row_id; + RAISE EXCEPTION 'unresolved solo winner address claim was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + INSERT INTO matches ( + economy, variant, ruleset_id, difficulty_preset, max_players, + seed_hash, dungeon, start_block_height + ) VALUES ( + 'credits_prestige', 'pvp', 'last-alive', 'race', 2, + repeat('c', 64), + jsonb_build_object('match_fairness_freeze', jsonb_build_object( + 'version', 'future-block-freeze-v2', + 'freezeBlockHeight', 800, + 'targetBlockHeight', 802, + 'entropyDelayBlocks', 2, + 'economy', 'credits_prestige', + 'rulesetId', 'last-alive', + 'queueEntryIds', jsonb_build_array('81', '82'), + 'freezeCommitment', repeat('c', 64) + )), + 802 + ) RETURNING id INTO match_row_id; + + BEGIN + UPDATE matches SET entropy_precommit_tip_height = 800 WHERE id = match_row_id; + RAISE EXCEPTION 'partial paid entropy precommit marker was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + UPDATE matches + SET entropy_precommit_tip_height = 800, + entropy_precommit_verified_at = NOW() + WHERE id = match_row_id; + + BEGIN + UPDATE matches SET entropy_precommit_tip_height = 799 WHERE id = match_row_id; + RAISE EXCEPTION 'paid entropy precommit witness mutation was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + UPDATE matches SET seed_hash = repeat('d', 64) WHERE id = match_row_id; + RAISE EXCEPTION 'verified pending paid freeze identity mutation was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + -- The single activation transition may replace the freeze envelope with the derived proof. + UPDATE matches + SET status = 'starting', + seed_hash = repeat('e', 64), + dungeon = jsonb_build_object('match_fairness', jsonb_build_object( + 'version', 'future-chain-block-v2', + 'freezeCommitment', repeat('c', 64) + )) + WHERE id = match_row_id; + + BEGIN + UPDATE matches SET ruleset_id = 'race' WHERE id = match_row_id; + RAISE EXCEPTION 'active paid match fairness identity mutation was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + UPDATE matches SET status = 'active', started_at = NOW() WHERE id = match_row_id; + + RAISE NOTICE 'economic invariants behavioral test passed'; +END +$economic_invariants$; + +ROLLBACK; diff --git a/test/stagenetFinancialCanary.test.js b/test/stagenetFinancialCanary.test.js new file mode 100644 index 0000000..45410f8 --- /dev/null +++ b/test/stagenetFinancialCanary.test.js @@ -0,0 +1,382 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const { + LIVE_CONFIRM, + SAFE_PROFILE_CONFIRM, + SCENARIOS, + addVisible, + assertEmptyFinancialDatabase, + assertInvoiceNotOwned, + assertLiveSafety, + canonicalAcknowledgement, + chooseMove, + readConfiguration, + safeMessage, + validateExpectedDatabaseName, + verifyCanaryDatabaseHandshake, + verifyGameOver +} = require('../src/scripts/stagenet-financial-canary'); +const { + CANARY_HANDSHAKE_PROTOCOL, + computeProof +} = require('../src/services/canaryDatabaseIdentity'); + +const SCRIPT = path.join(__dirname, '../src/scripts/stagenet-financial-canary.js'); + +function configEnv(overrides = {}) { + return { + E2E_MODE: 'preflight', + E2E_TARGET: 'http://127.0.0.1:3102', + ...overrides + }; +} + +function botState() { + return { + goal: null, + objective: null, + frontiersTried: new Set() + }; +} + +function visibleState({ player, exit, treasure }) { + return { + player, + exit, + treasure, + monster: null, + visibleTiles: { + 0: { 0: "'1", 1: "'1", 2: "'1" }, + 1: { 0: "'1", 1: "'1", 2: "'1" } + } + }; +} + +describe('XMR stagenet financial canary safety contract', () => { + test('recognizes only the two explicit financial scenarios', () => { + expect(Object.keys(SCENARIOS)).toEqual(['direct-2x', 'credits-3x']); + expect(SCENARIOS['direct-2x']).toEqual(expect.objectContaining({ + paymentType: 'single_game', + gameMode: 'PAID_SINGLE', + multiplier: 2, + collectTreasure: false + })); + expect(SCENARIOS['credits-3x']).toEqual(expect.objectContaining({ + paymentType: 'credits_package', + gameMode: 'PAID_CREDITS', + multiplier: 3, + collectTreasure: true + })); + expect(() => readConfiguration(configEnv({ E2E_SCENARIO: 'anything-else' }))) + .toThrow(/direct-2x or credits-3x/); + }); + + test('refuses public targets, embedded credentials, paths, and the wrong port', () => { + expect(() => readConfiguration(configEnv({ E2E_TARGET: 'https://example.test:3102' }))) + .toThrow(/localhost/); + expect(() => readConfiguration(configEnv({ E2E_TARGET: 'http://user:pass@127.0.0.1:3102' }))) + .toThrow(/credentials/); + expect(() => readConfiguration(configEnv({ E2E_TARGET: 'http://127.0.0.1:3000' }))) + .toThrow(/3102/); + expect(() => readConfiguration(configEnv({ E2E_TARGET: 'http://127.0.0.1:3102/app' }))) + .toThrow(/path/); + }); + + test('requires a scenario outside public preflight and scenario-specific live intent', () => { + expect(() => readConfiguration(configEnv({ E2E_MODE: 'database-preflight' }))) + .toThrow(/E2E_SCENARIO/); + + const env = configEnv({ + E2E_MODE: 'live-stagenet', + E2E_SCENARIO: 'credits-3x', + E2E_MAX_TRANSFER_ATOMIC: '500000000000', + E2E_CONFIRM: LIVE_CONFIRM, + E2E_SCENARIO_CONFIRM: SCENARIOS['credits-3x'].scenarioConfirm, + E2E_CANARY_PROFILE: SAFE_PROFILE_CONFIRM, + E2E_DATABASE_NONCE_FILE: '/run/credentials/canary/database-nonce' + }); + const config = readConfiguration(env); + expect(() => assertLiveSafety(config, env)).not.toThrow(); + expect(() => assertLiveSafety(config, { ...env, E2E_SCENARIO_CONFIRM: 'DIRECT_2X_ESCAPE' })) + .toThrow(/CREDITS_PACKAGE_THEN_3X/); + expect(() => assertLiveSafety(config, { ...env, E2E_CONFIRM: '' })) + .toThrow(/E2E_CONFIRM/); + const noNonceConfig = readConfiguration({ ...env, E2E_DATABASE_NONCE_FILE: '' }); + expect(() => assertLiveSafety(noNonceConfig, env)).toThrow(/DATABASE_NONCE_FILE/); + expect(() => assertLiveSafety( + readConfiguration({ ...env, E2E_MAX_TRANSFER_ATOMIC: '' }), env + )).toThrow(/MAX_TRANSFER/); + }); + + test('builds exactly the current five-field stagenet acknowledgement', () => { + const acknowledgement = canonicalAcknowledgement({ + policyVersion: '2026-07-21-v2', + paidAcknowledgementRequired: true, + service: { + cryptoType: 'XMR', + network: 'stagenet', + isTestNetwork: true, + paymentsEnabled: true, + directPaidEntryEnabled: true, + paidCreditsEnabled: true, + soloPayoutsEnabled: true, + anyPayoutsEnabled: true, + paidPrestigeOnly: false + } + }); + expect(acknowledgement).toEqual({ + policyVersion: '2026-07-21-v2', + ageEligible: true, + termsRead: true, + riskAccepted: true, + testnetUnderstood: true + }); + expect(Object.keys(acknowledgement)).toHaveLength(5); + expect(Object.isFrozen(acknowledgement)).toBe(true); + expect(() => canonicalAcknowledgement({ + policyVersion: 'v1', + paidAcknowledgementRequired: true, + service: { + cryptoType: 'XMR', + network: 'mainnet', + isTestNetwork: false, + paymentsEnabled: true, + directPaidEntryEnabled: true, + paidCreditsEnabled: true, + soloPayoutsEnabled: true, + anyPayoutsEnabled: true, + paidPrestigeOnly: false + } + })).toThrow(/stagenet/); + }); + + test('requires a scenario-named dedicated canary E2E database', () => { + expect(() => validateExpectedDatabaseName( + 'monerogue_canary_direct_e2e', SCENARIOS['direct-2x'] + )).not.toThrow(); + expect(() => validateExpectedDatabaseName( + 'monerogue_canary_credits_e2e', SCENARIOS['credits-3x'] + )).not.toThrow(); + expect(() => validateExpectedDatabaseName('monerogue', SCENARIOS['direct-2x'])) + .toThrow(/canary and e2e/); + expect(() => validateExpectedDatabaseName( + 'monerogue_canary_credits_e2e', SCENARIOS['direct-2x'] + )).toThrow(/scenario tag/); + }); + + test('freshness guard covers all financial/game ledgers and current migrations', async () => { + const row = { + users: 1, + seed_admins: 1, + required_migrations: 5, + payments: 0, + games: 0, + payouts: 0, + receipts: 0, + refunds: 0, + late_reviews: 0, + credit_transactions: 0, + entitlement_grants: 0, + pack_entitlements: 0, + matches: 0, + match_entrants: 0, + match_events: 0, + match_queue_entries: 0, + race_entry_transactions: 0, + race_entry_lots: 0 + }; + const write = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + const db = { query: jest.fn().mockResolvedValue({ rows: [row] }) }; + await expect(assertEmptyFinancialDatabase(db)).resolves.toBeUndefined(); + expect(db.query.mock.calls[0][0]).toContain('payment_receipts'); + expect(db.query.mock.calls[0][0]).toContain('payment_entitlement_grants'); + expect(db.query.mock.calls[0][0]).toContain('race_entry_lots'); + + const dirty = { query: jest.fn().mockResolvedValue({ + rows: [{ ...row, receipts: 1 }] + }) }; + await expect(assertEmptyFinancialDatabase(dirty)).rejects.toThrow(/receipts/); + } finally { + write.mockRestore(); + } + }); + + test('verifies the app and harness exact database identity before the live runner', async () => { + const directory = fs.mkdtempSync('/tmp/wowngeon-canary-handshake-'); + const nonceFile = path.join(directory, 'nonce'); + const nonce = 'cd'.repeat(32); + fs.writeFileSync(nonceFile, `${nonce}\n`, { mode: 0o600 }); + const config = readConfiguration(configEnv({ + E2E_MODE: 'live-stagenet', + E2E_SCENARIO: 'direct-2x', + E2E_MAX_TRANSFER_ATOMIC: '1000', + E2E_DATABASE_NONCE_FILE: nonceFile + })); + const identity = { + clusterId: '7612345678901234567', + databaseOid: '24591', + databaseName: 'monerogue_canary_direct_e2e' + }; + const db = { query: jest.fn().mockResolvedValue({ rows: [{ + cluster_id: identity.clusterId, + database_oid: identity.databaseOid, + database_name: identity.databaseName + }] }) }; + const request = jest.fn(async (_target, relative, options) => { + const challenge = options.headers['X-Canary-Database-Challenge']; + return { + response: { + status: 200, + headers: { get: name => name === 'cache-control' ? 'no-store' : null } + }, + body: { + protocol: CANARY_HANDSHAKE_PROTOCOL, + challenge, + database: identity, + proof: computeProof(nonce, identity, challenge) + } + }; + }); + const write = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await expect(verifyCanaryDatabaseHandshake(config, db, { + E2E_EXPECT_DATABASE: identity.databaseName + }, request)).resolves.toBeUndefined(); + expect(request).toHaveBeenCalledWith(config.target, + '/api/canary/database-identity', expect.any(Object)); + } finally { + write.mockRestore(); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + test('requires affirmative proof that invoice and funding wallets differ', () => { + expect(() => assertInvoiceNotOwned({ + error: { message: "Address doesn't belong to the wallet" } + })).not.toThrow(); + expect(() => assertInvoiceNotOwned({ result: { index: { major: 0, minor: 1 } } })) + .toThrow(/self-payment/); + expect(() => assertInvoiceNotOwned({ error: { message: 'daemon unavailable' } })) + .toThrow(/could not prove/); + }); + + test('direct bot avoids treasure while credits bot gets treasure before exit', () => { + const directState = visibleState({ + player: { x: 0, y: 0, hasTreasure: false }, + treasure: [1, 0], + exit: [0, 1] + }); + const directKnown = new Map(); + addVisible(directKnown, directState); + expect(chooseMove(directKnown, directState, botState(), SCENARIOS['direct-2x'])) + .toBe('down'); + + const creditsState = visibleState({ + player: { x: 0, y: 0, hasTreasure: false }, + treasure: [1, 0], + exit: [2, 0] + }); + const creditsKnown = new Map(); + addVisible(creditsKnown, creditsState); + const creditsBot = botState(); + expect(chooseMove(creditsKnown, creditsState, creditsBot, SCENARIOS['credits-3x'])) + .toBe('right'); + const collected = { + ...creditsState, + player: { x: 1, y: 0, hasTreasure: true }, + treasure: null + }; + expect(chooseMove(creditsKnown, collected, creditsBot, SCENARIOS['credits-3x'])) + .toBe('right'); + }); + + test.each([ + ['direct-2x', 10n, false], + ['credits-3x', 7n, true] + ])('verifies exact game-over liability and two-party proof for %s', (scenarioName, base, treasure) => { + const scenario = SCENARIOS[scenarioName]; + const serverSeed = '11'.repeat(32); + const clientSeed = '22'.repeat(32); + const commitment = crypto.createHash('sha256').update(serverSeed).digest('hex'); + const effectiveSeed = crypto.createHmac('sha256', serverSeed).update(clientSeed).digest('hex'); + const offer = { offerId: '33'.repeat(32), commitment }; + const gameId = 'game-proof-id'; + const expected = base * BigInt(scenario.multiplier); + const gameOver = { + status: 'won', + reason: 'escaped', + treasure, + payout: { + success: true, + mode: scenario.gameMode, + payout: { + status: 'queued', + payoutId: 9, + amount: expected.toString(), + multiplier: scenario.multiplier + } + }, + proof: { + gameId, + offerId: offer.offerId, + commitment, + clientSeed, + serverSeed, + effectiveSeed, + gameResult: { won: true, treasureFound: treasure } + } + }; + const write = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + expect(verifyGameOver(gameOver, { + scenario, payoutBase: base, offer, clientSeed + }, { proof: { gameId } })).toEqual({ + expected, + payoutId: 9, + gameId + }); + } finally { + write.mockRestore(); + } + }); + + test('redacts addresses, hashes, tokens, credentials, and database URL passwords', () => { + const address = '5'.repeat(95); + const hash = 'a'.repeat(64); + const raw = `${address} ${hash} token=secret {"authorization":"jsonsecret"} Bearer othersecret ` + + 'postgres://alice:password@example.invalid/db'; + const safe = safeMessage(raw); + expect(safe).toContain('[redacted-address]'); + expect(safe).toContain('[redacted-64-byte-value]'); + expect(safe).not.toContain(address); + expect(safe).not.toContain(hash); + expect(safe).not.toContain('secret'); + expect(safe).not.toContain('password@example'); + }); + + test('source has one non-retriable relay call, a guarded main, and both exact flows', () => { + const source = fs.readFileSync(SCRIPT, 'utf8'); + expect((source.match(/funding\.rpc\('transfer'/g) || [])).toHaveLength(1); + expect(source).toMatch(/transferGate\.attempted = true;[\s\S]*do_not_relay: false/); + expect(source).toContain('if (require.main === module)'); + expect(source).toContain("type: 'single_game'"); + expect(source).toContain("type: 'credits_package'"); + expect(source).toContain("socket.emit('auto_start'"); + expect(source).toContain('legalAcknowledgement: acknowledgement'); + expect(source).not.toContain('console.log('); + const mainBody = source.slice(source.indexOf('async function main(')); + expect(mainBody.indexOf('assertLiveSafety(config, env)')) + .toBeLessThan(mainBody.indexOf('publicPreflight(config)')); + expect(mainBody.indexOf('verifyCanaryDatabaseHandshake(config, db, env)')) + .toBeLessThan(mainBody.indexOf('runLive(config, modules, db, legal, env)')); + + const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '../src/package.json'), 'utf8')); + expect(manifest.scripts['canary:stagenet']) + .toBe('node scripts/stagenet-financial-canary.js'); + }); +}); diff --git a/test/tavernMatchBridge.test.js b/test/tavernMatchBridge.test.js index 6d57128..6b7c42a 100644 --- a/test/tavernMatchBridge.test.js +++ b/test/tavernMatchBridge.test.js @@ -27,15 +27,19 @@ describe('TavernMatchBridge', () => { return { id, economy: 'free', + variant: 'race', tickCount: 5, status: 'active', + rows: 2, + cols: 3, occupants: new Map([['a', { id: 'a', x: 1, y: 2, name: 'A', avatar: 'default', facing: 'down' }]]), playerStates: new Map([['a', { alive: true, finished: false, escaped: false, hasTreasure: false, placement: null }]]), monster: { x: 3, y: 4 }, treasure: null, - dungeon: { exit: [10, 10] }, + dungeon: { map: [["'1", "'1", '#'], ['#', "'1", '>']], entrance: [0, 0], exit: [2, 1] }, winnerId: null, - seedHash: 'deadbeef' + seedHash: 'deadbeef', + seed: 'never-publish-this' }; } @@ -65,4 +69,30 @@ describe('TavernMatchBridge', () => { expect(list[0].matchId).toBe('m3'); expect(list[0].players.length).toBe(1); }); + + test('public snapshots include a renderer-safe map but never the unrevealed seed', () => { + const room = makeRoom('m-render'); + room.toGameState = () => ({ + visibleTiles: room.dungeon.map, + exploredTiles: room.dungeon.map, + lighting: {}, + dungeonRows: 2, + dungeonCols: 3, + players: [{ id: 'a', x: 1, y: 1, alive: true }], + monster: { x: 0, y: 1 }, + treasure: { x: 1, y: 0, carrierId: null }, + entrance: [0, 0], + exit: [2, 1], + seed: room.seed, + privateEvents: [{ input: 'left' }] + }); + const { bridge } = makeBridge({ rooms: [room] }); + const snap = bridge.getActiveMatches()[0]; + + expect(snap.visibleTiles).toEqual(room.dungeon.map); + expect(snap.exploredTiles).toEqual(room.dungeon.map); + expect(snap).toMatchObject({ dungeonRows: 2, dungeonCols: 3, seedHash: 'deadbeef' }); + expect(snap.seed).toBeUndefined(); + expect(snap.privateEvents).toBeUndefined(); + }); }); diff --git a/test/verifyPageOg.test.js b/test/verifyPageOg.test.js index 1061e3f..af12ceb 100644 --- a/test/verifyPageOg.test.js +++ b/test/verifyPageOg.test.js @@ -14,7 +14,7 @@ describe('renderVerifyPage social card meta', () => { ogImage: 'og-card-xmr.png' }); expect(html).toContain('property="og:title"'); - expect(html).toContain('Monero Roguelike — Provably-Fair Roguelike'); + expect(html).toContain('Monero Roguelike — Dungeon Layout Verification'); expect(html).toContain(''); expect(html).toContain('content="summary_large_image"'); expect(html).toContain(''); @@ -39,4 +39,17 @@ describe('renderVerifyPage social card meta', () => { expect(html).not.toContain('