mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 15:48:33 +02:00
feat: productionize paid modes and PvP capture
This commit is contained in:
+86
-34
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"}}]'
|
||||
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+42
-18
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
+27
-15
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
+92
-13
@@ -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;
|
||||
module.exports = Game;
|
||||
|
||||
+343
-112
@@ -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;
|
||||
module.exports = GameManager;
|
||||
|
||||
+1339
-291
File diff suppressed because it is too large
Load Diff
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+83
-14
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
+472
-200
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
+110
-13
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
module.exports = AddressManager;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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)),
|
||||
|
||||
+292
-40
@@ -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);
|
||||
|
||||
+320
-109
@@ -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;
|
||||
|
||||
+975
-82
File diff suppressed because it is too large
Load Diff
+767
-51
@@ -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;
|
||||
|
||||
+358
-58
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
+56
-26
@@ -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;
|
||||
module.exports = QueueHandler;
|
||||
|
||||
+22
-13
@@ -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)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+174
-78
@@ -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;
|
||||
}
|
||||
|
||||
+366
-80
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Generated
+4
@@ -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",
|
||||
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
: [],
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
module.exports = { generatePaymentQR };
|
||||
|
||||
@@ -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;
|
||||
module.exports = WalletRPCService;
|
||||
module.exports.cryptoTypeFromAddress = cryptoTypeFromAddress;
|
||||
module.exports.endpointLabel = endpointLabel;
|
||||
|
||||
+205
-234
@@ -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
|
||||
});
|
||||
}));
|
||||
|
||||
|
||||
+219
-64
@@ -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' });
|
||||
}
|
||||
|
||||
+334
-94
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+128
-15
@@ -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 {
|
||||
<ul>
|
||||
<li><strong>Current Balance:</strong> ${balanceFormatted} ${cryptoType}</li>
|
||||
<li><strong>Warning Threshold:</strong> ${thresholdFormatted} ${cryptoType}</li>
|
||||
<li><strong>Critical Threshold:</strong> ${(this.balanceCriticalThreshold / divisor).toFixed(6)} ${cryptoType}</li>
|
||||
<li><strong>Critical Threshold:</strong> ${formatAtomicFixed(this.balanceCriticalThreshold, decimals, 6)} ${cryptoType}</li>
|
||||
</ul>
|
||||
<p>Please top up the wallet soon to avoid service interruption.</p>
|
||||
<p>If balance drops below the critical threshold, new games will be automatically halted.</p>
|
||||
@@ -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: `
|
||||
<h2>Financial transfers require manual review</h2>
|
||||
<ul>
|
||||
<li><strong>Payouts:</strong> ${payoutReviewCount}</li>
|
||||
<li><strong>Refunds:</strong> ${refundReviewCount}</li>
|
||||
</ul>
|
||||
<p>Do not resend these transfers. Reconcile each row against wallet history and transaction evidence.</p>
|
||||
`
|
||||
});
|
||||
} 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: `
|
||||
<h2>Financial transfers are stale</h2>
|
||||
<ul>
|
||||
<li><strong>Processing payouts:</strong> ${stalePayoutCount}</li>
|
||||
<li><strong>Requested/processing refunds:</strong> ${staleRefundCount}</li>
|
||||
<li><strong>Age threshold:</strong> ${staleMinutes} minutes</li>
|
||||
</ul>
|
||||
<p>A processing row may represent a broadcast transfer. Do not retry it until wallet reconciliation proves no transfer occurred.</p>
|
||||
`
|
||||
});
|
||||
} 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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 `<strong>${label}</strong>`;
|
||||
return `<a href="${escapeHtml(operator.contactUrl)}" rel="noopener">${label}</a>`;
|
||||
}
|
||||
|
||||
function modeSummary(disclosure) {
|
||||
const service = disclosure.service || {};
|
||||
const items = [];
|
||||
if (service.freePlayEnabled) {
|
||||
items.push('<li><strong>Free play:</strong> no entry payment and a separate free leaderboard; no free-play payout.</li>');
|
||||
}
|
||||
if (service.directPaidEntryEnabled) {
|
||||
items.push(`<li><strong>Paid single entry:</strong> one ${escapeHtml(service.currencyLabel)} payment purchases one qualifying entry.</li>`);
|
||||
}
|
||||
if (service.paidCreditsEnabled) {
|
||||
items.push('<li><strong>Paid credits:</strong> a cryptocurrency purchase grants the displayed credits or products. Credits are service records, not cryptocurrency balances.</li>');
|
||||
}
|
||||
if (service.paidPrestigeOnly) {
|
||||
items.push('<li><strong>Paid prestige mode:</strong> paid scores use the paid leaderboard, but this server does not currently offer a cryptocurrency prize.</li>');
|
||||
}
|
||||
if (service.soloPayoutsEnabled) {
|
||||
items.push('<li><strong>Reward-enabled solo play:</strong> a paid entry can be lost. Only the outcomes and payout amounts shown before entry are eligible.</li>');
|
||||
}
|
||||
if (service.cryptoMatchPayoutsEnabled) {
|
||||
items.push('<li><strong>Reward-enabled PvP:</strong> the configured race may award the collected pot after the displayed operator fee. Other PvP economies remain separate.</li>');
|
||||
}
|
||||
if (service.isTestNetwork) {
|
||||
items.push(`<li><strong>Test network:</strong> ${escapeHtml(service.currencyLabel)} is test currency with no intended monetary value. Never send ${escapeHtml(service.cryptoType)} mainnet funds to a test-network address.</li>`);
|
||||
}
|
||||
return items.length ? `<ul>${items.join('')}</ul>` : '<p>This instance currently exposes no paid play.</p>';
|
||||
}
|
||||
|
||||
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 `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)} — ${gameName}</title>
|
||||
<link rel="stylesheet" href="/styles/legal.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav aria-label="Legal and service navigation"><a href="/">Game</a><a href="/terms">Terms</a><a href="/privacy">Privacy</a><a href="/responsible-play">Responsible play</a></nav>
|
||||
<header><p class="eyebrow">${operator}</p><h1>${escapeHtml(title)}</h1><p>${effective} · policy ${escapeHtml(disclosure.policyVersion)}</p></header>
|
||||
${body}
|
||||
<section class="notice"><h2>Important status</h2><p>${escapeHtml(disclosure.notices?.legalReview)}</p><p>${escapeHtml(disclosure.notices?.identityControl)}</p></section>
|
||||
<footer><p>Operator contact: ${contactMarkup(disclosure)}</p></footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderTerms(disclosure) {
|
||||
const jurisdiction = disclosure.notices?.jurisdiction
|
||||
? `<p><strong>Operator location notice:</strong> ${escapeHtml(disclosure.notices.jurisdiction)}</p>`
|
||||
: '<p>No location-specific restriction list is published here. You are responsible for confirming that your use is permitted where you are located.</p>';
|
||||
return shell({
|
||||
title: 'Terms of Use',
|
||||
disclosure,
|
||||
body: `
|
||||
<section><h2>Who may use the service</h2><p>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.</p>${jurisdiction}</section>
|
||||
<section><h2>Current service modes</h2><p>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.</p>${modeSummary(disclosure)}</section>
|
||||
<section><h2>Payments, credits, and outcomes</h2><p>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.</p><p>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.</p><p>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.</p></section>
|
||||
<section><h2>Fair play and acceptable use</h2><p>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.</p></section>
|
||||
<section><h2>Availability and changes</h2><p>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.</p></section>
|
||||
<section><h2>Problems and disputes</h2><p>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.</p></section>`
|
||||
});
|
||||
}
|
||||
|
||||
function renderPrivacy(disclosure) {
|
||||
return shell({
|
||||
title: 'Privacy Notice',
|
||||
disclosure,
|
||||
body: `
|
||||
<section><h2>Data the service handles</h2><ul><li>A random browser session token stored in local storage and the matching account record.</li><li>Network and security data such as IP address, connection identifiers, rate-limit events, user agent information available to the web stack, and application logs.</li><li>Chosen display name, avatar/loadout, scores, game proofs, gameplay, leaderboard records, credit and ticket ledgers, and support evidence.</li><li>Payment addresses, payout addresses, invoice/subaddress records, transaction hashes, amounts, confirmations, and payout status. Public blockchain activity remains public independently of this service.</li><li>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.</li></ul></section>
|
||||
<section><h2>Why it is used</h2><p>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.</p></section>
|
||||
<section><h2>Where it goes</h2><p>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.</p></section>
|
||||
<section><h2>Storage and retention</h2><p>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.</p></section>
|
||||
<section><h2>Your choices</h2><p>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.</p></section>
|
||||
<section><h2>Security limits</h2><p>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.</p></section>`
|
||||
});
|
||||
}
|
||||
|
||||
function renderResponsiblePlay(disclosure) {
|
||||
const network = disclosure.service?.isTestNetwork
|
||||
? `<p class="notice"><strong>Test-network reminder:</strong> ${escapeHtml(disclosure.service.currencyLabel)} is for testing and has no intended monetary value. Never substitute mainnet funds.</p>`
|
||||
: '';
|
||||
return shell({
|
||||
title: 'Responsible Play',
|
||||
disclosure,
|
||||
body: `
|
||||
<section><h2>Know which mode you chose</h2>${modeSummary(disclosure)}${network}<p>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.</p></section>
|
||||
<section><h2>Set limits before playing</h2><ul><li>Choose a time limit and a cryptocurrency spending limit before opening an invoice.</li><li>Use only funds you can afford to lose completely. Never borrow, use essential funds, or try to recover previous losses.</li><li>Stop after the planned limit, after a loss, when frustrated, or when tired or impaired.</li><li>Keep a wallet-level spending record and review transaction history rather than relying on memory.</li><li>Do not play paid modes if you are under ${escapeHtml(disclosure.minimumAge)} or if play is unlawful where you are.</li></ul></section>
|
||||
<section><h2>Take a break or stop</h2><p>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.</p><p>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.</p></section>
|
||||
<section><h2>Operational safeguards are not personal limits</h2><p>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.</p></section>`
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { escapeHtml, renderPrivacy, renderResponsiblePlay, renderTerms };
|
||||
+115
-33
@@ -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(/</g, '\\u003c');
|
||||
|
||||
// Social card description reflects the actual result when we have a record.
|
||||
let ogDescription = `Provably-fair roguelike — escape the dungeon before the next block. Verify any game was fair.`;
|
||||
let ogDescription = `Committed-layout roguelike — verify the published seed generated the recorded dungeon depths.`;
|
||||
if (gameRecord) {
|
||||
const escaped = gameRecord.status === 'won';
|
||||
const bag = gameRecord.treasure_found ? ' with the treasure bag' : '';
|
||||
ogDescription = escaped
|
||||
? `Escaped the dungeon${bag} in ${gameRecord.moves_made || '?'} moves. Cryptographically verified provably fair.`
|
||||
: `A provably-fair run that didn't make it out. Verify the seed generated this exact dungeon.`;
|
||||
? `Escaped the dungeon${bag} in ${gameRecord.moves_made || '?'} moves. Verify the committed dungeon layout.`
|
||||
: `A run that didn't make it out. Verify the committed seed generated its recorded dungeon layout.`;
|
||||
}
|
||||
const ogTitle = `${name} — Provably-Fair Roguelike`;
|
||||
const ogTitle = `${name} — Dungeon Layout Verification`;
|
||||
const metaTags = `
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="${esc(ogTitle)}">
|
||||
@@ -43,6 +44,12 @@ function renderVerifyPage(gameId, gameRecord, opts = {}) {
|
||||
<meta name="twitter:description" content="${esc(ogDescription)}">
|
||||
${ogImage ? `<meta name="twitter:image" content="${esc(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 ? `
|
||||
<div class="box">
|
||||
<h3>Game Record</h3>
|
||||
@@ -52,6 +59,17 @@ function renderVerifyPage(gameId, gameRecord, opts = {}) {
|
||||
<p><strong>Moves:</strong> ${esc(gameRecord.moves_made || 'N/A')}</p>
|
||||
<p><strong>Duration:</strong> ${gameRecord.duration_seconds ? esc(gameRecord.duration_seconds) + 's' : 'N/A'}</p>
|
||||
<p><strong>Created:</strong> ${esc(gameRecord.created_at)}</p>
|
||||
${hasV2Proof ? `
|
||||
<p><strong>Proof version:</strong> ${esc(gameRecord.proof_version || 2)}</p>
|
||||
<p><strong>Published commitment:</strong> <code>${esc(String(gameRecord.proof_commitment).trim())}</code></p>
|
||||
<p><strong>Client seed:</strong> <code>${esc(gameRecord.client_seed || '(empty)')}</code></p>
|
||||
<p><strong>Revealed server seed:</strong> <code>${esc(String(gameRecord.server_seed).trim())}</code></p>
|
||||
<p><strong>Derived effective seed:</strong> <code>${esc(String(gameRecord.effective_seed).trim())}</code></p>
|
||||
<p><strong>Generator version:</strong> <code>${esc(gameRecord.generator_version || gameRecord.proof_context?.generatorVersion || 'legacy')}</code></p>
|
||||
<p><strong>Verification scope:</strong> ${recordedDepths ? `All ${recordedDepths} dungeon depth(s)` : 'Legacy level 1 only'}</p>
|
||||
<p><strong>Played layout fingerprint:</strong> <code>${esc(String(gameRecord.layout_fingerprint || '').trim())}</code></p>
|
||||
<button onclick="verifyRecorded()">🔍 Verify recorded proof + dungeon</button>
|
||||
` : '<p class="info">This legacy/in-progress game has no revealed v2 proof.</p>'}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
@@ -78,61 +96,125 @@ function renderVerifyPage(gameId, gameRecord, opts = {}) {
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔐 ${esc(name)} Provably Fair Verification</h1>
|
||||
<h1>🔐 ${esc(name)} Committed Dungeon Verification</h1>
|
||||
|
||||
<div class="box">
|
||||
<h3>How it works:</h3>
|
||||
<ol>
|
||||
<li>Before game start, you received a <strong>commitment hash</strong> (SHA-256)</li>
|
||||
<li>After the game, you received the <strong>seed</strong></li>
|
||||
<li>This page verifies: <code>SHA256(seed) === commitment</code></li>
|
||||
<li>The seed deterministically generated your dungeon layout</li>
|
||||
<li>The server publishes <code>SHA256(serverSeed)</code> before your browser chooses a client seed.</li>
|
||||
<li>Your one-time offer and client seed are bound to the game before dungeon generation.</li>
|
||||
<li>After the game, the server seed is revealed and this page re-derives the effective seed with HMAC-SHA256.</li>
|
||||
<li>The effective seed regenerates every advertised depth and each must match its versioned recorded fingerprint.</li>
|
||||
</ol>
|
||||
<p class="info">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.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h3>Verify Game</h3>
|
||||
<label>Game Seed (revealed after game):</label>
|
||||
<input type="text" id="seed" placeholder="64 character hex string">
|
||||
<h3>Manual v2 verification</h3>
|
||||
<label>Server seed (revealed after game):</label>
|
||||
<input type="text" id="serverSeed" placeholder="64 character hex string" value="${hasV2Proof ? esc(String(gameRecord.server_seed).trim()) : ''}">
|
||||
|
||||
<label>Client seed (accepted before game; empty for legacy clients):</label>
|
||||
<input type="text" id="clientSeed" placeholder="up to 64 hexadecimal characters" value="${hasV2Proof ? esc(gameRecord.client_seed || '') : ''}">
|
||||
|
||||
<label>Effective dungeon seed:</label>
|
||||
<input type="text" id="effectiveSeed" placeholder="64 character hex string" value="${hasV2Proof ? esc(String(gameRecord.effective_seed).trim()) : ''}">
|
||||
|
||||
<label>Commitment Hash (shown before game):</label>
|
||||
<input type="text" id="commitment" placeholder="64 character hex string">
|
||||
<input type="text" id="commitment" placeholder="64 character hex string" value="${hasV2Proof ? esc(String(gameRecord.proof_commitment).trim()) : ''}">
|
||||
|
||||
<button onclick="verify()">🔍 Verify</button>
|
||||
<button onclick="verifyManual()">🔍 Verify seed relationship</button>
|
||||
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
${recordBlock}
|
||||
<div class="box info">
|
||||
<p>For technical verification, you can also use the API:</p>
|
||||
<code>GET /api/verify?seed=YOUR_SEED&commitment=YOUR_COMMITMENT</code>
|
||||
<code>GET /api/verify/${esc(gameId)}</code>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function verify() {
|
||||
const seed = document.getElementById('seed').value.trim();
|
||||
const commitment = document.getElementById('commitment').value.trim();
|
||||
const result = document.getElementById('result');
|
||||
const gameId = ${gameIdJson};
|
||||
|
||||
if (!seed || !commitment) {
|
||||
result.innerHTML = '<p class="error">Please enter both seed and commitment.</p>';
|
||||
function clearResult() {
|
||||
const result = document.getElementById('result');
|
||||
result.textContent = '';
|
||||
return result;
|
||||
}
|
||||
|
||||
function appendLine(parent, text, className) {
|
||||
const line = document.createElement('p');
|
||||
if (className) line.className = className;
|
||||
line.textContent = String(text == null ? '' : text);
|
||||
parent.appendChild(line);
|
||||
}
|
||||
|
||||
function appendCodeLine(parent, label, value, suffix) {
|
||||
const line = document.createElement('p');
|
||||
line.appendChild(document.createTextNode(label));
|
||||
const code = document.createElement('code');
|
||||
code.textContent = String(value == null ? '' : value);
|
||||
line.appendChild(code);
|
||||
if (suffix) line.appendChild(document.createTextNode(suffix));
|
||||
parent.appendChild(line);
|
||||
}
|
||||
|
||||
function showFailure(message) {
|
||||
const result = clearResult();
|
||||
appendLine(result, message || 'Verification failed', 'error');
|
||||
}
|
||||
|
||||
function show(data) {
|
||||
const result = clearResult();
|
||||
if (data.valid) {
|
||||
appendLine(result, data.message, 'success');
|
||||
appendCodeLine(result, 'Commitment: ', data.computedCommitment);
|
||||
appendCodeLine(result, 'Effective seed: ', data.computedEffectiveSeed);
|
||||
if (Array.isArray(data.levels)) {
|
||||
const depthSummary = data.levels.map(function(level) {
|
||||
const state = level.matches === true ? 'match' : (level.matches === false ? 'mismatch' : 'unscoped');
|
||||
return String(level.depth) + ':' + state;
|
||||
}).join(', ');
|
||||
appendCodeLine(result, 'Verified depths: ', depthSummary);
|
||||
}
|
||||
if (data.layoutFingerprint) {
|
||||
appendCodeLine(result, 'Layout fingerprint: ', data.layoutFingerprint,
|
||||
data.layoutMatches === true ? ' ✅' : '');
|
||||
}
|
||||
} else {
|
||||
showFailure(data.message || data.error || 'Verification failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyRecorded() {
|
||||
try {
|
||||
const response = await fetch('/api/verify/' + encodeURIComponent(gameId));
|
||||
show(await response.json());
|
||||
} catch (err) {
|
||||
showFailure('Error: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyManual() {
|
||||
const serverSeed = document.getElementById('serverSeed').value.trim();
|
||||
const clientSeed = document.getElementById('clientSeed').value.trim();
|
||||
const effectiveSeed = document.getElementById('effectiveSeed').value.trim();
|
||||
const commitment = document.getElementById('commitment').value.trim();
|
||||
|
||||
if (!serverSeed || !effectiveSeed || !commitment) {
|
||||
showFailure('Please enter server seed, effective seed, and commitment.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/verify?seed=' + encodeURIComponent(seed) + '&commitment=' + encodeURIComponent(commitment));
|
||||
const data = await response.json();
|
||||
|
||||
if (data.valid) {
|
||||
result.innerHTML = '<p class="success">✅ ' + data.message + '</p>' +
|
||||
'<p>Computed hash: <code>' + data.computedHash + '</code></p>';
|
||||
} else {
|
||||
result.innerHTML = '<p class="error">❌ ' + data.message + '</p>' +
|
||||
'<p>Expected: <code>' + data.expectedCommitment + '</code></p>' +
|
||||
'<p>Got: <code>' + data.computedHash + '</code></p>';
|
||||
}
|
||||
const query = '?serverSeed=' + encodeURIComponent(serverSeed) +
|
||||
'&clientSeed=' + encodeURIComponent(clientSeed) +
|
||||
'&effectiveSeed=' + encodeURIComponent(effectiveSeed) +
|
||||
'&commitment=' + encodeURIComponent(commitment);
|
||||
const response = await fetch('/api/verify' + query);
|
||||
show(await response.json());
|
||||
} catch (err) {
|
||||
result.innerHTML = '<p class="error">Error: ' + err.message + '</p>';
|
||||
showFailure('Error: ' + err.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user