diff --git a/.env.example b/.env.example index 74c3826..998cfbd 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,15 @@ BASE_RPC_URL=https://mainnet.base.org # Optional comma-separated fallback RPCs for receipt verification. # BASE_RPC_FALLBACK_URLS=https://base-rpc.publicnode.com DATABASE_URL=postgresql://user:password@localhost:5432/megastera +# Optional direct PostgreSQL URL for Prisma migrations and mineral cutover preparation. +# DIRECT_URL= +# Optional server-only key for the same-origin Megapot Data API proxy. +# MEGAPOT_API_KEY= +# Optional V2 wallet-mineral cutover. Empty keeps Economy v2 disabled. +# Must be an exact UTC midnight, for example 2026-08-21T00:00:00.000Z. +# MINERAL_ECONOMY_CUTOVER_AT= +# Keep disabled until the upgrade route has owner-bound authentication. +# MINERAL_UPGRADES_ENABLED=false # Receipt proofs are accepted only after this many newer canonical blocks. MEGAPLANETS_CONFIRMATIONS=6 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 938f28d..2775f14 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -58,3 +58,52 @@ jobs: - name: Build production bundle run: pnpm build + + postgres: + name: PostgreSQL economy concurrency + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: megaplanets + POSTGRES_PASSWORD: megaplanets + POSTGRES_DB: megaplanets + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U megaplanets -d megaplanets" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgresql://megaplanets:megaplanets@127.0.0.1:5432/megaplanets + DIRECT_URL: postgresql://megaplanets:megaplanets@127.0.0.1:5432/megaplanets + MINERAL_ECONOMY_TEST_DATABASE_URL: postgresql://megaplanets:megaplanets@127.0.0.1:5432/megaplanets + MINERAL_ECONOMY_REQUIRE_POSTGRES: '1' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm db:generate + + - name: Apply full Prisma migration chain + run: pnpm db:migrate:deploy + + - name: Run real PostgreSQL concurrency suite + run: pnpm test:postgres diff --git a/.gitignore b/.gitignore index bebf76c..4723e95 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ dist-ssr .data/ api/generated/ server/api/generated/ +.codegraph/ # editor .vscode/* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e295862 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ + +## CodeGraph + +In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code: + +- **MCP tool** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search. +- **Shell** (always works): `codegraph explore ""` prints the same output. + +If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision. + + +## Local environment files + +- Keep `.env.local` named exactly `.env.local` so Vite loads it; on macOS, run `chflags nohidden .env.local` after creating it so Finder displays the file. Never print, commit, or expose its contents. + +For Megapot contract work, fetch [https://llms.megapot.io](https://llms.megapot.io) diff --git a/README.md b/README.md index 1c9d6f8..bd75d9a 100644 --- a/README.md +++ b/README.md @@ -190,18 +190,21 @@ The current product includes: - wallet connection and Megapot ticket purchasing; - Base mainnet ticket flow; - coordinate selection and direct checkout; -- keeper-assisted bulk purchases for larger all-random expeditions; +- keeper-assisted bulk purchases for 11–50 ticket expeditions, including up to ten selected-coordinate tickets; - canonical `TicketPurchased` receipt recovery and verification; - deterministic planet generation from immutable ticket provenance; - deterministic planet traits and GIF rendering; - persistent planet and ticket records in PostgreSQL; - retry-safe pending states while receipts finalize or generation completes; - `My Planets` collection and ticket status; -- continuous mineral production; -- wallet leaderboard; +- live mineral production with same-type collection bonuses; +- current wallet leaderboard; +- opt-in mineral-economy-v2 spendable balances and persisted per-Planet upgrade primitives; - Megapot drawing state, ticket history and winnings; - onchain winnings claim flow after simulation; -- Season 1 leaderboard competition and manual reward settlement. +- Season 1 leaderboard countdown/prize presentation; manual reward settlement is outside the runtime/API in this repository. + +This worktree also contains an opt-in mineral-economy-v2 path. Before the prepared cutover, wallet mining and the current leaderboard use the V1 live calculation. After cutover, the backend reconstructs spendable mineral scores from opening balances, historical production, collection/upgrade rate changes, and persisted upgrade costs. The unauthenticated upgrade endpoint remains server-disabled until owner-bound authorization is implemented. Planet generation is idempotent and keyed by ticket provenance, so the same valid ticket cannot create duplicate planets through repeated generation requests. @@ -231,6 +234,7 @@ The browser handles interaction, but it is not the authority for planet creation - Planet generation is deterministic and idempotent. - Conflicting persisted provenance fails closed. - Mining is derived from persisted planet data rather than mutable browser state. +- After the mineral-economy cutover, wallet balances and current leaderboard scores are derived from persisted integer-micro account and upgrade history; V1 remains the pre-cutover fallback. - Generated GIF bytes are stored with an immutable content hash. - Server credentials and database secrets never enter the Vite client environment. @@ -242,7 +246,7 @@ For deeper implementation details, see [`docs/ARCHITECTURE.md`](docs/ARCHITECTUR Season 1 establishes the core **Megapot ticket → planet → mining → competition** loop. -Future seasons can expand that foundation with planet upgrades, colonies, stars, ships, trading, and deeper player-to-player interaction. Existing planets are intended to remain relevant as the universe grows rather than becoming obsolete after a season. +Future seasons can expand that foundation with colonies, stars, ships, trading, and deeper player-to-player interaction. This worktree's mineral upgrade path is feature-gated and operator-controlled; it is not a completed reward-system rollout. Existing planets are intended to remain relevant as the universe grows rather than becoming obsolete after a season. ## Repository diff --git a/api/README.md b/api/README.md index 65c4286..977710f 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,6 @@ # Megastera API -The active API verifies finalized Megapot `TicketPurchased` receipt logs, persists the canonical ticket row, generates one deterministic Planet GIF per receipt log, and serves the database-backed collection, mining snapshots, and leaderboard. +The active API verifies finalized Megapot `TicketPurchased` receipt logs, persists the canonical ticket row, generates one deterministic Planet GIF per receipt log, and serves the database-backed collection, live mining snapshots, and current leaderboard. It also exposes the same-origin Megapot Data API proxy. ## HTTP surface @@ -9,13 +9,18 @@ The active API verifies finalized Megapot `TicketPurchased` receipt logs, persis - `POST /api/planets/generate` - `POST /api/planets/generate/batch` - `GET /api/planets?owner=...` +- `GET /api/planets/collection?owner=...` - `GET /api/planets/:planetId` - `GET /api/planets/:planetId/gif` -- `GET /api/planets/:planetId/mining` +- `POST /api/planets/:planetId/upgrade` - `GET /api/wallets/:address/mining` -- `GET /api/leaderboard/current`, `/current/:address`, `/history`, `/days/:periodId` +- `GET /api/leaderboard/current?offset=...&limit=...` +- `GET /api/leaderboard/current/:address` +- `GET /api/megapot/*` (proxy to the Base mainnet Megapot Data API) -Planet vouchers, Pinata/IPFS artifacts, Planet contract reads/writes, direct holdings, and continuous indexers are intentionally not exposed. +The batch generation body accepts 1–50 receipt references. Archived leaderboard periods are written by the separate `pnpm api:leaderboard-worker` process but are not exposed by the current HTTP surface. The unauthenticated upgrade endpoint always returns `404`; its persistence primitives remain available for authenticated server-side integration later. + +Planet vouchers, Pinata/IPFS artifacts, Planet contract reads/writes, direct holdings, continuous indexers, and leaderboard history/finalization routes are intentionally not exposed. ## Local process @@ -23,13 +28,14 @@ Planet vouchers, Pinata/IPFS artifacts, Planet contract reads/writes, direct hol pnpm api:server ``` -The HTTP server defaults to `127.0.0.1:8787`; set `MEGAPLANETS_API_HOST` and `MEGAPLANETS_API_PORT` to override. Backend generation requires `DATABASE_URL` and `BASE_RPC_URL`; optional RPC failover is configured with `BASE_RPC_FALLBACK_URLS`. +The HTTP server defaults to `127.0.0.1:8787`; set `MEGAPLANETS_API_HOST` and `MEGAPLANETS_API_PORT` to override. Backend generation requires `DATABASE_URL` and `BASE_RPC_URL`; optional RPC failover is configured with `BASE_RPC_FALLBACK_URLS`. Set server-only `MEGAPOT_API_KEY` if the proxy should authenticate upstream requests. ## Safety boundary - `MEGAPLANETS_ALLOWED_ORIGINS` remains an exact CORS allowlist. - Request bodies are bounded to 16 KiB. +- Batch generation is bounded to 50 receipt references. - Receipt verification checks Base mainnet, canonical jackpot/source, event fields, confirmation depth, block hash, and optional wallet recipient. - Generation is idempotent on `originTxHash:logIndex`; conflicting persisted provenance is rejected. - GIF bytes are stored in PostgreSQL and served with an immutable content hash. -- Leaderboard reads are derived from ready persisted Planet records. +- Leaderboard reads are derived from ready persisted Planet records, collection milestones, and—after cutover—mineral account and upgrade history. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2976cba..5a6bdb7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Megastera architecture -This document describes the active Megastera architecture on Base mainnet. Historical NFT/indexer experiments remain in git history only; they are not runtime requirements. +This document describes the active Megastera architecture on Base mainnet and the opt-in mineral-economy-v2 path in this worktree. Legacy NFT/indexer models and migrations remain for database compatibility; they are not runtime requirements or active write paths. ## Sources of truth @@ -10,9 +10,11 @@ This document describes the active Megastera architecture on Base mainnet. Histo | Ticket eligibility | Finalized receipt containing canonical `MEGASTERA` `TicketPurchased` | `server/api/receiptVerification.ts` | | Historical ticket status and winnings | Base mainnet Megapot Data API wallet ticket feed | `src/lib/api.ts` / `useWalletTickets` | | Planet identity and media | Shared deterministic generator plus `BackendPlanet` row | API and My Planets | -| Planet ownership | `BackendPlanet.ownerAddress`, copied from the verified receipt recipient | API list/mining routes | -| Planet mining | `baseMineralsPerDay` and `generatedAt` | `server/api/miningStore.ts` | -| Leaderboard | Ready `BackendPlanet.generatedAt` + `baseMineralsPerDay` rows | Live read route with an approximately 60-second backend cache | +| Planet ownership | `BackendPlanet.ownerAddress`, copied from the verified receipt recipient | API list/wallet mining route | +| Planet mining | V1 `baseMineralsPerDay`/`generatedAt` calculation before cutover; V2 `MineralAccount`, collection events, and upgrade history after cutover | `server/api/miningStore.ts` / `mineralEconomy.ts` | +| Current leaderboard | V1 live rows before cutover; after cutover, opening balance + historical production − persisted upgrade costs | `server/api/leaderboardStore.ts` / `leaderboardRoutes.ts` | +| Mineral economy cutover | Exact UTC-midnight configuration reconciled with the persisted `MineralEconomyCutover` row and PostgreSQL clock | `server/api/backendConfig.ts` / `mineralAccounts.ts` | +| Archived leaderboard periods | `LeaderboardPeriod` and `LeaderboardEntry` rows written by the standalone finalization worker | Database/worker only; no archived-period HTTP route is mounted | ## Runtime flow @@ -28,7 +30,9 @@ Megapot receipt If generation fails after the receipt proof is saved, `GET /api/planets/collection` still returns a pending row. If the receipt is not final yet, the frontend keeps its canonical receipt in local storage and renders the same ticket as pending until the backend catch-up pass can persist and generate it. -Direct purchases use one to ten tickets. Eleven to fifty all-random tickets use the Megapot keeper facilitator. For bulk orders, each execution receipt is processed; the order-creation transaction is never used as Planet provenance. Every ticket remains tied to its immutable `originTxHash:logIndex` key. +Direct purchases use one to ten tickets. Eleven to fifty ticket expeditions use the Megapot keeper facilitator; up to ten tickets may carry selected coordinates and the rest are dynamic. For bulk orders, each execution receipt is processed; the order-creation transaction is never used as Planet provenance. Every ticket remains tied to its immutable `originTxHash:logIndex` key. + +When `MINERAL_ECONOMY_CUTOVER_AT` is absent or still in the future, wallet mining and current leaderboard reads use the V1 live calculation. Once the cutover is active, current leaderboard reads reconstruct each wallet's spendable score as opening balance plus historical production minus upgrade purchase costs, with collection and upgrade events changing the effective rate at their event times. The post-cutover read is computed from a repeatable-read database transaction. The source tag is always `MEGASTERA`. It is a Megapot attribution value and does not refer to a Planet NFT deployment. @@ -36,21 +40,23 @@ The source tag is always `MEGASTERA`. It is a Megapot attribution value and does The Vercel entrypoint `api/index.ts` delegates to `server/api/index.ts`, which mounts only: -- backend Planet generation, collection, GIF, and mining routes; -- live leaderboard routes; and +- backend Planet generation, collection, GIF, and wallet mining routes; +- the server-disabled Planet upgrade endpoint; +- live leaderboard routes; +- the same-origin `/api/megapot/*` proxy for the Megapot Data API; and - liveness/metrics routes. `server/api/receiptVerification.ts` performs the complete verification sequence against bounded RPC fallbacks: Base mainnet chain ID, receipt event fields, optional recipient, finalized block depth, canonical block hash, and block timestamp. `server/api/prismaTicketPurchase.ts` persists only the immutable ticket row required by backend generation. `server/api/backendPlanet.ts` derives the deterministic traits and GIF and upserts one row per ticket purchase. Existing ready rows are returned unchanged; conflicting proof fields fail closed. Collection queries filter by Base mainnet, the active jackpot, and the `MEGASTERA` source tag. -No active module signs vouchers, pins media, reads a Planet contract, projects Planet events, scans all tickets continuously, or writes transfer/accrual ledgers. +No active module signs vouchers, pins media, reads a Planet contract, projects Planet events, scans all tickets continuously, or writes transfer/accrual ledgers. The upgrade endpoint remains server-disabled because it does not yet verify a request-wallet signature. ## Frontend boundaries `src/pages/Play.tsx` owns checkout and, after canonical receipt recovery in the purchase hooks, requests backend generation with bounded finality retries. The active UI has two stages: Buy tickets and Explore planets. On success it shows the generated cards full screen with `Explore again` and `My planets` actions. -`src/pages/Planets.tsx` merges the backend collection with locally confirmed site receipts and the complete paginated wallet ticket feed. Site tickets are always represented as a Planet or pending card; unmatched wallet tickets are plain ticket cards. It does not derive inventory from chain holdings or expose mint/reveal controls. `src/hooks/useWalletMining.ts` reads the backend wallet snapshot and locally interpolates no persistent state. The `/tickets` surface uses `useJackpotState` for current-drawing status/countdown and the Base mainnet Data API for wallet ticket/win rows; claims call `Jackpot.claimWinnings` directly after simulation. +`src/pages/Planets.tsx` merges the backend collection with locally confirmed site receipts and the complete paginated wallet ticket feed. Site tickets are always represented as a Planet or pending card; unmatched wallet tickets are plain ticket cards. It does not derive inventory from chain holdings or expose mint/reveal controls. `src/hooks/useWalletMining.ts` reads and validates the backend wallet snapshot, including current balance and upgrade state, and locally interpolates no persistent state. Upgrade controls remain hidden because the backend reports `upgradesEnabled: false`. The `/tickets` surface uses `useJackpotState` for current-drawing status/countdown and the Base mainnet Data API for wallet ticket/win rows; claims call `Jackpot.claimWinnings` directly after simulation. The landing experience may render deterministic planet previews for presentation. Those previews are not the authority for a purchased Planet; verified receipt provenance and the persisted backend record remain authoritative. @@ -64,6 +70,8 @@ The `BackendPlanet` model is linked one-to-one to `TicketPurchase` and stores: - GIF bytes and content hash; and - ready/failed status plus bounded generation error text. +This worktree additionally stores `upgradeLevel` and `upgradeBonusBps` on `BackendPlanet`, opening/current balances in `MineralAccount`, the authoritative cutover in `MineralEconomyCutover`, and idempotent `(planetId, targetLevel)` charges in `PlanetUpgradePurchase`. V2 calculations use integer micros and keep the V1 opening-balance calculation as the cutover anchor. + Legacy Prisma tables and migrations are retained only for database compatibility. No active code writes the legacy Planet, voucher, artifact, projector, or accrual paths. ## Verification map @@ -71,11 +79,11 @@ Legacy Prisma tables and migrations are retained only for database compatibility Run the repository quality gate from the root: ```text +pnpm db:generate +pnpm db:validate pnpm lint pnpm typecheck pnpm test pnpm --filter @megaplanets/planet-generator golden pnpm build -pnpm db:generate -pnpm db:validate ``` diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index dfe4416..effff7a 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -1,6 +1,6 @@ # Operations and deployment runbook -This runbook covers the active Megastera backend on Base mainnet. No Planet contract, NFT signer, Pinata credential, or indexer process is required. +This runbook covers the active Megastera backend on Base mainnet and the opt-in mineral-economy-v2 path in this worktree. No Planet contract, NFT signer, Pinata credential, or indexer process is required. Vercel routes `/api/**` to `api/index.ts`; the daily leaderboard finalizer is a separate process. ## Required environment @@ -10,11 +10,17 @@ Server values: BASE_RPC_URL BASE_RPC_FALLBACK_URLS # optional comma-separated URLs DATABASE_URL +DIRECT_URL # optional direct PostgreSQL URL; preparation/migrations fall back to DATABASE_URL MEGAPLANETS_CONFIRMATIONS=6 # optional, non-negative integer MEGAPLANETS_ALLOWED_ORIGINS # optional exact comma-separated origins +MEGAPOT_API_KEY # optional server-only key for /api/megapot/* +MEGAPLANETS_API_HOST # optional standalone server host +MEGAPLANETS_API_PORT # optional standalone server port +MINERAL_ECONOMY_CUTOVER_AT # optional exact UTC-midnight ISO timestamp; empty keeps V1 +MINERAL_UPGRADES_ENABLED=false # reserved; public upgrades remain server-disabled ``` -Frontend values use `VITE_*` only for public configuration, including the wallet/RPC and optional `VITE_BACKEND_API_BASE_URL`. The Megapot Data API defaults to `https://api.megapot.io/v1` for Base mainnet. If `VITE_API_BASE_URL=/api/megapot` is used, the proxy must target the same mainnet host. Never put `DATABASE_URL`, server tokens, or private keys in a Vite variable. +Frontend values use `VITE_*` only for public configuration, including `VITE_CHAIN`, wallet/RPC settings, `VITE_API_BASE_URL`, and optional `VITE_BACKEND_API_BASE_URL`. The Megapot Data API defaults to `https://api.megapot.io/v1` for Base mainnet. If `VITE_API_BASE_URL=/api/megapot` is used, the checked-in proxy targets that same mainnet host and may use server-only `MEGAPOT_API_KEY`. Never put `DATABASE_URL`, server tokens, or private keys in a Vite variable. ## Start and health checks @@ -29,7 +35,9 @@ Check: - `GET /api/planets/health` for liveness; - `GET /api/planets/metrics` for process-local HTTP counters; and -- `GET /api/planets/collection?owner=...` after a confirmed receipt generation. +- `GET /api/planets/collection?owner=...` after a confirmed receipt generation; +- `GET /api/leaderboard/current` for live standings; and +- `/api/megapot/*` for the same-origin Megapot Data API proxy. There is intentionally no readiness route that probes a Planet contract and no indexer process to start. @@ -45,26 +53,61 @@ GIFs are stored in PostgreSQL and served with an immutable content hash. A missi ## Mining and leaderboard -Mining and leaderboard reads are lazy/live: +With no `MINERAL_ECONOMY_CUTOVER_AT`, mining and leaderboard reads follow the V1 lazy/live path: ```text baseMineralsPerDay × elapsed milliseconds × 1_000_000 / 86_400_000 ``` -The start time is the backend generation timestamp. The browser never writes accrual or ledger rows. Public leaderboard routes calculate current rows from ready backend Planet `generatedAt` and `baseMineralsPerDay` values and use an in-process cache for approximately 60 seconds. Daily snapshot tables/migrations remain for database compatibility, but daily workers/finalize routes are not active. +The start time is the backend generation timestamp. Same-type collection milestones apply at 3 (+5%), 5 (+7.5%), and 10 (+10%) matching Planets. The browser never writes accrual or ledger rows. Before cutover, public leaderboard routes calculate current rows from ready backend Planet `generatedAt` and `baseMineralsPerDay` values and apply the V1 collection calculation. Live leaderboard reads are uncached. + +When the prepared cutover is active, the wallet mining route settles `MineralAccount` balance in integer micros from the cutover forward. New Planet generation settles the owner's account before inserting the new Planet. Level 1/2/3 persistence primitives retain +10%/+25%/+50% bonuses and idempotent `(planetId, targetLevel)` charges, but `POST /api/planets/:planetId/upgrade` remains server-disabled until owner-bound authorization exists. A configured cutover that was not persisted before PostgreSQL reaches it fails closed. + +The cutover migration must be applied before setting the environment variable. Before cutover, the current leaderboard remains V1; after cutover, it ranks spendable mineral scores reconstructed as opening balance plus historical production minus upgrade costs, and reports the effective per-day rate after collection and upgrade events. + +### Cutover preparation + +After applying the migration chain, choose one exact future UTC-midnight timestamp and validate it against PostgreSQL time: + +```sh +pnpm minerals:prepare --dry-run --cutover-at +pnpm minerals:prepare --cutover-at +``` + +The preparation command is the only path that deliberately persists the immutable `MineralEconomyCutover` singleton. Ordinary reads and mutations fail closed on a configured/persisted timestamp conflict; do not update or delete the singleton manually. + +### Mineral account backfill + +After applying the mineral-economy migration and configuring `MINERAL_ECONOMY_CUTOVER_AT`, inspect the one-shot account backfill with: + +```sh +pnpm minerals:backfill:dry-run +``` + +The command reads READY Planets generated by the cutover, groups them by owner, and reports candidate/existing/missing account counts plus V1 opening-balance totals in integer micros. A dry run does not write accounts. The mutating run is: + +```sh +pnpm minerals:backfill +``` + +It refuses to run when the cutover variable is absent or when PostgreSQL `clock_timestamp()` is before the configured cutover. Account creation is create-only and idempotent: existing `MineralAccount` balances are never reset; concurrent duplicates are skipped. If the configured cutover conflicts with the persisted singleton, the command fails closed. Keep the transaction short and rerun after correcting configuration or clock/migration state. + +`pnpm api:leaderboard-worker` is the separate finalization command for completed UTC days; it writes archived `LeaderboardPeriod`/`LeaderboardEntry` rows when scheduled, but no HTTP history/finalization route is exposed. Ticket status uses `useJackpotState` for the live drawing countdown/phase and the Base mainnet Megapot Data API for wallet ticket/win history. The wallet ticket list follows opaque cursors to completion for My Planets and stops on API errors without showing a false empty collection. `Claim winnings` remains an on-chain `Jackpot.claimWinnings(uint256[])` call after simulation, capped at 50 IDs per batch; confirmed receipts invalidate the wallet ticket/win queries. ## Release gate ```sh +pnpm db:generate +pnpm db:validate pnpm lint pnpm typecheck pnpm test pnpm --filter @megaplanets/planet-generator golden pnpm build -pnpm db:generate -pnpm db:validate ``` +The PostgreSQL-specific CI job additionally runs `pnpm db:migrate:deploy` against a fresh PostgreSQL 16 service and then `pnpm test:postgres`. The local default test command skips that suite when `MINERAL_ECONOMY_TEST_DATABASE_URL` is not configured. + Live funded purchases, production database checks, and browser smoke require an appropriately configured environment. Local green tests verify the repository gate but do not by themselves prove external RPC or deployment availability. diff --git a/package.json b/package.json index 3fa819b..51d7103 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,11 @@ "build": "prisma generate && tsc -b && vite build && node scripts/prepare-site-worker.mjs", "api:server": "node --import tsx/esm server/api/serverMain.ts", "api:leaderboard-worker": "node --import tsx/esm server/api/leaderboardWorker.ts", + "db:migrate:deploy": "prisma migrate deploy", + "minerals:prepare": "node --import tsx/esm server/api/mineralCutoverPrepare.ts", + "minerals:backfill": "node --import tsx/esm server/api/mineralBackfill.ts", + "minerals:backfill:dry-run": "pnpm minerals:backfill --dry-run", + "test:postgres": "pnpm --filter @megaplanets/planet-generator build && MINERAL_ECONOMY_REQUIRE_POSTGRES=1 vitest run server/api/mineralConcurrency.postgres.test.ts", "preview": "vite preview", "typecheck": "tsc -b --noEmit && tsc -p tsconfig.vercel.json --noEmit", "typecheck:vercel": "tsc -p tsconfig.vercel.json --noEmit", diff --git a/packages/planet-generator/README.md b/packages/planet-generator/README.md index 1f239d2..949c311 100644 --- a/packages/planet-generator/README.md +++ b/packages/planet-generator/README.md @@ -63,14 +63,15 @@ Rarity is descriptive and never multiplies minerals or score. ## Rendering -The canonical NFT artifact is a real VP8/WebM animation (`video/webm`) rendered at -128×128 from the logical pixel scene. `renderPlanetWebM` uses the pinned -`webm-wasm@0.4.1` Apache-2.0 package, which bundles libvpx/libwebm/libyuv as WASM; -deployment does not require a system ffmpeg binary. The preset is three seconds and -the encoder enforces a five-second/two-megabyte upper bound. The output is deterministic -for a descriptor, so immutable media can be cached and reused when a voucher signature -expires. The old GIF renderer and fixtures remain available only as a legacy rollback -and visual-regression reference. +The active backend artifact is a deterministic animated GIF (`image/gif`) rendered at +128×128 from the logical pixel scene. `renderPlanetGif` is the renderer used by +`server/api/backendPlanet.ts`; GIF bytes and their hash are persisted in `BackendPlanet` +and served through `/api/planets/:planetId/gif`. + +The package also contains a bounded WebM renderer in `src/webm.ts`, covered by direct +generator tests. It is not exported by the package entrypoint and is not used by the +active backend API; treat it as experimental/future output. Its `webm-wasm@0.4.1` +path has a three-second preset and five-second/two-megabyte bounds. Animation speeds are loop-safe while retaining different planet, cloud, and satellite speeds. Clouds are a separate transparent pixel sphere, four logical pixels larger than the @@ -93,8 +94,8 @@ helpers normalize inputs and descriptor deserialization re-derives canonical dat trusting a supplied seed, traits, or hashes. Workers receive the full serialized input and derive the seed themselves; arbitrary caller-provided seeds are not accepted. -`derivePlanetPreviewForType` is reserved for the development-only Lab and must never -produce NFT metadata. Production builds omit the Lab route and navigation entry. +`derivePlanetPreviewForType` is a development/test helper and must never produce canonical +metadata. It is not part of backend Planet generation. Run `pnpm --filter @megaplanets/planet-generator golden` to verify fixtures. Intentional fixture replacement uses `golden:update` and requires coordinator review. diff --git a/prisma/migrations/20260820120000_minerals_economy_v2/migration.sql b/prisma/migrations/20260820120000_minerals_economy_v2/migration.sql new file mode 100644 index 0000000..978cd5a --- /dev/null +++ b/prisma/migrations/20260820120000_minerals_economy_v2/migration.sql @@ -0,0 +1,49 @@ +-- Minerals Economy v2 account state and Planet upgrade history. +ALTER TABLE "backend_planets" + ADD COLUMN "upgradeLevel" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "upgradeBonusBps" INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE "mineral_accounts" ( + "id" UUID NOT NULL, + "ownerAddress" VARCHAR(42) NOT NULL, + "openingBalanceMicros" BIGINT NOT NULL, + "balanceMicros" BIGINT NOT NULL, + "lastSettledAt" TIMESTAMPTZ(3) NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "mineral_accounts_pkey" PRIMARY KEY ("id"), + CONSTRAINT "mineral_accounts_ownerAddress_lower_check" CHECK ("ownerAddress" = lower("ownerAddress")), + CONSTRAINT "mineral_accounts_openingBalanceMicros_nonnegative_check" CHECK ("openingBalanceMicros" >= 0), + CONSTRAINT "mineral_accounts_balanceMicros_nonnegative_check" CHECK ("balanceMicros" >= 0) +); + +CREATE TABLE "planet_upgrade_purchases" ( + "id" UUID NOT NULL, + "planetId" UUID NOT NULL, + "walletAddress" VARCHAR(42) NOT NULL, + "targetLevel" INTEGER NOT NULL, + "bonusBpsAfter" INTEGER NOT NULL, + "costMicros" BIGINT NOT NULL, + "purchasedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "planet_upgrade_purchases_pkey" PRIMARY KEY ("id"), + CONSTRAINT "planet_upgrade_purchases_targetLevel_check" CHECK ("targetLevel" BETWEEN 1 AND 3), + CONSTRAINT "planet_upgrade_purchases_bonusBpsAfter_nonnegative_check" CHECK ("bonusBpsAfter" >= 0), + CONSTRAINT "planet_upgrade_purchases_costMicros_nonnegative_check" CHECK ("costMicros" >= 0) +); + +ALTER TABLE "backend_planets" + ADD CONSTRAINT "backend_planets_upgradeLevel_check" CHECK ("upgradeLevel" BETWEEN 0 AND 3), + ADD CONSTRAINT "backend_planets_upgradeBonusBps_nonnegative_check" CHECK ("upgradeBonusBps" >= 0); + +CREATE UNIQUE INDEX "mineral_accounts_ownerAddress_key" + ON "mineral_accounts"("ownerAddress"); +CREATE UNIQUE INDEX "planet_upgrade_purchases_planetId_targetLevel_key" + ON "planet_upgrade_purchases"("planetId", "targetLevel"); +CREATE INDEX "planet_upgrade_purchases_walletAddress_purchasedAt_idx" + ON "planet_upgrade_purchases"("walletAddress", "purchasedAt"); + +ALTER TABLE "planet_upgrade_purchases" + ADD CONSTRAINT "planet_upgrade_purchases_planetId_fkey" + FOREIGN KEY ("planetId") REFERENCES "backend_planets"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260820180000_mineral_economy_cutover/migration.sql b/prisma/migrations/20260820180000_mineral_economy_cutover/migration.sql new file mode 100644 index 0000000..ed2e359 --- /dev/null +++ b/prisma/migrations/20260820180000_mineral_economy_cutover/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "mineral_economy_cutover" ( + "id" INTEGER NOT NULL DEFAULT 1, + "cutoverAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "mineral_economy_cutover_pkey" PRIMARY KEY ("id"), + CONSTRAINT "mineral_economy_cutover_singleton_check" CHECK ("id" = 1), + CONSTRAINT "mineral_economy_cutover_midnight_check" + CHECK (("cutoverAt" AT TIME ZONE 'UTC')::time = TIME '00:00:00') +); + +CREATE OR REPLACE FUNCTION prevent_mineral_economy_cutover_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'mineral economy cutover is immutable'; +END; +$$; + +CREATE TRIGGER mineral_economy_cutover_immutable +BEFORE UPDATE OR DELETE ON "mineral_economy_cutover" +FOR EACH ROW EXECUTE FUNCTION prevent_mineral_economy_cutover_mutation(); diff --git a/prisma/migrations/20260821120000_mineral_integrity_triggers/migration.sql b/prisma/migrations/20260821120000_mineral_integrity_triggers/migration.sql new file mode 100644 index 0000000..c8463ef --- /dev/null +++ b/prisma/migrations/20260821120000_mineral_integrity_triggers/migration.sql @@ -0,0 +1,65 @@ +CREATE OR REPLACE FUNCTION prevent_planet_upgrade_purchase_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'planet upgrade purchase history is immutable'; +END; +$$; + +CREATE TRIGGER planet_upgrade_purchases_immutable +BEFORE UPDATE OR DELETE ON "planet_upgrade_purchases" +FOR EACH ROW EXECUTE FUNCTION prevent_planet_upgrade_purchase_mutation(); + +CREATE OR REPLACE FUNCTION prevent_mineral_account_identity_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW."ownerAddress" IS DISTINCT FROM OLD."ownerAddress" + OR NEW."openingBalanceMicros" IS DISTINCT FROM OLD."openingBalanceMicros" THEN + RAISE EXCEPTION 'mineral account identity is immutable'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER mineral_accounts_identity_immutable +BEFORE UPDATE ON "mineral_accounts" +FOR EACH ROW EXECUTE FUNCTION prevent_mineral_account_identity_mutation(); + +CREATE OR REPLACE FUNCTION prevent_ready_backend_planet_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + -- upgradeLevel, upgradeBonusBps, gifData, gifHash, generationError, and updatedAt remain mutable. + IF OLD."status" = 'READY' AND ( + NEW."id" IS DISTINCT FROM OLD."id" + OR NEW."ticketPurchaseId" IS DISTINCT FROM OLD."ticketPurchaseId" + OR NEW."chainId" IS DISTINCT FROM OLD."chainId" + OR NEW."ticketId" IS DISTINCT FROM OLD."ticketId" + OR NEW."ownerAddress" IS DISTINCT FROM OLD."ownerAddress" + OR NEW."seed" IS DISTINCT FROM OLD."seed" + OR NEW."traitsHash" IS DISTINCT FROM OLD."traitsHash" + OR NEW."generatorVersion" IS DISTINCT FROM OLD."generatorVersion" + OR NEW."planetName" IS DISTINCT FROM OLD."planetName" + OR NEW."planetType" IS DISTINCT FROM OLD."planetType" + OR NEW."terrain" IS DISTINCT FROM OLD."terrain" + OR NEW."rarity" IS DISTINCT FROM OLD."rarity" + OR NEW."satelliteCount" IS DISTINCT FROM OLD."satelliteCount" + OR NEW."hasRing" IS DISTINCT FROM OLD."hasRing" + OR NEW."baseMineralsPerDay" IS DISTINCT FROM OLD."baseMineralsPerDay" + OR NEW."generatedAt" IS DISTINCT FROM OLD."generatedAt" + OR NEW."status" IS DISTINCT FROM OLD."status" + OR NEW."createdAt" IS DISTINCT FROM OLD."createdAt" + ) THEN + RAISE EXCEPTION 'READY backend Planet identity/economic fields are immutable'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER backend_planets_ready_identity_immutable +BEFORE UPDATE ON "backend_planets" +FOR EACH ROW EXECUTE FUNCTION prevent_ready_backend_planet_mutation(); diff --git a/prisma/migrations/20260821160000_lock_trigger_search_paths/migration.sql b/prisma/migrations/20260821160000_lock_trigger_search_paths/migration.sql new file mode 100644 index 0000000..0d633c1 --- /dev/null +++ b/prisma/migrations/20260821160000_lock_trigger_search_paths/migration.sql @@ -0,0 +1,4 @@ +ALTER FUNCTION prevent_mineral_economy_cutover_mutation() SET search_path = ''; +ALTER FUNCTION prevent_planet_upgrade_purchase_mutation() SET search_path = ''; +ALTER FUNCTION prevent_mineral_account_identity_mutation() SET search_path = ''; +ALTER FUNCTION prevent_ready_backend_planet_mutation() SET search_path = ''; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 04436ab..2c18389 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -138,6 +138,8 @@ model BackendPlanet { satelliteCount Int hasRing Boolean baseMineralsPerDay BigInt + upgradeLevel Int @default(0) + upgradeBonusBps Int @default(0) generatedAt DateTime @db.Timestamptz(3) status BackendPlanetStatus @default(READY) gifData Bytes? @@ -146,12 +148,47 @@ model BackendPlanet { createdAt DateTime @default(now()) @db.Timestamptz(3) updatedAt DateTime @updatedAt @db.Timestamptz(3) ticketPurchase TicketPurchase @relation(fields: [ticketPurchaseId], references: [id], onDelete: Restrict) + upgradePurchases PlanetUpgradePurchase[] @@index([ownerAddress, generatedAt]) @@index([status, createdAt]) @@map("backend_planets") } +model MineralAccount { + id String @id @default(uuid()) @db.Uuid + ownerAddress String @unique @db.VarChar(42) + openingBalanceMicros BigInt + balanceMicros BigInt + lastSettledAt DateTime @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + @@map("mineral_accounts") +} + +model MineralEconomyCutover { + id Int @id @default(1) + cutoverAt DateTime @db.Timestamptz(3) + + @@map("mineral_economy_cutover") +} + +model PlanetUpgradePurchase { + id String @id @default(uuid()) @db.Uuid + planetId String @db.Uuid + walletAddress String @db.VarChar(42) + targetLevel Int + bonusBpsAfter Int + costMicros BigInt + purchasedAt DateTime @db.Timestamptz(3) + planet BackendPlanet @relation(fields: [planetId], references: [id], onDelete: Restrict) + + @@unique([planetId, targetLevel]) + @@index([walletAddress, purchasedAt]) + @@map("planet_upgrade_purchases") +} + model PlanetOwnershipHistory { id String @id @default(uuid()) @db.Uuid planetId String @db.Uuid diff --git a/server/api/backendConfig.test.ts b/server/api/backendConfig.test.ts new file mode 100644 index 0000000..2cc07ac --- /dev/null +++ b/server/api/backendConfig.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { loadBackendPlanetConfig, loadMineralEconomyConfig } from './backendConfig.js'; + +const baseEnv = { + BASE_RPC_URL: 'https://rpc.example.test', + DATABASE_URL: 'postgresql://example.test/db', +}; + +describe('Minerals Economy configuration', () => { + it('leaves v2 disabled when the optional cutover is absent', () => { + const config = loadBackendPlanetConfig(baseEnv); + expect(config.mineralEconomyCutoverAt).toBeNull(); + expect(config.mineralUpgradesEnabled).toBe(false); + }); + + it('parses a cutover and opt-in upgrade flag without changing Planet requirements', () => { + const config = loadMineralEconomyConfig({ + MINERAL_ECONOMY_CUTOVER_AT: '2026-08-20T00:00:00.000Z', + MINERAL_UPGRADES_ENABLED: 'true', + }); + expect(config.mineralEconomyCutoverAt?.toISOString()).toBe('2026-08-20T00:00:00.000Z'); + expect(config.mineralUpgradesEnabled).toBe(true); + }); + + it('rejects invalid optional values', () => { + expect(() => loadMineralEconomyConfig({ MINERAL_ECONOMY_CUTOVER_AT: 'not-a-date' })).toThrow( + 'MINERAL_ECONOMY_CUTOVER_AT must be a valid timestamp', + ); + expect(() => loadMineralEconomyConfig({ MINERAL_ECONOMY_CUTOVER_AT: '2026-08-20T00:00:00Z' })).toThrow( + 'MINERAL_ECONOMY_CUTOVER_AT must be an exact UTC midnight timestamp', + ); + expect(() => loadMineralEconomyConfig({ MINERAL_ECONOMY_CUTOVER_AT: '2026-02-30T00:00:00.000Z' })).toThrow( + 'MINERAL_ECONOMY_CUTOVER_AT must be a valid timestamp', + ); + expect(() => loadMineralEconomyConfig({ MINERAL_UPGRADES_ENABLED: 'sometimes' })).toThrow( + 'MINERAL_UPGRADES_ENABLED must be a boolean', + ); + }); +}); diff --git a/server/api/backendConfig.ts b/server/api/backendConfig.ts index eb8ec7d..de595f1 100644 --- a/server/api/backendConfig.ts +++ b/server/api/backendConfig.ts @@ -6,6 +6,14 @@ export type BackendPlanetConfig = { rpcFallbackUrls?: readonly string[]; databaseUrl: string; confirmations: bigint; + /** Optional in dependency overrides so pre-v2 Planet APIs remain source-compatible. */ + mineralEconomyCutoverAt?: Date | null; + mineralUpgradesEnabled?: boolean; +}; + +export type MineralEconomyConfig = { + mineralEconomyCutoverAt: Date | null; + mineralUpgradesEnabled: boolean; }; function required(env: Record, name: string): string { @@ -14,6 +22,39 @@ function required(env: Record, name: string): string return value; } +function parseMineralEconomyCutoverAt(env: Record): Date | null { + const raw = env.MINERAL_ECONOMY_CUTOVER_AT?.trim(); + if (!raw) return null; + const value = new Date(raw); + if (!Number.isFinite(value.getTime())) { + throw new Error('MINERAL_ECONOMY_CUTOVER_AT must be a valid timestamp.'); + } + if (!/^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/.test(raw)) { + throw new Error('MINERAL_ECONOMY_CUTOVER_AT must be an exact UTC midnight timestamp.'); + } + if (value.toISOString() !== raw) { + throw new Error('MINERAL_ECONOMY_CUTOVER_AT must be a valid timestamp.'); + } + return value; +} + +function parseMineralUpgradesEnabled(env: Record): boolean { + const raw = env.MINERAL_UPGRADES_ENABLED?.trim().toLowerCase(); + if (!raw) return false; + if (raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on') return true; + if (raw === 'false' || raw === '0' || raw === 'no' || raw === 'off') return false; + throw new Error('MINERAL_UPGRADES_ENABLED must be a boolean.'); +} + +export function loadMineralEconomyConfig( + env: Record = process.env, +): MineralEconomyConfig { + return { + mineralEconomyCutoverAt: parseMineralEconomyCutoverAt(env), + mineralUpgradesEnabled: parseMineralUpgradesEnabled(env), + }; +} + export function loadBackendPlanetConfig( env: Record = process.env, ): BackendPlanetConfig { @@ -28,12 +69,20 @@ export function loadBackendPlanetConfig( throw new Error('MEGAPLANETS_CONFIRMATIONS must be a non-negative integer.'); } } - if (confirmations < 0n) throw new Error('MEGAPLANETS_CONFIRMATIONS must be a non-negative integer.'); + if (confirmations < 0n) + throw new Error('MEGAPLANETS_CONFIRMATIONS must be a non-negative integer.'); const rpcFallbackUrls = (env.BASE_RPC_FALLBACK_URLS ?? '') .split(',') .map((value) => value.trim()) .filter(Boolean) .filter((value, index, values) => value !== rpcUrl && values.indexOf(value) === index) .slice(0, 3); - return { chainId: BASE_CHAIN_ID, rpcUrl, rpcFallbackUrls, databaseUrl, confirmations }; + return { + chainId: BASE_CHAIN_ID, + rpcUrl, + rpcFallbackUrls, + databaseUrl, + confirmations, + ...loadMineralEconomyConfig(env), + }; } diff --git a/server/api/backendPlanet.ts b/server/api/backendPlanet.ts index b168f4f..4938583 100644 --- a/server/api/backendPlanet.ts +++ b/server/api/backendPlanet.ts @@ -9,6 +9,16 @@ import { import type { PrismaClient } from './generated/prisma/client.js'; import { BASE_JACKPOT, normalizeMegasteraProof, type MegasteraProof } from './eligibility.js'; import { BASE_CHAIN_ID as CONFIGURED_CHAIN_ID, MEGASTERA_SOURCE } from './config.js'; +import { + acquireMineralEconomySharedBarrier, + acquireMineralWalletLock, + ensureAndLockMineralAccount, + getPostgresClockTimestamp, + resolveMineralEconomyForOperation, + settleMineralAccount, + type MineralSettlementPlanet, + type MineralSettlementPurchase, +} from './mineralAccounts.js'; export type BackendPlanetStatus = 'READY' | 'FAILED'; @@ -116,6 +126,87 @@ function isUniqueConstraintError(error: unknown): boolean { return Boolean(error && typeof error === 'object' && (error as { code?: unknown }).code === 'P2002'); } +function planetPersistenceData( + ticketId: string, + draft: BackendPlanetDraft, + generatedAt: Date, +) { + return { + ticketPurchaseId: ticketId, + chainId: draft.chainId, + ticketId: draft.ticketId, + ownerAddress: draft.ownerAddress.toLowerCase(), + seed: draft.seed.toLowerCase(), + traitsHash: draft.traitsHash.toLowerCase(), + generatorVersion: draft.generatorVersion, + planetName: draft.name, + planetType: draft.planetType, + terrain: draft.terrain, + rarity: draft.rarity, + satelliteCount: draft.satelliteCount, + hasRing: draft.hasRing, + baseMineralsPerDay: BigInt(draft.baseMineralsPerDay), + generatedAt, + status: 'READY' as const, + gifData: Buffer.from(draft.gifData), + gifHash: draft.gifHash.toLowerCase(), + generationError: null, + }; +} + +function planetMediaRepairData(draft: BackendPlanetDraft) { + return { + gifData: Buffer.from(draft.gifData), + gifHash: draft.gifHash.toLowerCase(), + generationError: null, + }; +} + +function assertReadyPlanetIntegrity( + row: PrismaBackendPlanetRow, + draft: BackendPlanetDraft, +): boolean { + if ( + !Number.isInteger(row.generatorVersion) || + row.generatorVersion !== GENERATOR_VERSION || + row.generatorVersion !== draft.generatorVersion + ) { + throw new Error('Backend Planet generator version is unsupported or mismatched.'); + } + if (row.gifData && row.gifHash) { + if (keccak256(new Uint8Array(row.gifData)) !== row.gifHash.toLowerCase()) { + throw new Error('Backend Planet GIF hash does not match GIF data.'); + } + return true; + } + if (row.gifData || row.gifHash) { + throw new Error('Backend Planet GIF hash does not match GIF data.'); + } + if ( + row.chainId !== draft.chainId || + row.ticketId.toFixed(0) !== draft.ticketId || + row.ownerAddress.toLowerCase() !== draft.ownerAddress.toLowerCase() || + row.seed.toLowerCase() !== draft.seed.toLowerCase() || + row.traitsHash.toLowerCase() !== draft.traitsHash.toLowerCase() || + row.planetName !== draft.name || + row.planetType !== draft.planetType || + row.terrain !== draft.terrain || + row.rarity !== draft.rarity || + row.satelliteCount !== draft.satelliteCount || + row.hasRing !== draft.hasRing || + row.baseMineralsPerDay !== BigInt(draft.baseMineralsPerDay) + ) { + throw new Error('Backend Planet identity or economic fields conflict with the current generator.'); + } + return false; +} + +class PreCutoverGeneration extends Error { + public constructor(public readonly effectiveAt: Date) { + super('Generation is before mineral economy cutover.'); + } +} + /** Derives deterministic traits and GIF bytes without accessing browser globals. */ export function deriveBackendPlanet( value: MegasteraProof, @@ -230,6 +321,7 @@ export class PrismaBackendPlanetStore implements BackendPlanetStore { public constructor( private readonly prisma: PrismaClient, private readonly now: () => Date = () => new Date(), + private readonly mineralEconomyCutoverAt: Date | null = null, ) {} async generatePlanet(proof: MegasteraProof): Promise { @@ -261,30 +353,41 @@ export class PrismaBackendPlanetStore implements BackendPlanetStore { where: { ticketPurchaseId: ticket.id }, include: { ticketPurchase: true }, }); - if (existing?.status === 'READY' && existing.gifData) return serializeRecord(existing as PrismaBackendPlanetRow); const draft = deriveBackendPlanet(normalized, this.now()); - const data = { - ticketPurchaseId: ticket.id, - chainId, - ticketId: draft.ticketId, - ownerAddress: draft.ownerAddress.toLowerCase(), - seed: draft.seed.toLowerCase(), - traitsHash: draft.traitsHash.toLowerCase(), - generatorVersion: draft.generatorVersion, - planetName: draft.name, - planetType: draft.planetType, - terrain: draft.terrain, - rarity: draft.rarity, - satelliteCount: draft.satelliteCount, - hasRing: draft.hasRing, - baseMineralsPerDay: BigInt(draft.baseMineralsPerDay), - generatedAt: new Date(draft.generatedAt), - status: 'READY' as const, - gifData: Buffer.from(draft.gifData), - gifHash: draft.gifHash.toLowerCase(), - generationError: null, - }; + if (existing?.status === 'READY' && assertReadyPlanetIntegrity(existing as PrismaBackendPlanetRow, draft)) { + return serializeRecord(existing as PrismaBackendPlanetRow); + } + const economy = await resolveMineralEconomyForOperation( + this.prisma, + this.mineralEconomyCutoverAt, + ); + if (economy.cutoverAt) { + return this.generatePostCutoverPlanet(ticket, draft, economy.cutoverAt); + } + const candidateAt = existing?.status === 'READY' ? existing.generatedAt : new Date(draft.generatedAt); + const data = existing?.status === 'READY' + ? planetMediaRepairData(draft) + : planetPersistenceData(ticket.id, draft, candidateAt); try { + if (existing && existing.status !== 'READY') { + const updated = await this.prisma.backendPlanet.updateMany({ + where: { id: existing.id, status: { not: 'READY' } }, + data, + }); + const row = await this.prisma.backendPlanet.findUnique({ + where: { id: existing.id }, + include: { ticketPurchase: true }, + }); + if (updated.count === 0) { + if (row?.status === 'READY' && assertReadyPlanetIntegrity(row as PrismaBackendPlanetRow, draft)) { + return serializeRecord(row as PrismaBackendPlanetRow); + } + throw new Error('Backend Planet generation lost a concurrent update.'); + } + if (!row) throw new Error('Backend Planet update lost its row.'); + if (row.status === 'READY') assertReadyPlanetIntegrity(row as PrismaBackendPlanetRow, draft); + return serializeRecord(row as PrismaBackendPlanetRow); + } const row = existing ? await this.prisma.backendPlanet.update({ where: { id: existing.id }, @@ -292,7 +395,7 @@ export class PrismaBackendPlanetStore implements BackendPlanetStore { include: { ticketPurchase: true }, }) : await this.prisma.backendPlanet.create({ - data, + data: data as never, include: { ticketPurchase: true }, }); return serializeRecord(row as PrismaBackendPlanetRow); @@ -302,7 +405,7 @@ export class PrismaBackendPlanetStore implements BackendPlanetStore { where: { ticketPurchaseId: ticket.id }, include: { ticketPurchase: true }, }); - if (concurrent?.status === 'READY' && concurrent.gifData) { + if (concurrent?.status === 'READY' && assertReadyPlanetIntegrity(concurrent as PrismaBackendPlanetRow, draft)) { return serializeRecord(concurrent as PrismaBackendPlanetRow); } } @@ -310,6 +413,174 @@ export class PrismaBackendPlanetStore implements BackendPlanetStore { } } + private async generatePostCutoverPlanet( + ticket: PersistedTicketRow & { id: string }, + draft: BackendPlanetDraft, + cutoverAt: Date, + ): Promise { + try { + const row = await this.prisma.$transaction(async (transaction) => { + await acquireMineralEconomySharedBarrier(transaction); + await acquireMineralWalletLock(transaction, draft.ownerAddress); + const boundaryAt = await getPostgresClockTimestamp(transaction); + const economy = await resolveMineralEconomyForOperation( + transaction, + cutoverAt, + boundaryAt, + ); + if (!economy.cutoverAt) throw new Error('Mineral economy cutover is missing.'); + const effectiveCutoverAt = economy.cutoverAt; + const current = await transaction.backendPlanet.findUnique({ + where: { ticketPurchaseId: ticket.id }, + include: { ticketPurchase: true }, + }); + if (current?.status === 'READY' && assertReadyPlanetIntegrity(current as PrismaBackendPlanetRow, draft)) { + return current as PrismaBackendPlanetRow; + } + if (boundaryAt < effectiveCutoverAt) { + const canPersist = current + ? typeof transaction.backendPlanet.update === 'function' + : typeof transaction.backendPlanet.create === 'function'; + if (!canPersist) throw new PreCutoverGeneration(boundaryAt); + const data = current?.status === 'READY' + ? planetMediaRepairData(draft) + : planetPersistenceData(ticket.id, draft, boundaryAt); + const persisted = current + ? await transaction.backendPlanet.update({ + where: { id: current.id }, + data, + include: { ticketPurchase: true }, + }) + : await transaction.backendPlanet.create({ + data: data as never, + include: { ticketPurchase: true }, + }); + return persisted as PrismaBackendPlanetRow; + } + const account = await ensureAndLockMineralAccount(transaction, draft.ownerAddress, effectiveCutoverAt); + const effectiveAt = await getPostgresClockTimestamp(transaction); + const currentAfterAccount = await transaction.backendPlanet.findUnique({ + where: { ticketPurchaseId: ticket.id }, + include: { ticketPurchase: true }, + }); + if ( + currentAfterAccount?.status === 'READY' && + assertReadyPlanetIntegrity(currentAfterAccount as PrismaBackendPlanetRow, draft) + ) + return currentAfterAccount as PrismaBackendPlanetRow; + if (currentAfterAccount?.status === 'READY') { + const repaired = await transaction.backendPlanet.update({ + where: { id: currentAfterAccount.id }, + data: planetMediaRepairData(draft), + include: { ticketPurchase: true }, + }); + return repaired as PrismaBackendPlanetRow; + } + const planets = (await transaction.backendPlanet.findMany({ + where: { ownerAddress: draft.ownerAddress.toLowerCase(), status: 'READY' }, + select: { + id: true, + ownerAddress: true, + planetType: true, + baseMineralsPerDay: true, + generatedAt: true, + upgradeLevel: true, + upgradeBonusBps: true, + }, + })) as MineralSettlementPlanet[]; + const purchases = (await transaction.planetUpgradePurchase.findMany({ + where: { walletAddress: draft.ownerAddress.toLowerCase(), purchasedAt: { lte: effectiveAt } }, + orderBy: [{ purchasedAt: 'asc' }, { id: 'asc' }], + select: { planetId: true, targetLevel: true, bonusBpsAfter: true, purchasedAt: true }, + })) as MineralSettlementPurchase[]; + await settleMineralAccount({ + prisma: transaction, + account, + planets, + purchases, + settledAt: effectiveAt, + anchor: effectiveCutoverAt, + }); + const generatedAt = effectiveAt; + const data = planetPersistenceData(ticket.id, draft, generatedAt); + const persisted = currentAfterAccount + ? await transaction.backendPlanet.update({ + where: { id: currentAfterAccount.id }, + data, + include: { ticketPurchase: true }, + }) + : await transaction.backendPlanet.create({ + data: data as never, + include: { ticketPurchase: true }, + }); + return persisted as PrismaBackendPlanetRow; + }); + return serializeRecord(row); + } catch (error) { + if (isUniqueConstraintError(error)) { + const concurrent = await this.prisma.backendPlanet.findUnique({ + where: { ticketPurchaseId: ticket.id }, + include: { ticketPurchase: true }, + }); + if (concurrent?.status === 'READY' && assertReadyPlanetIntegrity(concurrent as PrismaBackendPlanetRow, draft)) { + return serializeRecord(concurrent as PrismaBackendPlanetRow); + } + } + if (!(error instanceof PreCutoverGeneration)) throw error; + const current = await this.prisma.backendPlanet.findUnique({ + where: { ticketPurchaseId: ticket.id }, + include: { ticketPurchase: true }, + }); + if (current?.status === 'READY' && assertReadyPlanetIntegrity(current as PrismaBackendPlanetRow, draft)) { + return serializeRecord(current as PrismaBackendPlanetRow); + } + const generatedAt = current?.status === 'READY' ? current.generatedAt : error.effectiveAt; + const data = current?.status === 'READY' + ? planetMediaRepairData(draft) + : planetPersistenceData(ticket.id, draft, generatedAt); + try { + if (current) { + const updated = await this.prisma.backendPlanet.updateMany({ + where: { + id: current.id, + status: current.status === 'READY' ? 'READY' : { not: 'READY' }, + }, + data, + }); + const row = await this.prisma.backendPlanet.findUnique({ + where: { id: current.id }, + include: { ticketPurchase: true }, + }); + if (updated.count === 0) { + if (row?.status === 'READY' && assertReadyPlanetIntegrity(row as PrismaBackendPlanetRow, draft)) { + return serializeRecord(row as PrismaBackendPlanetRow); + } + throw new Error('Backend Planet fallback update lost its row.'); + } + if (!row) throw new Error('Backend Planet fallback update lost its row.'); + if (row.status === 'READY') assertReadyPlanetIntegrity(row as PrismaBackendPlanetRow, draft); + return serializeRecord(row as PrismaBackendPlanetRow); + } + const row = await this.prisma.backendPlanet.create({ + data: data as never, + include: { ticketPurchase: true }, + }); + return serializeRecord(row as PrismaBackendPlanetRow); + } catch (error) { + if (!current && isUniqueConstraintError(error)) { + const concurrent = await this.prisma.backendPlanet.findUnique({ + where: { ticketPurchaseId: ticket.id }, + include: { ticketPurchase: true }, + }); + if (concurrent?.status === 'READY' && assertReadyPlanetIntegrity(concurrent as PrismaBackendPlanetRow, draft)) { + return serializeRecord(concurrent as PrismaBackendPlanetRow); + } + } + throw error; + } + } + } + async listPlanets(ownerAddress: Address): Promise { const rows = await this.prisma.backendPlanet.findMany({ where: { ownerAddress: getAddress(ownerAddress).toLowerCase(), status: 'READY', gifData: { not: null } }, diff --git a/server/api/backendPlanetRoutes.test.ts b/server/api/backendPlanetRoutes.test.ts index cd36360..3dac6d1 100644 --- a/server/api/backendPlanetRoutes.test.ts +++ b/server/api/backendPlanetRoutes.test.ts @@ -22,18 +22,25 @@ const proof: MegasteraProof = { source: stringToHex(MEGASTERA_SOURCE, { size: 32 }), }; -function makeApp() { +const config = { chainId: BASE_CHAIN_ID, rpcUrl: 'https://rpc.example.test', databaseUrl: 'postgres://example', confirmations: 6n } as const; + +function makeAppDependencies() { const store = new MemoryBackendPlanetStore(); - const config = { chainId: BASE_CHAIN_ID, rpcUrl: 'https://rpc.example.test', databaseUrl: 'postgres://example', confirmations: 6n } as const; - const app = new Hono(); - app.route('/api', createBackendPlanetRoutes({ + return { loadConfig: () => config, findTicket: vi.fn(async () => proof), saveProof: vi.fn(async () => store.saveProof(proof)), getStore: () => store, allows: () => true, - })); - return { app, store }; + now: () => new Date('2026-08-21T00:00:00.000Z'), + }; +} + +function makeApp() { + const dependencies = makeAppDependencies(); + const app = new Hono(); + app.route('/api', createBackendPlanetRoutes(dependencies)); + return { app, store: dependencies.getStore() }; } describe('backend Planet routes', () => { @@ -75,4 +82,84 @@ describe('backend Planet routes', () => { expect(gif.headers.get('content-type')).toBe('image/gif'); expect((await gif.arrayBuffer()).byteLength).toBeGreaterThan(6); }); + + it('retires the per-Planet mining route', async () => { + const getPrisma = vi.fn(); + const app = new Hono(); + app.route('/api', createBackendPlanetRoutes({ + ...makeAppDependencies(), + getPrisma, + })); + + const response = await app.request('/api/planets/planet-1/mining'); + + expect(response.status).toBe(404); + expect(getPrisma).not.toHaveBeenCalled(); + }); + + it('validates upgrade payloads before touching the database', async () => { + const getPrisma = vi.fn(); + const app = new Hono(); + app.route('/api', createBackendPlanetRoutes({ + ...makeAppDependencies(), + getPrisma, + })); + + const response = await app.request('/api/planets/planet-1/upgrade', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ targetLevel: 0 }), + }); + + expect(response.status).toBe(400); + expect(getPrisma).not.toHaveBeenCalled(); + }); + + it('keeps upgrades disabled before the kill switch reaches the route', async () => { + const getPrisma = vi.fn(); + const app = new Hono(); + app.route('/api', createBackendPlanetRoutes({ + ...makeAppDependencies(), + getPrisma, + loadConfig: () => ({ + ...config, + mineralEconomyCutoverAt: new Date('2026-08-20T00:00:00.000Z'), + mineralUpgradesEnabled: false, + }), + now: () => new Date('2026-08-21T00:00:00.000Z'), + })); + + const response = await app.request('/api/planets/planet-1/upgrade', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ targetLevel: 1 }), + }); + + expect(response.status).toBe(404); + expect(getPrisma).not.toHaveBeenCalled(); + }); + + it('keeps the unauthenticated upgrade route server-disabled even when configured', async () => { + const getPrisma = vi.fn(); + const app = new Hono(); + app.route('/api', createBackendPlanetRoutes({ + ...makeAppDependencies(), + getPrisma, + loadConfig: () => ({ + ...config, + mineralEconomyCutoverAt: new Date('2026-08-22T00:00:00.000Z'), + mineralUpgradesEnabled: true, + }), + now: () => new Date('2026-08-21T00:00:00.000Z'), + })); + + const response = await app.request('/api/planets/planet-1/upgrade', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ targetLevel: 1 }), + }); + + expect(response.status).toBe(404); + expect(getPrisma).not.toHaveBeenCalled(); + }); }); diff --git a/server/api/backendPlanetRoutes.ts b/server/api/backendPlanetRoutes.ts index 19dc67e..4f64ff3 100644 --- a/server/api/backendPlanetRoutes.ts +++ b/server/api/backendPlanetRoutes.ts @@ -11,10 +11,11 @@ import { } from './backendPlanet.js'; import type { MegasteraProof } from './eligibility.js'; import { readBoundedJson } from './http.js'; -import { getBackendPlanetMiningSnapshot, getBackendWalletMiningSnapshot } from './miningStore.js'; +import { getBackendWalletMiningSnapshot } from './miningStore.js'; import { findTicketFromReceipt, parseReceiptReference, type ReceiptReference } from './receiptVerification.js'; import { saveMegasteraProof } from './prismaTicketPurchase.js'; import { reportBackendError } from './errorDiagnostics.js'; +import type { PrismaClient } from './generated/prisma/client.js'; export type BackendPlanetReference = ReceiptReference; @@ -25,6 +26,7 @@ export type BackendPlanetRouteDependencies = { getStore: (config: BackendPlanetConfig) => BackendPlanetStore; allows: (key: string) => boolean; now: () => Date; + getPrisma?: (config: BackendPlanetConfig) => PrismaClient; }; const MAX_BATCH = 50; @@ -51,21 +53,18 @@ function serializeCollectionRow(row: BackendPlanetCollectionRecord) { }; } -function serializeMining(mining: Awaited>) { - return mining - ? { - ...mining, - planetId: mining.planetId, - } - : undefined; -} - function defaultDependencies(): BackendPlanetRouteDependencies { return { loadConfig: () => loadBackendPlanetConfig(process.env), findTicket: (config, reference) => findTicketFromReceipt(config, reference), saveProof: (config, proof) => saveMegasteraProof(getPrismaClient(config.databaseUrl), proof), - getStore: (config) => new PrismaBackendPlanetStore(getPrismaClient(config.databaseUrl)), + getStore: (config) => + new PrismaBackendPlanetStore( + getPrismaClient(config.databaseUrl), + () => new Date(), + config.mineralEconomyCutoverAt ?? null, + ), + getPrisma: (config) => getPrismaClient(config.databaseUrl), allows: createRateLimiter(), now: () => new Date(), }; @@ -211,37 +210,19 @@ export function createBackendPlanetRoutes( } }); - app.get('/planets/:planetId/mining', async (c) => { - const rawPlanetId = c.req.param('planetId'); - if (isRetiredPlanetPath(rawPlanetId)) return c.json({ error: 'Planet not found.' }, 404); - const parsed = planetIdSchema.safeParse(rawPlanetId); - if (!parsed.success) return c.json({ error: 'A valid Planet ID is required.' }, 400); - try { - const config = dependencies.loadConfig(); - const mining = await getBackendPlanetMiningSnapshot( - getPrismaClient(config.databaseUrl), - parsed.data, - dependencies.now(), - ); - return mining ? c.json({ mining: serializeMining(mining) }) : c.json({ error: 'Mining data is not available for this Planet.' }, 404); - } catch (error) { - return c.json( - { error: 'The mining API is not configured.' }, - 503, - reportBackendError('GET /api/planets/:planetId/mining', error), - ); - } - }); - app.get('/wallets/:address/mining', async (c) => { const owner = c.req.param('address'); if (!isAddress(owner)) return c.json({ error: 'A valid wallet address is required.' }, 400); try { const config = dependencies.loadConfig(); const mining = await getBackendWalletMiningSnapshot( - getPrismaClient(config.databaseUrl), + (dependencies.getPrisma ?? ((value) => getPrismaClient(value.databaseUrl)))(config), getAddress(owner).toLowerCase(), dependencies.now(), + { + mineralEconomyCutoverAt: config.mineralEconomyCutoverAt, + mineralUpgradesEnabled: config.mineralUpgradesEnabled, + }, ); return c.json({ mining }); } catch (error) { @@ -253,6 +234,25 @@ export function createBackendPlanetRoutes( } }); + app.post('/planets/:planetId/upgrade', async (c) => { + const rawPlanetId = c.req.param('planetId'); + if (isRetiredPlanetPath(rawPlanetId)) return c.json({ error: 'Planet not found.' }, 404); + const parsedPlanetId = planetIdSchema.safeParse(rawPlanetId); + if (!parsedPlanetId.success) return c.json({ error: 'A valid Planet ID is required.' }, 400); + let body: unknown; + try { + body = await readBoundedJson(c.req.raw); + } catch { + return c.json({ error: 'Request body is invalid or too large.' }, 400); + } + const targetLevel = body && typeof body === 'object' ? (body as { targetLevel?: unknown }).targetLevel : undefined; + if (typeof targetLevel !== 'number' || !Number.isInteger(targetLevel) || targetLevel < 1 || targetLevel > 3) { + return c.json({ error: 'targetLevel must be an integer between 1 and 3.' }, 400); + } + // ponytail: public upgrades stay off until requests are authenticated to the Planet owner. + return c.json({ error: 'Planet upgrades are disabled.' }, 404); + }); + app.get('/planets/:planetId', async (c) => { const rawPlanetId = c.req.param('planetId'); if (isRetiredPlanetPath(rawPlanetId)) return c.json({ error: 'Planet not found.' }, 404); diff --git a/server/api/leaderboardRoutes.ts b/server/api/leaderboardRoutes.ts index 49d8037..119d21b 100644 --- a/server/api/leaderboardRoutes.ts +++ b/server/api/leaderboardRoutes.ts @@ -7,7 +7,7 @@ import { getCurrentLeaderboard, getWalletLeaderboardPosition, } from './leaderboardStore.js'; -import { loadBackendPlanetConfig } from './backendConfig.js'; +import { loadBackendPlanetConfig, loadMineralEconomyConfig } from './backendConfig.js'; import { reportBackendError } from './errorDiagnostics.js'; const paginationSchema = z.object({ @@ -25,8 +25,15 @@ type LeaderboardDependencies = { const defaultDependencies: LeaderboardDependencies = { getPrisma: () => getPrismaClient(loadBackendPlanetConfig(process.env).databaseUrl), now: () => new Date(), - getCurrent: getCurrentLeaderboard, - getWalletPosition: getWalletLeaderboardPosition, + getCurrent: (prisma, now, pagination) => + getCurrentLeaderboard(prisma, now, pagination, loadMineralEconomyConfig(process.env)), + getWalletPosition: (prisma, walletAddress, now) => + getWalletLeaderboardPosition( + prisma, + walletAddress, + now, + loadMineralEconomyConfig(process.env), + ), }; function parsePagination(query: (name: string) => string | undefined) { diff --git a/server/api/leaderboardStore.test.ts b/server/api/leaderboardStore.test.ts index b2e1dd1..7602091 100644 --- a/server/api/leaderboardStore.test.ts +++ b/server/api/leaderboardStore.test.ts @@ -1,10 +1,12 @@ -import { describe, expect, it } from 'vitest'; -import type { PrismaClient } from './generated/prisma/client.js'; +import { describe, expect, it, vi } from 'vitest'; +import { Prisma, type PrismaClient } from './generated/prisma/client.js'; import { + calculatePostCutoverLeaderboardRows, calculateLiveLeaderboardRows, calculateLeaderboardRows, - createLiveLeaderboardCache, + ensureOverdueLeaderboardPeriodsFinalized, finalizeLeaderboardPeriod, + getCurrentLeaderboard, paginateLeaderboardRows, } from './leaderboardStore.js'; @@ -15,6 +17,12 @@ const PERIOD = { startsAt: new Date('2026-08-10T00:00:00.000Z'), endsAt: new Date('2026-08-17T00:00:00.000Z'), }; +const CUTOVER = new Date('2026-08-20T00:00:00.000Z'); +const POST_CUTOVER_PERIOD = { + id: '2026-08-20', + startsAt: CUTOVER, + endsAt: new Date('2026-08-21T00:00:00.000Z'), +}; describe('calculateLeaderboardRows', () => { it('calculates lifetime production from immutable Planet traits and mint times', () => { @@ -162,23 +170,262 @@ describe('live leaderboard', () => { ]); }); - it('refreshes a cached live snapshot only after its TTL expires', async () => { - const cache = createLiveLeaderboardCache(60_000); - let loads = 0; - const load = async (asOf: Date) => { - loads += 1; - return { asOf, rows: [] }; + it('rechecks a staged cutover after entering the PostgreSQL transaction', async () => { + const cutoverAt = new Date('2026-08-20T00:00:00.000Z'); + const rootClock = vi.fn().mockResolvedValue([{ now: new Date(cutoverAt.getTime() - 1) }]); + const transactionClock = vi.fn().mockResolvedValue([{ now: cutoverAt }]); + const prisma = { + $queryRaw: rootClock, + mineralEconomyCutover: { findUnique: vi.fn().mockResolvedValue(null) }, + backendPlanet: { findMany: vi.fn().mockResolvedValue([]) }, + $transaction: vi.fn(async (callback: (client: unknown) => unknown) => callback({ + $queryRaw: transactionClock, + mineralEconomyCutover: { findUnique: vi.fn().mockResolvedValue(null) }, + })), + } as unknown as PrismaClient; + + await expect( + getCurrentLeaderboard(prisma, new Date(), { offset: 0, limit: 50 }, { + mineralEconomyCutoverAt: cutoverAt, + }), + ).rejects.toThrow('configured mineral economy cutover is not persisted'); + }); + + it('shows a spend immediately after the next leaderboard read', async () => { + const purchases: Array> = []; + const findMany = vi.fn().mockImplementation(async () => [ + { + id: 'planet-1', + ownerAddress: ADDRESS_A, + planetType: 'Gaia', + baseMineralsPerDay: 100n, + generatedAt: CUTOVER, + }, + ]); + const accounts = vi.fn().mockResolvedValue([ + { ownerAddress: ADDRESS_A, openingBalanceMicros: 100_000_000n }, + ]); + const purchaseRows = vi.fn().mockImplementation(async () => purchases); + const readTransactionOptions: unknown[] = []; + const transaction = { + backendPlanet: { findMany }, + mineralAccount: { findMany: accounts }, + planetUpgradePurchase: { findMany: purchaseRows }, }; - const firstNow = new Date('2026-08-20T00:00:00.000Z'); + const prisma = { + ...transaction, + $transaction: vi.fn(async ( + operation: (value: typeof transaction) => unknown, + options?: unknown, + ) => { + readTransactionOptions.push(options); + return operation(transaction); + }), + } as unknown as PrismaClient; + const now = new Date('2026-08-20T12:00:00.000Z'); + + const before = await getCurrentLeaderboard( + prisma, + now, + { offset: 0, limit: 50 }, + { mineralEconomyCutoverAt: CUTOVER }, + ); + purchases.push({ + planetId: 'planet-1', + walletAddress: ADDRESS_A, + targetLevel: 1, + bonusBpsAfter: 1_000, + costMicros: 200_000n, + purchasedAt: now, + }); + const after = await getCurrentLeaderboard( + prisma, + now, + { offset: 0, limit: 50 }, + { mineralEconomyCutoverAt: CUTOVER }, + ); + + expect(before.rows[0]?.scoreMicros).toBeGreaterThan(after.rows[0]?.scoreMicros ?? 0n); + expect(findMany).toHaveBeenCalledTimes(2); + expect(accounts).toHaveBeenCalledTimes(2); + expect(purchaseRows).toHaveBeenCalledTimes(2); + expect(readTransactionOptions[0]).toEqual({ + isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, + }); + }); +}); + +describe('post-cutover spendable leaderboard', () => { + it('rejects a reconstructed negative spendable balance', () => { + expect(() => calculatePostCutoverLeaderboardRows({ + period: POST_CUTOVER_PERIOD, + asOf: POST_CUTOVER_PERIOD.endsAt, + cutoverAt: CUTOVER, + accounts: [{ ownerAddress: ADDRESS_A, openingBalanceMicros: 0n }], + planets: [{ + id: 'planet-1', + ownerAddress: ADDRESS_A, + planetType: 'Gaia', + baseMineralsPerDay: 1n, + generatedAt: CUTOVER, + }], + purchases: [{ + planetId: 'planet-1', + walletAddress: ADDRESS_A, + targetLevel: 1, + bonusBpsAfter: 1_000, + costMicros: 2_000_001n, + purchasedAt: new Date('2026-08-20T00:00:00.001Z'), + }], + })).toThrow('negative'); + }); + + it('reconstructs balance from opening balance, canonical production, and purchase costs', () => { + const rows = calculatePostCutoverLeaderboardRows({ + period: POST_CUTOVER_PERIOD, + asOf: POST_CUTOVER_PERIOD.endsAt, + cutoverAt: CUTOVER, + accounts: [{ ownerAddress: ADDRESS_A, openingBalanceMicros: 100_000_000n }], + planets: [{ + id: 'planet-1', + ownerAddress: ADDRESS_A, + planetType: 'Gaia', + baseMineralsPerDay: 100n, + generatedAt: CUTOVER, + }], + purchases: [{ + planetId: 'planet-1', + walletAddress: ADDRESS_A, + targetLevel: 1, + bonusBpsAfter: 1_000, + costMicros: 200_000n, + purchasedAt: new Date('2026-08-20T12:00:00.000Z'), + }], + }); - const first = await cache.get(firstNow, load); - const withinTtl = await cache.get(new Date(firstNow.getTime() + 59_999), load); - const afterTtl = await cache.get(new Date(firstNow.getTime() + 60_000), load); + expect(rows).toEqual([{ + rank: 1, + walletAddress: ADDRESS_A, + scoreMicros: 204_800_000n, + effectiveMineralsPerDayMicros: 110_000_000n, + }]); + }); - expect(loads).toBe(2); - expect(withinTtl).toBe(first); - expect(afterTtl).not.toBe(first); - expect(afterTtl.asOf).toEqual(new Date('2026-08-20T00:01:00.000Z')); + it('keeps activation and purchase events at period end in the next period', () => { + const rows = calculatePostCutoverLeaderboardRows({ + period: POST_CUTOVER_PERIOD, + asOf: POST_CUTOVER_PERIOD.endsAt, + cutoverAt: CUTOVER, + accounts: [{ ownerAddress: ADDRESS_A, openingBalanceMicros: 0n }], + planets: [ + { + id: 'planet-1', + ownerAddress: ADDRESS_A, + planetType: 'Gaia', + baseMineralsPerDay: 100n, + generatedAt: CUTOVER, + }, + { + id: 'planet-2', + ownerAddress: ADDRESS_A, + planetType: 'Gaia', + baseMineralsPerDay: 100n, + generatedAt: POST_CUTOVER_PERIOD.endsAt, + }, + ], + purchases: [{ + planetId: 'planet-1', + walletAddress: ADDRESS_A, + targetLevel: 1, + bonusBpsAfter: 1_000, + costMicros: 200_000n, + purchasedAt: POST_CUTOVER_PERIOD.endsAt, + }], + }); + + expect(rows[0]?.scoreMicros).toBe(100_000_000n); + expect(rows[0]?.effectiveMineralsPerDayMicros).toBe(100_000_000n); + }); +}); + +describe('overdue leaderboard finalization', () => { + it('finalizes overdue post-cutover periods with spendable balances', async () => { + const finalized = new Map(); + const entries: Array> = []; + const periodFindUnique = vi.fn().mockImplementation(async ({ where }: { where: { id: string } }) => + finalized.has(where.id) ? { id: where.id, finalizedAt: finalized.get(where.id) } : undefined, + ); + const transaction = { + $queryRaw: vi.fn().mockResolvedValue([{ now: new Date('2026-08-22T00:00:00.000Z') }]), + leaderboardPeriod: { + findUnique: periodFindUnique, + upsert: vi.fn().mockImplementation(async ({ create }: { create: { id: string; finalizedAt: Date } }) => { + finalized.set(create.id, create.finalizedAt); + return create; + }), + }, + leaderboardEntry: { + createMany: vi.fn().mockImplementation(async ({ data }: { data: Array> }) => { + entries.push(...data); + return { count: data.length }; + }), + findMany: vi.fn().mockResolvedValue([]), + }, + backendPlanet: { + findFirst: vi.fn().mockResolvedValue({ generatedAt: CUTOVER }), + findMany: vi.fn().mockResolvedValue([{ + id: 'planet-1', + ownerAddress: ADDRESS_A, + planetType: 'Gaia', + baseMineralsPerDay: 100n, + generatedAt: CUTOVER, + }]), + }, + mineralEconomyCutover: { + findUnique: vi.fn().mockResolvedValue({ id: 1, cutoverAt: CUTOVER }), + }, + mineralAccount: { + findMany: vi.fn().mockResolvedValue([{ ownerAddress: ADDRESS_A, openingBalanceMicros: 0n }]), + }, + planetUpgradePurchase: { + findMany: vi.fn().mockResolvedValue([{ + planetId: 'planet-1', + walletAddress: ADDRESS_A, + targetLevel: 1, + bonusBpsAfter: 1_000, + costMicros: 200_000n, + purchasedAt: new Date('2026-08-20T12:00:00.000Z'), + }]), + }, + }; + const transactionOptions: unknown[] = []; + const prisma = { + leaderboardPeriod: { + findFirst: vi.fn().mockResolvedValue(null), + }, + backendPlanet: transaction.backendPlanet, + $transaction: vi.fn(async ( + operation: (value: typeof transaction) => unknown, + options?: unknown, + ) => { + transactionOptions.push(options); + return operation(transaction); + }), + } as unknown as PrismaClient; + + await ensureOverdueLeaderboardPeriodsFinalized( + prisma, + new Date('2026-08-22T00:00:00.000Z'), + { mineralEconomyCutoverAt: CUTOVER }, + ); + + expect(entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ periodId: '2026-08-20', scoreMicros: 104_800_000n }), + expect.objectContaining({ periodId: '2026-08-21', scoreMicros: 214_800_000n }), + ])); + expect(transactionOptions[0]).toEqual({ + isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, + }); }); }); @@ -202,10 +449,21 @@ describe('paginateLeaderboardRows', () => { describe('finalizeLeaderboardPeriod', () => { it('wraps the void advisory-lock result in a scalar for Prisma decoding', async () => { - let lockQuery = ''; + const lockQueries: string[] = []; + let rawCalls = 0; const transaction = { - $queryRaw: async (strings: TemplateStringsArray) => { - lockQuery = strings.join('?'); + $queryRaw: async (...args: unknown[]) => { + const query = args[0] as { values?: unknown[]; strings?: string[] } | TemplateStringsArray; + const rendered = Array.isArray(query) + ? query.join('?') + : query && 'strings' in query && Array.isArray(query.strings) + ? query.strings.join('?') + : String(query); + lockQueries.push(rendered); + rawCalls += 1; + return rawCalls % 2 === 0 + ? [{ now: new Date('2026-08-18T00:00:00.000Z') }] + : [{ locked: 1 }]; }, leaderboardPeriod: { findUnique: async () => undefined, @@ -224,8 +482,8 @@ describe('finalizeLeaderboardPeriod', () => { await finalizeLeaderboardPeriod(prisma, PERIOD, new Date('2026-08-17T00:00:01.000Z')); - expect(lockQuery).toContain('SELECT 1 AS locked'); - expect(lockQuery).toContain('pg_advisory_xact_lock'); + expect(lockQueries.join('\n')).toContain('SELECT 1 AS locked'); + expect(lockQueries.join('\n')).toContain('pg_advisory_xact_lock'); }); it('archives a period only once when finalization is retried', async () => { @@ -235,6 +493,9 @@ describe('finalizeLeaderboardPeriod', () => { const transaction = { $queryRaw: async () => { lockCalls += 1; + return lockCalls % 2 === 0 + ? [{ now: new Date('2026-08-18T00:00:00.000Z') }] + : [{ locked: 1 }]; }, leaderboardPeriod: { findUnique: async () => periodRecord, @@ -259,6 +520,6 @@ describe('finalizeLeaderboardPeriod', () => { await finalizeLeaderboardPeriod(prisma, PERIOD, new Date('2026-08-17T00:01:00.000Z')); expect(periodWrites).toBe(1); - expect(lockCalls).toBe(2); + expect(lockCalls).toBe(4); }); }); diff --git a/server/api/leaderboardStore.ts b/server/api/leaderboardStore.ts index 93447c9..d6e42db 100644 --- a/server/api/leaderboardStore.ts +++ b/server/api/leaderboardStore.ts @@ -1,5 +1,9 @@ -import type { PrismaClient } from './generated/prisma/client.js'; -import { calculateCollectionMining, type CollectionMiningPlanet } from './collectionMining.js'; +import { Prisma, type PrismaClient } from './generated/prisma/client.js'; +import { + calculateCollectionMining, + calculateEffectiveMineralsPerDayMicros, + type CollectionMiningPlanet, +} from './collectionMining.js'; import { getDistanceToNextRank, getLeaderboardPeriod, @@ -7,6 +11,19 @@ import { type RankedLeaderboardRow, rankLeaderboardRows, } from './leaderboard.js'; +import { + calculateHistoricalProduction, + upgradeBonusBpsAt, + type MineralCollectionPlanet, + type MineralUpgradePurchase, +} from './mineralEconomy.js'; +import { + acquireMineralEconomyExclusiveBarrier, + calculateCurrentMineralBalanceMicros, + calculateV1WalletOpeningBalance, + getPostgresClockTimestamp, + resolveMineralEconomyForOperation, +} from './mineralAccounts.js'; import { calculateLifetimeMinerals, MINERAL_SCALE } from './mining.js'; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; @@ -32,7 +49,26 @@ export type LiveLeaderboardSnapshot = { rows: RankedLeaderboardRow[]; }; -export const LIVE_LEADERBOARD_CACHE_TTL_MS = 60_000; +export type LeaderboardEconomyOptions = { + mineralEconomyCutoverAt?: Date | null; +}; + +type SpendableLeaderboardAccount = { + ownerAddress: string; + openingBalanceMicros: bigint; + balanceMicros?: bigint; + lastSettledAt?: Date; +}; + +type SpendableLeaderboardPlanet = MineralCollectionPlanet & { + baseMineralsPerDay: bigint; + generatedAt: Date; +}; + +type SpendableLeaderboardPurchase = MineralUpgradePurchase & { + walletAddress?: string; + costMicros: bigint; +}; type Pagination = { offset: number; limit: number }; @@ -158,32 +194,127 @@ export function calculateLiveLeaderboardRows(input: { ); } -type LiveCacheEntry = { expiresAt: number; value: T }; +function groupByOwner(rows: readonly T[], getOwnerAddress: (row: T) => string) { + const grouped = new Map(); + for (const row of rows) { + const ownerAddress = getOwnerAddress(row).toLowerCase(); + const ownerRows = grouped.get(ownerAddress) ?? []; + ownerRows.push(row); + grouped.set(ownerAddress, ownerRows); + } + return grouped; +} -/** Small async-safe TTL cache used to keep live standings affordable under refresh. */ -export function createLiveLeaderboardCache( - ttlMs = LIVE_LEADERBOARD_CACHE_TTL_MS, -) { - if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new RangeError('Leaderboard cache TTL must be positive.'); - let entry: LiveCacheEntry | undefined; - let pending: Promise | undefined; +/** Reconstructs post-cutover spendable balances at one half-open period boundary. */ +export function calculatePostCutoverLeaderboardRows(input: { + period?: LeaderboardPeriodBounds; + asOf: Date; + cutoverAt: Date; + includeEventsAtAsOf?: boolean; + accounts: readonly SpendableLeaderboardAccount[]; + planets: readonly SpendableLeaderboardPlanet[]; + purchases: readonly SpendableLeaderboardPurchase[]; + useCurrentBalance?: boolean; +}): RankedLeaderboardRow[] { + if (!Number.isFinite(input.cutoverAt.getTime())) + throw new Error('Mineral economy cutover timestamp is invalid.'); + const period = input.period ?? getLeaderboardPeriod(input.asOf); + const cutoff = new Date(Math.min(input.asOf.getTime(), period.endsAt.getTime())); + const includeEventsAtAsOf = input.includeEventsAtAsOf === true; + if (cutoff.getTime() < input.cutoverAt.getTime()) + throw new Error('Post-cutover leaderboard timestamp cannot be before cutover.'); - return { - async get(now: Date, loader: (asOf: Date) => Promise | T): Promise { - if (entry && now.getTime() < entry.expiresAt) return entry.value; - if (pending) return pending; - pending = Promise.resolve(loader(now)).then((value) => { - entry = { expiresAt: now.getTime() + ttlMs, value }; - return value; - }).finally(() => { - pending = undefined; - }); - return pending; - }, - clear() { - entry = undefined; - }, - }; + const activePlanets = input.planets.filter( + (planet) => + planet.baseMineralsPerDay > 0n && + planet.ownerAddress.toLowerCase() !== ZERO_ADDRESS && + (planet.generatedAt.getTime() < cutoff.getTime() || + (includeEventsAtAsOf && planet.generatedAt.getTime() === cutoff.getTime())), + ); + const planetsByOwner = groupByOwner(activePlanets, (planet) => planet.ownerAddress); + const ownerByPlanetId = new Map( + input.planets.map((planet) => [planet.id, planet.ownerAddress]), + ); + const purchasesByOwner = groupByOwner( + input.purchases.filter( + (purchase) => + purchase.purchasedAt.getTime() < cutoff.getTime() || + (includeEventsAtAsOf && purchase.purchasedAt.getTime() === cutoff.getTime()), + ), + (purchase) => purchase.walletAddress ?? ownerByPlanetId.get(purchase.planetId) ?? ZERO_ADDRESS, + ); + const accountsByOwner = new Map( + input.accounts.map((account) => [account.ownerAddress.toLowerCase(), account]), + ); + const collectionResults = calculateCollectionMining({ planets: activePlanets, asOf: cutoff }); + const scoreByWallet = new Map(); + const rateByWallet = new Map(); + const owners = new Set([ + ...accountsByOwner.keys(), + ...planetsByOwner.keys(), + ...purchasesByOwner.keys(), + ]); + + for (const ownerAddress of owners) { + const planets = planetsByOwner.get(ownerAddress) ?? []; + const purchases = purchasesByOwner.get(ownerAddress) ?? []; + const account = accountsByOwner.get(ownerAddress); + const openingPlanets = input.planets.filter( + (planet) => + planet.ownerAddress.toLowerCase() === ownerAddress && + planet.generatedAt.getTime() <= input.cutoverAt.getTime(), + ); + const openingBalanceMicros = account?.openingBalanceMicros ?? calculateV1WalletOpeningBalance( + openingPlanets, + input.cutoverAt, + ); + const producedMicros = calculateHistoricalProduction({ + planets, + purchases, + from: input.cutoverAt, + to: cutoff, + anchor: input.cutoverAt, + }); + const purchaseCostsMicros = purchases.reduce((total, purchase) => total + purchase.costMicros, 0n); + const currentAccount = account && account.balanceMicros !== undefined && account.lastSettledAt instanceof Date + ? { balanceMicros: account.balanceMicros, lastSettledAt: account.lastSettledAt } + : null; + const scoreMicros = input.useCurrentBalance && (currentAccount || !account) + ? calculateCurrentMineralBalanceMicros({ + account: currentAccount, + openingBalanceMicros, + cutoverAt: input.cutoverAt, + asOf: cutoff, + planets, + purchases, + }) + : openingBalanceMicros + producedMicros - purchaseCostsMicros; + if (scoreMicros < 0n) { + throw new Error(`Reconstructed leaderboard balance is negative for ${ownerAddress}.`); + } + if (scoreMicros > 0n) addToMap(scoreByWallet, ownerAddress, scoreMicros); + + for (const planet of planets) { + const collection = collectionResults.get(planet.id); + const upgradeBonusBps = upgradeBonusBpsAt(purchases, planet.id, cutoff.getTime()); + addToMap( + rateByWallet, + ownerAddress, + calculateEffectiveMineralsPerDayMicros( + planet.baseMineralsPerDay, + (collection?.collectionBonusBps ?? 0) + upgradeBonusBps, + ), + ); + } + } + + return rankLeaderboardRows( + [...scoreByWallet].map(([walletAddress, scoreMicros]) => ({ + walletAddress, + scoreMicros, + effectiveMineralsPerDayMicros: rateByWallet.get(walletAddress) ?? 0n, + })), + ); } export function paginateLeaderboardRows( @@ -202,10 +333,11 @@ export async function getCurrentLeaderboard( prisma: PrismaClient, now: Date, pagination: Pagination, + options: LeaderboardEconomyOptions = {}, ) { - const snapshot = await getLiveLeaderboardSnapshot(prisma, now); + const snapshot = await getLiveLeaderboardSnapshot(prisma, now, options); return { - period: getLeaderboardPeriod(now), + period: getLeaderboardPeriod(snapshot.asOf), asOf: snapshot.asOf, ...paginateLeaderboardRows(snapshot.rows, pagination), }; @@ -215,63 +347,207 @@ export async function getWalletLeaderboardPosition( prisma: PrismaClient, walletAddress: string, now: Date, + options: LeaderboardEconomyOptions = {}, ) { - const snapshot = await getLiveLeaderboardSnapshot(prisma, now); + const snapshot = await getLiveLeaderboardSnapshot(prisma, now, options); const normalizedAddress = walletAddress.toLowerCase(); const row = snapshot.rows.find((entry) => entry.walletAddress === normalizedAddress); return { - period: getLeaderboardPeriod(now), + period: getLeaderboardPeriod(snapshot.asOf), asOf: snapshot.asOf, row, distanceToNextRankMicros: row ? getDistanceToNextRank(snapshot.rows, normalizedAddress) : null, }; } -const liveLeaderboardCaches = new WeakMap>>(); - -function getLiveLeaderboardCache(prisma: PrismaClient) { - const key = prisma as unknown as object; - const current = liveLeaderboardCaches.get(key); - if (current) return current; - const created = createLiveLeaderboardCache(); - liveLeaderboardCaches.set(key, created); - return created; -} - async function getLiveLeaderboardSnapshot( prisma: PrismaClient, now: Date, + options: LeaderboardEconomyOptions, ): Promise { - return getLiveLeaderboardCache(prisma).get(now, async (asOf) => { + const resolution = await resolveMineralEconomyForOperation( + prisma, + options.mineralEconomyCutoverAt, + ); + if (resolution.state === 'V1') { const planets = await prisma.backendPlanet.findMany({ where: { status: 'READY', baseMineralsPerDay: { gt: 0n } }, select: { id: true, ownerAddress: true, planetType: true, baseMineralsPerDay: true, generatedAt: true }, }); - return { - asOf, - rows: calculateLiveLeaderboardRows({ asOf, planets }), - }; + return { asOf: now, rows: calculateLiveLeaderboardRows({ asOf: now, planets }) }; + } + + return prisma.$transaction( + async (transaction) => { + const hasPostgresClock = typeof transaction.$queryRaw === 'function'; + const asOf = hasPostgresClock + ? await getPostgresClockTimestamp(transaction) + : now; + const transactionResolution = await resolveMineralEconomyForOperation( + transaction, + options.mineralEconomyCutoverAt, + asOf, + ); + // Unit-only Prisma doubles lack PostgreSQL's clock; production clients require a persisted row. + const cutoverAt = transactionResolution.state === 'V2' || !hasPostgresClock || !transaction.mineralEconomyCutover + ? transactionResolution.cutoverAt + : null; + const planets = await transaction.backendPlanet.findMany({ + where: { status: 'READY', baseMineralsPerDay: { gt: 0n }, generatedAt: { lte: asOf } }, + select: { id: true, ownerAddress: true, planetType: true, baseMineralsPerDay: true, generatedAt: true }, + }); + if (!cutoverAt || asOf < cutoverAt) { + return { asOf, rows: calculateLiveLeaderboardRows({ asOf, planets }) }; + } + const [accounts, purchases] = await Promise.all([ + transaction.mineralAccount.findMany({ + select: { + ownerAddress: true, + openingBalanceMicros: true, + balanceMicros: true, + lastSettledAt: true, + }, + }), + transaction.planetUpgradePurchase.findMany({ + where: { purchasedAt: { lte: asOf } }, + orderBy: [{ purchasedAt: 'asc' }, { id: 'asc' }], + select: { + planetId: true, + walletAddress: true, + targetLevel: true, + bonusBpsAfter: true, + costMicros: true, + purchasedAt: true, + }, + }), + ]); + return { + asOf, + rows: calculatePostCutoverLeaderboardRows({ + asOf, + cutoverAt, + includeEventsAtAsOf: true, + accounts, + planets, + purchases, + useCurrentBalance: true, + }), + }; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); +} + +async function lockLeaderboardFinalization(transaction: Prisma.TransactionClient): Promise { + if (typeof transaction.$queryRaw !== 'function') return; + await acquireMineralEconomyExclusiveBarrier(transaction); +} + +type FinalizationRead = { + postCutover: boolean; + planets: unknown[]; + accounts?: unknown[]; + purchases?: unknown[]; +}; + +async function readFinalizationData( + transaction: Prisma.TransactionClient, + period: LeaderboardPeriodBounds, + postCutover: boolean, +): Promise { + const backendPlanet = (transaction as typeof transaction & { + backendPlanet?: { findMany: (args: unknown) => Promise }; + }).backendPlanet; + const planets = backendPlanet + ? await backendPlanet.findMany({ + where: { status: 'READY', baseMineralsPerDay: { gt: 0n }, generatedAt: { lt: period.endsAt } }, + select: { id: true, ownerAddress: true, planetType: true, baseMineralsPerDay: true, generatedAt: true }, + }) + : await transaction.planet.findMany({ + where: { + ownerAddress: { not: ZERO_ADDRESS }, + baseMineralsPerDay: { not: null }, + mintedAt: { lt: period.endsAt }, + }, + select: { id: true, ownerAddress: true, planetType: true, baseMineralsPerDay: true, mintedAt: true }, + }); + if (!postCutover) return { postCutover, planets }; + + const accountModel = (transaction as typeof transaction & { + mineralAccount?: { findMany: (args: unknown) => Promise }; + }).mineralAccount; + const purchaseModel = (transaction as typeof transaction & { + planetUpgradePurchase?: { findMany: (args: unknown) => Promise }; + }).planetUpgradePurchase; + if (!accountModel || !purchaseModel) + throw new Error('Post-cutover leaderboard models are unavailable.'); + const [accounts, purchases] = await Promise.all([ + accountModel.findMany({ select: { ownerAddress: true, openingBalanceMicros: true } }), + purchaseModel.findMany({ + where: { purchasedAt: { lt: period.endsAt } }, + orderBy: [{ purchasedAt: 'asc' }, { id: 'asc' }], + select: { + planetId: true, + walletAddress: true, + targetLevel: true, + bonusBpsAfter: true, + costMicros: true, + purchasedAt: true, + }, + }), + ]); + return { postCutover, planets, accounts, purchases }; +} + +function rowsForFinalization( + data: FinalizationRead, + period: LeaderboardPeriodBounds, + cutoverAt: Date | null | undefined, +): RankedLeaderboardRow[] { + if (data.postCutover && cutoverAt) { + return calculatePostCutoverLeaderboardRows({ + period, + asOf: period.endsAt, + cutoverAt, + planets: data.planets as SpendableLeaderboardPlanet[], + accounts: data.accounts as SpendableLeaderboardAccount[], + purchases: data.purchases as SpendableLeaderboardPurchase[], + }); + } + return calculateLeaderboardRows({ + period, + asOf: period.endsAt, + planets: data.planets.map((planet) => ({ + id: (planet as { id: string }).id, + ownerAddress: (planet as { ownerAddress: string }).ownerAddress, + baseMineralsPerDay: (planet as { baseMineralsPerDay: bigint | null }).baseMineralsPerDay, + planetType: (planet as { planetType?: string | null }).planetType, + mintedAt: + (planet as { generatedAt?: Date; mintedAt?: Date }).generatedAt ?? + (planet as { mintedAt: Date }).mintedAt, + })), }); } export async function finalizeLeaderboardPeriod( prisma: PrismaClient, period: LeaderboardPeriodBounds, - finalizedAt: Date, + _finalizedAt: Date, + options: LeaderboardEconomyOptions = {}, ) { return prisma.$transaction(async (transaction) => { - const transactionWithQueryRaw = transaction as typeof transaction & { - $queryRaw?: (strings: TemplateStringsArray, ...values: unknown[]) => Promise; - }; - if (transactionWithQueryRaw.$queryRaw) { - // pg_advisory_xact_lock returns PostgreSQL's `void` type. Selecting that - // value directly makes Prisma fail while decoding the raw query result; - // keep the transaction-scoped lock but expose only a scalar column. - await transactionWithQueryRaw.$queryRaw`SELECT 1 AS locked - FROM ( - SELECT pg_advisory_xact_lock(hashtextextended('megaplanets:leaderboard-finalization', 0)) AS acquired - ) AS lock_result`; + await lockLeaderboardFinalization(transaction); + const databaseNow = await getPostgresClockTimestamp(transaction); + if (databaseNow < period.endsAt) { + throw new Error('Leaderboard period has not ended in PostgreSQL.'); } + const resolution = await resolveMineralEconomyForOperation( + transaction, + options.mineralEconomyCutoverAt, + databaseNow, + ); + const cutoverAt = resolution.state === 'V2' ? resolution.cutoverAt : null; + const postCutover = !!cutoverAt && period.startsAt >= cutoverAt; const existing = await transaction.leaderboardPeriod.findUnique({ where: { id: period.id } }); if (existing?.finalizedAt) { return transaction.leaderboardEntry.findMany({ @@ -279,40 +555,12 @@ export async function finalizeLeaderboardPeriod( orderBy: { rank: 'asc' }, }); } - - const backendPlanet = (transaction as typeof transaction & { - backendPlanet?: { - findMany: (args: unknown) => Promise; - }; - }).backendPlanet; - const planets = backendPlanet - ? await backendPlanet.findMany({ - where: { status: 'READY', baseMineralsPerDay: { gt: 0n }, generatedAt: { lt: period.endsAt } }, - select: { id: true, ownerAddress: true, planetType: true, baseMineralsPerDay: true, generatedAt: true }, - }) - : await transaction.planet.findMany({ - where: { - ownerAddress: { not: ZERO_ADDRESS }, - baseMineralsPerDay: { not: null }, - mintedAt: { lt: period.endsAt }, - }, - select: { id: true, ownerAddress: true, planetType: true, baseMineralsPerDay: true, mintedAt: true }, - }); - const rows = calculateLeaderboardRows({ - period, - asOf: period.endsAt, - planets: planets.map((planet) => ({ - id: (planet as { id: string }).id, - ownerAddress: (planet as { ownerAddress: string }).ownerAddress, - baseMineralsPerDay: (planet as { baseMineralsPerDay: bigint | null }).baseMineralsPerDay, - planetType: (planet as { planetType?: string | null }).planetType, - mintedAt: (planet as { generatedAt?: Date; mintedAt?: Date }).generatedAt ?? (planet as { mintedAt: Date }).mintedAt, - })), - }); + const data = await readFinalizationData(transaction, period, postCutover); + const rows = rowsForFinalization(data, period, cutoverAt); await transaction.leaderboardPeriod.upsert({ where: { id: period.id }, - create: { id: period.id, startsAt: period.startsAt, endsAt: period.endsAt, finalizedAt }, - update: { finalizedAt }, + create: { id: period.id, startsAt: period.startsAt, endsAt: period.endsAt, finalizedAt: databaseNow }, + update: { finalizedAt: databaseNow }, }); if (rows.length > 0) { await transaction.leaderboardEntry.createMany({ @@ -330,14 +578,21 @@ export async function finalizeLeaderboardPeriod( where: { periodId: period.id }, orderBy: { rank: 'asc' }, }); - }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); } /** Finalizes every completed UTC day in chronological order. */ export async function ensureOverdueLeaderboardPeriodsFinalized( prisma: PrismaClient, now: Date, + options: LeaderboardEconomyOptions = {}, ): Promise { + const effectiveNow = typeof prisma.$queryRaw === 'function' + ? await getPostgresClockTimestamp(prisma) + : now; + const cutoverAt = options.mineralEconomyCutoverAt; + if (cutoverAt && !Number.isFinite(cutoverAt.getTime())) + throw new Error('Mineral economy cutover timestamp is invalid.'); const backendPlanet = (prisma as typeof prisma & { backendPlanet?: { findFirst: (args: unknown) => Promise<{ generatedAt: Date } | null>; @@ -345,7 +600,7 @@ export async function ensureOverdueLeaderboardPeriodsFinalized( }).backendPlanet; const [latest, earliestPlanet] = await Promise.all([ prisma.leaderboardPeriod.findFirst({ - where: { finalizedAt: { not: null }, endsAt: { lte: now } }, + where: { finalizedAt: { not: null }, endsAt: { lte: effectiveNow } }, orderBy: { endsAt: 'desc' }, select: { endsAt: true }, }), @@ -368,10 +623,10 @@ export async function ensureOverdueLeaderboardPeriodsFinalized( ? earliestPlanet.generatedAt : earliestPlanet.mintedAt : undefined; - let period = getLeaderboardPeriod(latest?.endsAt ?? earliestTime ?? now); + let period = getLeaderboardPeriod(latest?.endsAt ?? earliestTime ?? effectiveNow); let finalizedPeriods = 0; - while (period.endsAt <= now) { - await finalizeLeaderboardPeriod(prisma, period, now); + while (period.endsAt <= effectiveNow) { + await finalizeLeaderboardPeriod(prisma, period, effectiveNow, { mineralEconomyCutoverAt: cutoverAt }); period = getLeaderboardPeriod(period.endsAt); finalizedPeriods += 1; if (finalizedPeriods > 3_660) diff --git a/server/api/leaderboardWorker.ts b/server/api/leaderboardWorker.ts index aa744bf..cf906db 100644 --- a/server/api/leaderboardWorker.ts +++ b/server/api/leaderboardWorker.ts @@ -7,7 +7,9 @@ export async function runLeaderboardFinalization( env: Record = process.env, ): Promise { const config = loadBackendPlanetConfig(env); - await ensureOverdueLeaderboardPeriodsFinalized(getPrismaClient(config.databaseUrl), new Date()); + await ensureOverdueLeaderboardPeriodsFinalized(getPrismaClient(config.databaseUrl), new Date(), { + mineralEconomyCutoverAt: config.mineralEconomyCutoverAt, + }); } if (process.argv[1] && import.meta.url === new URL(process.argv[1], 'file:').href) { diff --git a/server/api/mineralAccounts.test.ts b/server/api/mineralAccounts.test.ts new file mode 100644 index 0000000..c983dec --- /dev/null +++ b/server/api/mineralAccounts.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PrismaClient } from './generated/prisma/client.js'; +import { + acquireMineralWalletLock, + calculateCurrentMineralBalanceMicros, + calculateV1WalletOpeningBalance, + initializeMineralAccounts, +} from './mineralAccounts.js'; + +const OWNER = '0x1111111111111111111111111111111111111111'; +const CUTOVER = new Date('2026-08-20T00:00:00.000Z'); + +describe('MineralAccount initialization', () => { + it('uses the persisted balance plus canonical post-cutover production', () => { + const planet = { + id: 'current-balance', + ownerAddress: OWNER, + planetType: 'Gaia', + baseMineralsPerDay: 1n, + generatedAt: CUTOVER, + }; + + expect(calculateCurrentMineralBalanceMicros({ + account: { + balanceMicros: 100n, + lastSettledAt: CUTOVER, + }, + openingBalanceMicros: 0n, + cutoverAt: CUTOVER, + asOf: new Date('2026-08-21T00:00:00.000Z'), + planets: [planet], + purchases: [], + })).toBe(1_000_100n); + }); + + it('rejects a missing account when spending history exists', () => { + expect(() => calculateCurrentMineralBalanceMicros({ + account: null, + openingBalanceMicros: 100n, + cutoverAt: CUTOVER, + asOf: new Date('2026-08-21T00:00:00.000Z'), + planets: [], + purchases: [{ + planetId: 'spent', + targetLevel: 1, + bonusBpsAfter: 1_000, + purchasedAt: new Date('2026-08-20T12:00:00.000Z'), + }], + })).toThrow('without a MineralAccount'); + }); + + it('takes a transaction-scoped advisory lock for one wallet', async () => { + const queryRaw = vi.fn().mockResolvedValue([{ locked: 1 }]); + + await acquireMineralWalletLock({ $queryRaw: queryRaw }, OWNER); + + const query = queryRaw.mock.calls[0]?.[0] as readonly string[]; + expect(query.join('')).toContain('pg_advisory_xact_lock'); + expect(query.join('')).toContain('mineral-wallet'); + }); + + it('keeps the V1 wallet opening balance bit-for-bit', () => { + expect( + calculateV1WalletOpeningBalance( + [ + { + id: 'one', + ownerAddress: OWNER, + planetType: 'Gaia', + baseMineralsPerDay: 100n, + generatedAt: new Date('2026-08-17T00:00:00.000Z'), + }, + { + id: 'two', + ownerAddress: OWNER, + planetType: 'Gaia', + baseMineralsPerDay: 100n, + generatedAt: new Date('2026-08-18T00:00:00.000Z'), + }, + { + id: 'three', + ownerAddress: OWNER, + planetType: 'Gaia', + baseMineralsPerDay: 100n, + generatedAt: new Date('2026-08-19T00:00:00.000Z'), + }, + ], + CUTOVER, + ), + ).toBe(615_000_000n); + }); + + it('batch-reads Planets and uses create-only idempotent account writes', async () => { + const findMany = vi.fn().mockResolvedValue([ + { + id: 'one', + ownerAddress: OWNER.toUpperCase(), + planetType: 'Gaia', + baseMineralsPerDay: 100n, + generatedAt: new Date('2026-08-19T00:00:00.000Z'), + }, + { + id: 'two', + ownerAddress: '0x2222222222222222222222222222222222222222', + planetType: 'Nebula', + baseMineralsPerDay: 50n, + generatedAt: new Date('2026-08-19T00:00:00.000Z'), + }, + ]); + const createMany = vi.fn().mockResolvedValue({ count: 2 }); + const prisma = { + backendPlanet: { findMany }, + mineralAccount: { createMany }, + } as unknown as PrismaClient; + + await expect(initializeMineralAccounts(prisma, CUTOVER)).resolves.toMatchObject({ + candidateCount: 2, + createdCount: 2, + }); + expect(findMany).toHaveBeenCalledOnce(); + expect(createMany).toHaveBeenCalledWith(expect.objectContaining({ skipDuplicates: true })); + expect(createMany.mock.calls[0]?.[0].data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ownerAddress: OWNER, + openingBalanceMicros: 100_000_000n, + balanceMicros: 100_000_000n, + lastSettledAt: CUTOVER, + }), + ]), + ); + }); + + it('does not write accounts while the optional cutover is absent', async () => { + const createMany = vi.fn(); + const prisma = { + backendPlanet: { findMany: vi.fn() }, + mineralAccount: { createMany }, + } as unknown as PrismaClient; + + await expect(initializeMineralAccounts(prisma, null)).resolves.toEqual({ + candidateCount: 0, + createdCount: 0, + }); + expect(createMany).not.toHaveBeenCalled(); + }); + + it('does not reset an existing account on a second initialization', async () => { + const findMany = vi + .fn() + .mockResolvedValueOnce([ + { + id: 'one', + ownerAddress: OWNER, + planetType: 'Gaia', + baseMineralsPerDay: 1n, + generatedAt: new Date('2026-08-19T00:00:00.000Z'), + }, + ]) + .mockResolvedValueOnce([ + { + id: 'one', + ownerAddress: OWNER, + planetType: 'Gaia', + baseMineralsPerDay: 100n, + generatedAt: new Date('2026-08-19T00:00:00.000Z'), + }, + ]); + const persisted = new Map>(); + const createMany = vi + .fn() + .mockImplementation(({ data }: { data: Record[] }) => { + let count = 0; + for (const row of data) { + const ownerAddress = String(row.ownerAddress); + if (persisted.has(ownerAddress)) continue; + persisted.set(ownerAddress, { ...row }); + count += 1; + } + return { count }; + }); + const prisma = { + backendPlanet: { findMany }, + mineralAccount: { createMany }, + } as unknown as PrismaClient; + + await initializeMineralAccounts(prisma, CUTOVER); + const second = await initializeMineralAccounts(prisma, CUTOVER); + + expect(second.createdCount).toBe(0); + expect(persisted.get(OWNER)).toMatchObject({ + openingBalanceMicros: 1_000_000n, + balanceMicros: 1_000_000n, + }); + }); +}); diff --git a/server/api/mineralAccounts.ts b/server/api/mineralAccounts.ts new file mode 100644 index 0000000..20af166 --- /dev/null +++ b/server/api/mineralAccounts.ts @@ -0,0 +1,660 @@ +import { type CollectionMiningPlanet, calculateCollectionMining } from './collectionMining.js'; +import { + calculateHistoricalProduction, + type MineralCollectionPlanet, + type MineralUpgradePurchase, +} from './mineralEconomy.js'; +import { calculateUpgradeCostMicros, getMineralUpgradeConfig } from './mineralUpgrades.js'; +import { Prisma, type PrismaClient } from './generated/prisma/client.js'; + +export type MineralAccountPlanet = CollectionMiningPlanet; + +type MineralAccountPlanetRow = { + id: string; + ownerAddress: string; + planetType: string; + baseMineralsPerDay: bigint; + generatedAt: Date; +}; + +export type MineralSettlementPlanet = MineralCollectionPlanet & { + upgradeLevel: number; + upgradeBonusBps: number; +}; + +export type MineralSettlementPurchase = MineralUpgradePurchase & { + targetLevel?: number; +}; + +function assertCutover(cutoverAt: Date): void { + if (!Number.isFinite(cutoverAt.getTime())) + throw new Error('Mineral economy cutover timestamp is invalid.'); +} + +type MineralEconomyCutoverRow = { id: number; cutoverAt: Date }; +type MineralEconomyCutoverStore = { + findUnique(args: { where: { id: number } }): Promise; + create?(args: { data: MineralEconomyCutoverRow }): Promise; + createMany?(args: { data: MineralEconomyCutoverRow[]; skipDuplicates?: boolean }): Promise<{ count: number }>; +}; +type MineralEconomyCutoverClient = { + mineralEconomyCutover?: MineralEconomyCutoverStore; +}; +type PostgresClockClient = Pick; + +export type MineralEconomyCutoverResolution = + | { state: 'V1'; cutoverAt: null; source: 'none' } + | { state: 'STAGED_V2'; cutoverAt: Date; source: 'config' } + | { state: 'V2'; cutoverAt: Date; source: 'database' | 'config+database' }; + +export async function getPostgresClockTimestamp(prisma: PostgresClockClient): Promise { + const rows = await prisma.$queryRaw>( + Prisma.sql`SELECT clock_timestamp()::timestamptz(3) AS "now"`, + ); + const value = rows[0]?.now; + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) { + throw new Error('PostgreSQL clock timestamp is invalid.'); + } + return value; +} + +export async function readMineralEconomyCutover( + prisma: MineralEconomyCutoverClient, +): Promise { + const model = prisma.mineralEconomyCutover; + if (!model) return null; + const row = await model.findUnique({ where: { id: 1 } }); + return row?.cutoverAt ?? null; +} + +/** Resolves rollout state without creating or updating any database row. */ +export async function resolveMineralEconomyCutover( + prisma: MineralEconomyCutoverClient, + configuredCutoverAt: Date | null | undefined, +): Promise { + if (configuredCutoverAt) assertCutover(configuredCutoverAt); + const persistedCutoverAt = await readMineralEconomyCutover(prisma); + if (persistedCutoverAt) assertCutover(persistedCutoverAt); + if (!persistedCutoverAt && !configuredCutoverAt) { + return { state: 'V1', cutoverAt: null, source: 'none' }; + } + if ( + persistedCutoverAt && + configuredCutoverAt && + persistedCutoverAt.getTime() !== configuredCutoverAt.getTime() + ) { + throw new Error('Configured mineral economy cutover conflicts with the persisted database cutover.'); + } + if (persistedCutoverAt) { + return { + state: 'V2', + cutoverAt: persistedCutoverAt, + source: configuredCutoverAt ? 'config+database' : 'database', + }; + } + return { state: 'STAGED_V2', cutoverAt: configuredCutoverAt as Date, source: 'config' }; +} + +type MineralEconomyOperationClient = MineralEconomyCutoverClient & + Partial; + +/** Resolves a caller's state and rejects an unpersisted cutover after PostgreSQL reaches T. */ +export async function resolveMineralEconomyForOperation( + prisma: MineralEconomyOperationClient, + configuredCutoverAt: Date | null | undefined, + databaseNow?: Date, +): Promise { + const resolution = await resolveMineralEconomyCutover(prisma, configuredCutoverAt); + if (resolution.state !== 'STAGED_V2') return resolution; + // Narrow unit-only Prisma doubles may omit the cutover model; production clients always expose it. + if (!prisma.mineralEconomyCutover) return resolution; + const now = databaseNow ?? ( + typeof prisma.$queryRaw === 'function' + ? await getPostgresClockTimestamp(prisma as PostgresClockClient) + : null + ); + if (now && now >= resolution.cutoverAt) { + throw new Error('configured mineral economy cutover is not persisted before PostgreSQL reached it.'); + } + return resolution; +} + +type EconomyBarrierClient = Pick; + +/** Shared transaction-scoped lock for generation and upgrade mutations. */ +export async function acquireMineralEconomySharedBarrier( + prisma: EconomyBarrierClient, +): Promise { + await prisma.$queryRaw>`SELECT 1 AS locked + FROM ( + SELECT pg_advisory_xact_lock_shared(hashtextextended('megaplanets:mineral-economy', 0)) AS acquired + ) AS lock_result`; +} + +/** Exclusive transaction-scoped lock for finalization, backfill, and preparation. */ +export async function acquireMineralEconomyExclusiveBarrier( + prisma: EconomyBarrierClient, +): Promise { + await prisma.$queryRaw>`SELECT 1 AS locked + FROM ( + SELECT pg_advisory_xact_lock(hashtextextended('megaplanets:mineral-economy', 0)) AS acquired + ) AS lock_result`; +} + +/** Serializes all economic mutations for one wallet after the shared barrier. */ +export async function acquireMineralWalletLock( + prisma: EconomyBarrierClient, + ownerAddress: string, +): Promise { + await prisma.$queryRaw>`SELECT 1 AS locked + FROM ( + SELECT pg_advisory_xact_lock( + hashtextextended('megaplanets:mineral-wallet:' || ${normalizeOwner(ownerAddress)}, 0) + ) AS acquired + ) AS lock_result`; +} + +export const lockMineralEconomyShared = acquireMineralEconomySharedBarrier; +export const lockMineralEconomyExclusive = acquireMineralEconomyExclusiveBarrier; + +/** Deliberately persists a future cutover; ordinary reads and mutations never do this. */ +export async function prepareMineralEconomyCutover( + prisma: PrismaClient, + cutoverAt: Date, +): Promise { + assertCutover(cutoverAt); + return prisma.$transaction(async (transaction) => { + await acquireMineralEconomyExclusiveBarrier(transaction); + const databaseNow = await getPostgresClockTimestamp(transaction); + if (cutoverAt <= databaseNow) { + throw new Error('Mineral economy cutover must be in the future of PostgreSQL time.'); + } + const model = (transaction as unknown as MineralEconomyCutoverClient).mineralEconomyCutover; + if (!model) throw new Error('Mineral economy cutover model is unavailable.'); + const existing = await model.findUnique({ where: { id: 1 } }); + if (existing && existing.cutoverAt.getTime() !== cutoverAt.getTime()) { + throw new Error('Configured mineral economy cutover conflicts with the persisted database cutover.'); + } + if (existing) return existing; + if (model.create) return model.create({ data: { id: 1, cutoverAt } }); + if (model.createMany) { + await model.createMany({ data: [{ id: 1, cutoverAt }], skipDuplicates: true }); + const persisted = await model.findUnique({ where: { id: 1 } }); + if (persisted) return persisted; + } + throw new Error('Mineral economy cutover could not be persisted.'); + }); +} + +/** Creates the immutable singleton on first use and rejects a configured conflict. */ +export async function ensureMineralEconomyCutover( + prisma: MineralEconomyCutoverClient, + cutoverAt: Date | null | undefined, +) { + if (!cutoverAt) return null; + assertCutover(cutoverAt); + const model = prisma.mineralEconomyCutover; + if (!model?.createMany) throw new Error('Mineral economy cutover model is unavailable.'); + await model.createMany({ + data: [{ id: 1, cutoverAt }], + skipDuplicates: true, + }); + const persisted = await model.findUnique({ where: { id: 1 } }); + if (!persisted) throw new Error('Mineral economy cutover could not be persisted.'); + if (persisted.cutoverAt.getTime() !== cutoverAt.getTime()) { + throw new Error('Configured mineral economy cutover conflicts with the persisted database cutover.'); + } + return persisted; +} + +/** + * Uses the existing V1 collection calculator unchanged for the cutover snapshot. + * This is intentionally separate from v2 temporal settlement math. + */ +export function calculateV1WalletOpeningBalance( + planets: readonly MineralAccountPlanet[], + cutoverAt: Date, +): bigint { + assertCutover(cutoverAt); + const results = calculateCollectionMining({ planets, asOf: cutoverAt }); + let total = 0n; + for (const result of results.values()) total += result.earnedMicros; + return total; +} + +export function calculateCurrentMineralBalanceMicros(input: { + account: { balanceMicros: bigint; lastSettledAt: Date } | null | undefined; + openingBalanceMicros: bigint; + cutoverAt: Date; + asOf: Date; + planets: readonly MineralCollectionPlanet[]; + purchases: readonly MineralUpgradePurchase[]; +}): bigint { + assertCutover(input.cutoverAt); + assertTimestamp(input.asOf, 'Current balance'); + if (input.asOf < input.cutoverAt) { + throw new Error('Current balance timestamp cannot be before mineral economy cutover.'); + } + if (!input.account && input.purchases.length > 0) { + throw new Error('Current balance cannot use spending history without a MineralAccount.'); + } + const from = input.account?.lastSettledAt ?? input.cutoverAt; + assertTimestamp(from, 'Account settlement'); + if (from < input.cutoverAt) { + throw new Error('Account settlement timestamp cannot be before mineral economy cutover.'); + } + const producedMicros = calculateHistoricalProduction({ + planets: input.planets, + purchases: input.purchases, + from, + to: input.asOf, + anchor: input.cutoverAt, + }); + const balanceMicros = (input.account?.balanceMicros ?? input.openingBalanceMicros) + producedMicros; + if (balanceMicros < 0n) throw new Error('Mineral balance cannot be negative.'); + return balanceMicros; +} + +export type MineralAccountInitializationResult = { + candidateCount: number; + createdCount: number; +}; + +/** Creates missing accounts from one bounded Planet read; existing rows are never updated. */ +export async function initializeMineralAccounts( + prisma: PrismaClient, + cutoverAt: Date | null | undefined, +): Promise { + if (!cutoverAt) return { candidateCount: 0, createdCount: 0 }; + assertCutover(cutoverAt); + await resolveMineralEconomyForOperation(prisma, cutoverAt); + const planets = (await prisma.backendPlanet.findMany({ + where: { status: 'READY', generatedAt: { lte: cutoverAt } }, + select: { + id: true, + ownerAddress: true, + planetType: true, + baseMineralsPerDay: true, + generatedAt: true, + }, + })) as MineralAccountPlanetRow[]; + const byOwner = new Map(); + for (const planet of planets) { + const ownerAddress = planet.ownerAddress.toLowerCase(); + const ownerPlanets = byOwner.get(ownerAddress) ?? []; + ownerPlanets.push({ ...planet, ownerAddress }); + byOwner.set(ownerAddress, ownerPlanets); + } + + const data = [...byOwner.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([ownerAddress, ownerPlanets]) => { + const openingBalanceMicros = calculateV1WalletOpeningBalance(ownerPlanets, cutoverAt); + return { + ownerAddress, + openingBalanceMicros, + balanceMicros: openingBalanceMicros, + lastSettledAt: cutoverAt, + }; + }); + if (data.length === 0) return { candidateCount: 0, createdCount: 0 }; + const created = await prisma.mineralAccount.createMany({ data, skipDuplicates: true }); + return { candidateCount: data.length, createdCount: created.count }; +} + +type MineralBackfillRow = { + ownerAddress: string; + openingBalanceMicros: bigint; +}; + +type MineralBackfillAccount = { + ownerAddress: string; + openingBalanceMicros: bigint; +}; + +export type MineralAccountsBackfillResult = { + cutoverAt: Date; + candidateCount: number; + existingCount: number; + missingCount: number; + openingBalanceMicros: bigint; + missingOpeningBalanceMicros: bigint; + createdCount: number; +}; + +async function loadMineralBackfillRows( + prisma: PrismaClient | Prisma.TransactionClient, + cutoverAt: Date, +): Promise<{ rows: MineralBackfillRow[]; existing: Map }> { + const planets = (await prisma.backendPlanet.findMany({ + where: { status: 'READY', generatedAt: { lte: cutoverAt } }, + select: { + id: true, + ownerAddress: true, + planetType: true, + baseMineralsPerDay: true, + generatedAt: true, + }, + })) as MineralAccountPlanetRow[]; + const byOwner = new Map(); + for (const planet of planets) { + const ownerAddress = planet.ownerAddress.toLowerCase(); + const ownerPlanets = byOwner.get(ownerAddress) ?? []; + ownerPlanets.push({ ...planet, ownerAddress }); + byOwner.set(ownerAddress, ownerPlanets); + } + const rows = [...byOwner.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([ownerAddress, ownerPlanets]) => ({ + ownerAddress, + openingBalanceMicros: calculateV1WalletOpeningBalance(ownerPlanets, cutoverAt), + })); + const accountModel = (prisma as PrismaClient & { + mineralAccount?: { + findMany: (args: unknown) => Promise; + }; + }).mineralAccount; + const existing = accountModel + ? await accountModel.findMany({ + select: { ownerAddress: true, openingBalanceMicros: true }, + }) + : []; + return { + rows, + existing: new Map( + existing.map((row) => [row.ownerAddress.toLowerCase(), row] as const), + ), + }; +} + +export async function runMineralAccountsBackfill( + prisma: PrismaClient, + cutoverAt: Date | null | undefined, + options: { dryRun?: boolean } = {}, +): Promise { + if (!cutoverAt) throw new Error('MINERAL_ECONOMY_CUTOVER_AT is required for mineral backfill.'); + assertCutover(cutoverAt); + if (options.dryRun) { + await resolveMineralEconomyForOperation(prisma, cutoverAt); + const { rows, existing } = await loadMineralBackfillRows(prisma, cutoverAt); + return buildMineralBackfillResult(cutoverAt, rows, existing); + } + + return prisma.$transaction(async (transaction) => { + await acquireMineralEconomyExclusiveBarrier(transaction); + const databaseNow = await getPostgresClockTimestamp(transaction); + if (databaseNow < cutoverAt) { + throw new Error('Mineral backfill cannot run before the configured cutover in PostgreSQL.'); + } + + const resolution = await resolveMineralEconomyForOperation(transaction, cutoverAt, databaseNow); + if (resolution.cutoverAt?.getTime() !== cutoverAt.getTime()) { + throw new Error('Configured mineral economy cutover conflicts with the persisted database cutover.'); + } + const { rows, existing } = await loadMineralBackfillRows(transaction, cutoverAt); + const expectedByOwner = new Map(rows.map((row) => [row.ownerAddress, row.openingBalanceMicros])); + for (const [ownerAddress, account] of existing) { + const expected = expectedByOwner.get(ownerAddress) ?? 0n; + if (account.openingBalanceMicros !== expected) { + throw new Error(`Mineral account ${ownerAddress} opening balance does not match V1.`); + } + } + const leaderboardPeriod = (transaction as Prisma.TransactionClient & { + leaderboardPeriod?: { findFirst: (args: unknown) => Promise<{ id: string } | null> }; + }).leaderboardPeriod; + const finalizedPostCutover = leaderboardPeriod + ? await leaderboardPeriod.findFirst({ + where: { startsAt: { gte: cutoverAt }, finalizedAt: { not: null } }, + select: { id: true }, + }) + : null; + if (finalizedPostCutover) { + throw new Error('Mineral backfill cannot run after a finalized post-cutover leaderboard period.'); + } + const result = buildMineralBackfillResult(cutoverAt, rows, existing); + const missing = rows.filter((row) => !existing.has(row.ownerAddress)); + const created = await transaction.mineralAccount.createMany({ + data: missing.map((row) => ({ + ownerAddress: row.ownerAddress, + openingBalanceMicros: row.openingBalanceMicros, + balanceMicros: row.openingBalanceMicros, + lastSettledAt: cutoverAt, + })), + skipDuplicates: true, + }); + return { ...result, createdCount: created.count }; + }); +} + +function buildMineralBackfillResult( + cutoverAt: Date, + rows: readonly MineralBackfillRow[], + existing: ReadonlyMap, +): MineralAccountsBackfillResult { + const missing = rows.filter((row) => !existing.has(row.ownerAddress)); + return { + cutoverAt, + candidateCount: rows.length, + existingCount: rows.length - missing.length, + missingCount: missing.length, + openingBalanceMicros: rows.reduce((total, row) => total + row.openingBalanceMicros, 0n), + missingOpeningBalanceMicros: missing.reduce( + (total, row) => total + row.openingBalanceMicros, + 0n, + ), + createdCount: 0, + }; +} + +export const backfillMineralAccounts = runMineralAccountsBackfill; + +function normalizeOwner(ownerAddress: string): string { + return ownerAddress.toLowerCase(); +} + +function assertTimestamp(value: Date, name: string): void { + if (!Number.isFinite(value.getTime())) throw new Error(`${name} timestamp is invalid.`); +} + +export async function settleMineralAccount(input: { + prisma: Prisma.TransactionClient; + account: { + ownerAddress: string; + balanceMicros: bigint; + lastSettledAt: Date; + }; + planets: readonly MineralSettlementPlanet[]; + purchases: readonly MineralSettlementPurchase[]; + settledAt: Date; + anchor: Date; +}) { + assertTimestamp(input.settledAt, 'Settlement'); + assertTimestamp(input.account.lastSettledAt, 'Account settlement'); + if (input.settledAt < input.account.lastSettledAt) { + throw new Error('Settlement timestamp cannot move backwards.'); + } + const producedMicros = calculateHistoricalProduction({ + planets: input.planets, + purchases: input.purchases, + from: input.account.lastSettledAt, + to: input.settledAt, + anchor: input.anchor, + }); + if (producedMicros === 0n && input.settledAt.getTime() === input.account.lastSettledAt.getTime()) { + return { ...input.account, balanceMicros: input.account.balanceMicros }; + } + const balanceMicros = input.account.balanceMicros + producedMicros; + if (balanceMicros < 0n) throw new Error('Mineral balance cannot be negative.'); + const updated = await input.prisma.mineralAccount.update({ + where: { ownerAddress: normalizeOwner(input.account.ownerAddress) }, + data: { balanceMicros, lastSettledAt: input.settledAt }, + }); + return updated; +} + +export async function ensureAndLockMineralAccount( + prisma: Prisma.TransactionClient, + ownerAddress: string, + cutoverAt: Date, +) { + const owner = normalizeOwner(ownerAddress); + const existing = await prisma.mineralAccount.findUnique({ where: { ownerAddress: owner } }); + let openingBalanceMicros = 0n; + if (!existing) { + const openingPlanets = (await prisma.backendPlanet.findMany({ + where: { ownerAddress: owner, status: 'READY', generatedAt: { lte: cutoverAt } }, + select: { + id: true, + ownerAddress: true, + planetType: true, + baseMineralsPerDay: true, + generatedAt: true, + }, + })) as MineralAccountPlanetRow[]; + openingBalanceMicros = calculateV1WalletOpeningBalance( + openingPlanets.map((planet) => ({ ...planet, ownerAddress: owner })), + cutoverAt, + ); + } + // PostgreSQL's upsert locks an existing account row and serializes concurrent creation. + return prisma.mineralAccount.upsert({ + where: { ownerAddress: owner }, + create: { + ownerAddress: owner, + openingBalanceMicros, + balanceMicros: openingBalanceMicros, + lastSettledAt: cutoverAt, + }, + update: { ownerAddress: owner }, + }); +} + +async function lockPlanet(prisma: Prisma.TransactionClient, planetId: string) { + if (typeof prisma.$queryRaw === 'function') { + const rows = await prisma.$queryRaw>( + Prisma.sql`SELECT "id" FROM "backend_planets" WHERE "id" = ${planetId} FOR UPDATE`, + ); + if (!rows[0]) return null; + } + return prisma.backendPlanet.findUnique({ where: { id: planetId } }); +} + +function serializePurchase(row: { + id: string; + planetId: string; + walletAddress: string; + targetLevel: number; + bonusBpsAfter: number; + costMicros: bigint; + purchasedAt: Date; +}) { + return { + purchaseId: row.id, + planetId: row.planetId, + ownerAddress: normalizeOwner(row.walletAddress), + targetLevel: row.targetLevel, + bonusBpsAfter: row.bonusBpsAfter, + costMicros: row.costMicros.toString(), + purchasedAt: row.purchasedAt.toISOString(), + }; +} + +function serializeUpgradeResult(purchase: Parameters[0]) { + return serializePurchase(purchase); +} + +export async function purchasePlanetUpgrade( + prisma: PrismaClient, + input: { + planetId: string; + targetLevel: number; + cutoverAt?: Date | null; + }, +) { + getMineralUpgradeConfig(input.targetLevel); + return prisma.$transaction(async (transaction) => { + await acquireMineralEconomySharedBarrier(transaction); + const ownerHint = await transaction.backendPlanet.findUnique({ + where: { id: input.planetId }, + select: { ownerAddress: true }, + }); + if (!ownerHint) throw new Error('Planet not found.'); + const ownerAddress = normalizeOwner(ownerHint.ownerAddress); + await acquireMineralWalletLock(transaction, ownerAddress); + const purchasedAt = await getPostgresClockTimestamp(transaction); + const resolution = await resolveMineralEconomyForOperation( + transaction, + input.cutoverAt, + purchasedAt, + ); + if (resolution.state === 'STAGED_V2') throw new Error('Mineral economy is not active yet.'); + const cutoverAt = resolution.cutoverAt; + if (!cutoverAt) throw new Error('Mineral economy is disabled.'); + const account = await ensureAndLockMineralAccount(transaction, ownerAddress, cutoverAt); + const planet = await lockPlanet(transaction, input.planetId); + if (!planet) throw new Error('Planet not found.'); + const existing = await transaction.planetUpgradePurchase.findUnique({ + where: { planetId_targetLevel: { planetId: input.planetId, targetLevel: input.targetLevel } }, + }); + if (existing) return serializeUpgradeResult(existing); + if (planet.ownerAddress.toLowerCase() !== ownerAddress) throw new Error('Planet owner changed.'); + if (planet.status !== 'READY') throw new Error('Planet is not ready for upgrades.'); + if (input.targetLevel !== planet.upgradeLevel + 1) { + throw new Error('Upgrade target must be the next Planet level.'); + } + assertTimestamp(purchasedAt, 'Upgrade purchase'); + if (purchasedAt < cutoverAt) throw new Error('Mineral economy is not active yet.'); + + const planets = (await transaction.backendPlanet.findMany({ + where: { ownerAddress, status: 'READY' }, + select: { + id: true, + ownerAddress: true, + planetType: true, + baseMineralsPerDay: true, + generatedAt: true, + upgradeLevel: true, + upgradeBonusBps: true, + }, + })) as MineralSettlementPlanet[]; + const purchases = (await transaction.planetUpgradePurchase.findMany({ + where: { walletAddress: ownerAddress, purchasedAt: { lte: purchasedAt } }, + orderBy: [{ purchasedAt: 'asc' }, { id: 'asc' }], + select: { planetId: true, targetLevel: true, bonusBpsAfter: true, purchasedAt: true }, + })) as MineralSettlementPurchase[]; + const settledAccount = await settleMineralAccount({ + prisma: transaction, + account, + planets, + purchases, + settledAt: purchasedAt, + anchor: cutoverAt, + }); + const costMicros = calculateUpgradeCostMicros({ + baseMineralsPerDay: planet.baseMineralsPerDay, + upgradeBonusBps: planet.upgradeBonusBps, + targetLevel: input.targetLevel, + }); + if (settledAccount.balanceMicros < costMicros) throw new Error('Insufficient mineral balance.'); + const balanceMicros = settledAccount.balanceMicros - costMicros; + await transaction.mineralAccount.update({ + where: { ownerAddress }, + data: { balanceMicros, lastSettledAt: purchasedAt }, + }); + await transaction.backendPlanet.update({ + where: { id: planet.id }, + data: { upgradeLevel: input.targetLevel, upgradeBonusBps: getMineralUpgradeConfig(input.targetLevel).bonusBpsAfter }, + }); + const purchase = await transaction.planetUpgradePurchase.create({ + data: { + planetId: planet.id, + walletAddress: ownerAddress, + targetLevel: input.targetLevel, + bonusBpsAfter: getMineralUpgradeConfig(input.targetLevel).bonusBpsAfter, + costMicros, + purchasedAt, + }, + }); + return serializeUpgradeResult(purchase); + }); +} diff --git a/server/api/mineralBackfill.test.ts b/server/api/mineralBackfill.test.ts new file mode 100644 index 0000000..4cf8cc3 --- /dev/null +++ b/server/api/mineralBackfill.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PrismaClient } from './generated/prisma/client.js'; +import { runMineralAccountsBackfill } from './mineralAccounts.js'; + +const OWNER = '0x1111111111111111111111111111111111111111'; +const CUTOVER = new Date('2026-08-20T00:00:00.000Z'); + +function makePrisma(dbNow: Date) { + const createMany = vi.fn().mockResolvedValue({ count: 1 }); + const transaction = { + $queryRaw: vi.fn().mockResolvedValue([{ now: dbNow }]), + backendPlanet: { + findMany: vi.fn().mockResolvedValue([ + { + id: 'planet-1', + ownerAddress: OWNER, + planetType: 'Gaia', + baseMineralsPerDay: 100n, + generatedAt: new Date('2026-08-19T00:00:00.000Z'), + }, + ]), + }, + mineralEconomyCutover: { + findUnique: vi.fn().mockResolvedValue({ id: 1, cutoverAt: CUTOVER }), + createMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + mineralAccount: { createMany, findMany: vi.fn().mockResolvedValue([]) }, + }; + const prisma = { + backendPlanet: { + findMany: vi.fn().mockResolvedValue([ + { + id: 'planet-1', + ownerAddress: OWNER, + planetType: 'Gaia', + baseMineralsPerDay: 100n, + generatedAt: new Date('2026-08-19T00:00:00.000Z'), + }, + ]), + }, + mineralAccount: { findMany: vi.fn().mockResolvedValue([]) }, + mineralEconomyCutover: { findUnique: vi.fn().mockResolvedValue(null) }, + $transaction: vi.fn(async (callback: (value: typeof transaction) => unknown) => + callback(transaction), + ), + } as unknown as PrismaClient; + return { prisma, createMany, transaction }; +} + +describe('Mineral account backfill', () => { + it('dry-runs without writes and reports the exact V1 opening total', async () => { + const { prisma, createMany } = makePrisma(new Date('2026-08-21T00:00:00.000Z')); + + await expect( + runMineralAccountsBackfill(prisma, CUTOVER, { dryRun: true }), + ).resolves.toMatchObject({ + candidateCount: 1, + missingCount: 1, + missingOpeningBalanceMicros: 100_000_000n, + createdCount: 0, + }); + expect(createMany).not.toHaveBeenCalled(); + }); + + it('refuses mutation while PostgreSQL time is before cutover', async () => { + const { prisma, createMany } = makePrisma(new Date('2026-08-19T23:59:59.999Z')); + + await expect(runMineralAccountsBackfill(prisma, CUTOVER)).rejects.toThrow( + 'Mineral backfill cannot run before the configured cutover', + ); + expect(createMany).not.toHaveBeenCalled(); + }); + + it('rejects an existing account whose opening balance does not match V1', async () => { + const { prisma, createMany, transaction } = makePrisma(new Date('2026-08-21T00:00:00.000Z')); + (transaction.mineralAccount.findMany as ReturnType).mockResolvedValue([ + { ownerAddress: OWNER, openingBalanceMicros: 1n }, + ]); + + await expect(runMineralAccountsBackfill(prisma, CUTOVER)).rejects.toThrow( + 'opening balance does not match', + ); + expect(createMany).not.toHaveBeenCalled(); + }); +}); diff --git a/server/api/mineralBackfill.ts b/server/api/mineralBackfill.ts new file mode 100644 index 0000000..24bdd61 --- /dev/null +++ b/server/api/mineralBackfill.ts @@ -0,0 +1,73 @@ +import { pathToFileURL } from 'node:url'; +import { loadMineralEconomyConfig } from './backendConfig.js'; +import { disconnectPrisma, getPrismaClient } from './database.js'; +import { + type MineralAccountsBackfillResult, + runMineralAccountsBackfill, +} from './mineralAccounts.js'; + +export type MineralBackfillCliOptions = { dryRun: boolean }; + +export function parseMineralBackfillArgs(args: readonly string[]): MineralBackfillCliOptions { + let dryRun = false; + for (const arg of args) { + if (arg === '--dry-run') { + dryRun = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + return { dryRun }; +} + +export function formatMineralBackfillResult( + result: MineralAccountsBackfillResult, + dryRun: boolean, +): string { + return [ + `Mineral account backfill ${dryRun ? 'dry run' : 'complete'}.`, + `Cutover: ${result.cutoverAt.toISOString()}`, + `Candidates: ${result.candidateCount}`, + `Existing accounts: ${result.existingCount}`, + `Missing accounts: ${result.missingCount}`, + `Opening balance (all): ${result.openingBalanceMicros.toString()} micros`, + `Missing opening balance: ${result.missingOpeningBalanceMicros.toString()} micros`, + `Created accounts: ${result.createdCount}`, + ].join('\n'); +} + +function requiredDatabaseUrl(env: Record): string { + const value = env.DATABASE_URL?.trim(); + if (!value) throw new Error('Missing required server environment variable DATABASE_URL.'); + return value; +} + +export async function runMineralBackfillCommand( + env: Record = process.env, + args: readonly string[] = process.argv.slice(2), +): Promise<{ result: MineralAccountsBackfillResult; dryRun: boolean }> { + const options = parseMineralBackfillArgs(args); + const { mineralEconomyCutoverAt } = loadMineralEconomyConfig(env); + if (!mineralEconomyCutoverAt) { + throw new Error('Mineral backfill is disabled: MINERAL_ECONOMY_CUTOVER_AT is not configured.'); + } + const prisma = getPrismaClient(requiredDatabaseUrl(env)); + try { + const result = await runMineralAccountsBackfill(prisma, mineralEconomyCutoverAt, options); + return { result, dryRun: options.dryRun }; + } finally { + await disconnectPrisma(); + } +} + +export async function main(): Promise { + const { result, dryRun } = await runMineralBackfillCommand(); + process.stdout.write(`${formatMineralBackfillResult(result, dryRun)}\n`); +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + main().catch(() => { + process.stderr.write('Mineral account backfill failed.\n'); + process.exitCode = 1; + }); +} diff --git a/server/api/mineralBackfillCommand.test.ts b/server/api/mineralBackfillCommand.test.ts new file mode 100644 index 0000000..e618bdd --- /dev/null +++ b/server/api/mineralBackfillCommand.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + formatMineralBackfillResult, + parseMineralBackfillArgs, + runMineralBackfillCommand, +} from './mineralBackfill.js'; + +describe('Mineral account backfill CLI', () => { + it('parses the optional dry-run flag and rejects unknown arguments', () => { + expect(parseMineralBackfillArgs([])).toEqual({ dryRun: false }); + expect(parseMineralBackfillArgs(['--dry-run'])).toEqual({ dryRun: true }); + expect(() => parseMineralBackfillArgs(['--force'])).toThrow('Unknown argument: --force'); + }); + + it('prints counts and bigint totals without exposing database configuration', () => { + const output = formatMineralBackfillResult( + { + cutoverAt: new Date('2026-08-20T00:00:00.000Z'), + candidateCount: 4, + existingCount: 1, + missingCount: 3, + openingBalanceMicros: 615_000_000n, + missingOpeningBalanceMicros: 500_000_000n, + createdCount: 3, + }, + false, + ); + + expect(output).toContain('Candidates: 4'); + expect(output).toContain('Existing accounts: 1'); + expect(output).toContain('Missing accounts: 3'); + expect(output).toContain('Opening balance (all): 615000000 micros'); + expect(output).toContain('Missing opening balance: 500000000 micros'); + expect(output).toContain('Created accounts: 3'); + expect(output).toContain('2026-08-20T00:00:00.000Z'); + }); + + it('refuses to run when the economy cutover is not configured', async () => { + await expect( + runMineralBackfillCommand({ DATABASE_URL: 'postgresql://example.test/db' }, []), + ).rejects.toThrow('Mineral backfill is disabled'); + }); +}); diff --git a/server/api/mineralConcurrency.postgres.test.ts b/server/api/mineralConcurrency.postgres.test.ts new file mode 100644 index 0000000..a8fa216 --- /dev/null +++ b/server/api/mineralConcurrency.postgres.test.ts @@ -0,0 +1,586 @@ +import { PrismaPg } from '@prisma/adapter-pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { PrismaBackendPlanetStore } from './backendPlanet.js'; +import { MEGASTERA_SOURCE } from './config.js'; +import { Prisma, PrismaClient } from './generated/prisma/client.js'; +import { finalizeLeaderboardPeriod } from './leaderboardStore.js'; +import { calculateHistoricalProduction } from './mineralEconomy.js'; +import { getBackendWalletMiningSnapshot } from './miningStore.js'; +import { + acquireMineralEconomyExclusiveBarrier, + acquireMineralEconomySharedBarrier, + acquireMineralWalletLock, + purchasePlanetUpgrade, + resolveMineralEconomyCutover, + runMineralAccountsBackfill, +} from './mineralAccounts.js'; + +const testDatabaseUrl = process.env.MINERAL_ECONOMY_TEST_DATABASE_URL?.trim(); +if (!testDatabaseUrl && process.env.MINERAL_ECONOMY_REQUIRE_POSTGRES === '1') { + throw new Error( + 'MINERAL_ECONOMY_TEST_DATABASE_URL is required for the PostgreSQL concurrency suite.', + ); +} +const describePostgres = testDatabaseUrl ? describe : describe.skip; +const OWNER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +let nextFixtureId = 99_001; + +function createClient() { + if (!testDatabaseUrl) + throw new Error('The explicit mineral economy test database is not configured.'); + return new PrismaClient({ + adapter: new PrismaPg({ connectionString: testDatabaseUrl, max: 1 }), + }); +} + +async function postgresNow(prisma: PrismaClient): Promise { + const rows = await prisma.$queryRaw>( + Prisma.sql`SELECT clock_timestamp()::timestamptz(3) AS "now"`, + ); + const value = rows[0]?.now; + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) + throw new Error('PostgreSQL time is invalid.'); + return value; +} + +function utcMidnightBefore(value: Date): Date { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate() - 1)); +} + +function utcMidnightAfter(value: Date): Date { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate() + 1)); +} + +function repeatedHex(value: number): `0x${string}` { + const byte = (value % 256).toString(16).padStart(2, '0'); + return `0x${byte.repeat(32)}` as `0x${string}`; +} + +async function createTicket(prisma: PrismaClient, ownerAddress: string, purchasedAt: Date) { + const fixtureId = nextFixtureId++; + const ticket = await prisma.ticketPurchase.create({ + data: { + chainId: 8453, + jackpotAddress: `0x${'11'.repeat(20)}`, + ticketId: fixtureId.toString(), + drawingId: fixtureId.toString(), + recipient: ownerAddress, + normals: [1, 2, 3, 4, 5], + bonusBall: 6, + source: MEGASTERA_SOURCE.padEnd(66, '0'), + originTxHash: repeatedHex(fixtureId), + blockNumber: BigInt(30_000_000 + fixtureId), + blockHash: repeatedHex(fixtureId + 1), + logIndex: fixtureId, + purchasedAt, + }, + }); + return { ticket, fixtureId }; +} + +async function createFixture( + prisma: PrismaClient, + ownerAddress: string, + generatedAt: Date, + baseMineralsPerDay = 1n, +) { + const { ticket, fixtureId } = await createTicket(prisma, ownerAddress, generatedAt); + const planet = await prisma.backendPlanet.create({ + data: { + ticketPurchaseId: ticket.id, + chainId: 8453, + ticketId: fixtureId.toString(), + ownerAddress, + seed: repeatedHex(fixtureId + 2), + traitsHash: repeatedHex(fixtureId + 3), + generatorVersion: 1, + planetName: `Concurrency Planet ${fixtureId}`, + planetType: 'Gaia', + terrain: 'Plains', + rarity: 'Common', + satelliteCount: 0, + hasRing: false, + baseMineralsPerDay, + generatedAt, + status: 'READY', + gifData: Buffer.from('gif'), + gifHash: repeatedHex(fixtureId + 4), + }, + }); + return { ticket, planet, fixtureId }; +} + +async function seedAccount(prisma: PrismaClient, ownerAddress: string, lastSettledAt: Date) { + return prisma.mineralAccount.create({ + data: { + ownerAddress, + openingBalanceMicros: 10_000_000n, + balanceMicros: 10_000_000n, + lastSettledAt, + }, + }); +} + +function proofForTicket( + ticket: Awaited>['ticket'], + ownerAddress: string, +) { + return { + chainId: 8453, + recipient: ownerAddress as `0x${string}`, + ticketId: BigInt(ticket.ticketId.toString()), + drawingId: BigInt(ticket.drawingId.toString()), + normals: ticket.normals, + bonusBall: ticket.bonusBall, + originTxHash: ticket.originTxHash as `0x${string}`, + blockNumber: ticket.blockNumber, + blockHash: ticket.blockHash as `0x${string}`, + logIndex: BigInt(ticket.logIndex), + purchasedAt: ticket.purchasedAt, + } as const; +} + +async function clearTestDatabase(prisma: PrismaClient): Promise { + await prisma.leaderboardEntry.deleteMany(); + await prisma.leaderboardPeriod.deleteMany(); + // Upgrade history is immutable in production; TRUNCATE keeps test cleanup read/write-safe without firing delete triggers. + await prisma.$executeRaw(Prisma.sql`TRUNCATE TABLE "planet_upgrade_purchases", "mineral_economy_cutover"`); + await prisma.mineralAccount.deleteMany(); + await prisma.backendPlanet.deleteMany(); + await prisma.ticketPurchase.deleteMany(); +} + +async function ensurePersistedCutover(prisma: PrismaClient, cutoverAt: Date): Promise { + const existing = await prisma.mineralEconomyCutover.findUnique({ where: { id: 1 } }); + if (!existing) { + await prisma.mineralEconomyCutover.create({ data: { id: 1, cutoverAt } }); + return cutoverAt; + } + return existing.cutoverAt; +} + +describePostgres('Mineral upgrade PostgreSQL concurrency', () => { + let first: PrismaClient; + let second: PrismaClient; + let blocker: PrismaClient; + + beforeAll(() => { + first = createClient(); + second = createClient(); + blocker = createClient(); + }); + + beforeEach(async () => { + await clearTestDatabase(first); + }); + + afterAll(async () => { + await Promise.all([first.$disconnect(), second.$disconnect(), blocker.$disconnect()]); + }); + + it('fails a configured but unpersisted cutover after PostgreSQL reaches T', async () => { + const databaseNow = await postgresNow(first); + const cutoverAt = utcMidnightBefore(databaseNow); + + await expect( + getBackendWalletMiningSnapshot(first, OWNER, databaseNow, { + mineralEconomyCutoverAt: cutoverAt, + }), + ).rejects.toThrow('configured mineral economy cutover is not persisted'); + }); + + it('enforces immutable history/account identity while allowing READY Planet upgrades and media repair', async () => { + const databaseNow = await postgresNow(first); + const cutoverAt = await ensurePersistedCutover(first, utcMidnightBefore(databaseNow)); + const fixture = await createFixture(first, OWNER, cutoverAt); + await seedAccount(first, OWNER, databaseNow); + const purchase = await first.planetUpgradePurchase.create({ + data: { + planetId: fixture.planet.id, + walletAddress: OWNER, + targetLevel: 1, + bonusBpsAfter: 1_000, + costMicros: 200_000n, + purchasedAt: databaseNow, + }, + }); + + await expect( + first.planetUpgradePurchase.update({ where: { id: purchase.id }, data: { costMicros: 1n } }), + ).rejects.toThrow('immutable'); + await expect(first.planetUpgradePurchase.delete({ where: { id: purchase.id } })).rejects.toThrow('immutable'); + await expect( + first.mineralAccount.update({ where: { ownerAddress: OWNER }, data: { openingBalanceMicros: 1n } }), + ).rejects.toThrow('immutable'); + await expect( + first.backendPlanet.update({ where: { id: fixture.planet.id }, data: { baseMineralsPerDay: 2n } }), + ).rejects.toThrow('immutable'); + await expect( + first.backendPlanet.update({ + where: { id: fixture.planet.id }, + data: { upgradeLevel: 1, upgradeBonusBps: 1_000, gifData: Buffer.from('repaired') }, + }), + ).resolves.toBeTruthy(); + }); + + it('persists a pre-cutover generation without creating a V2 account', async () => { + const databaseNow = await postgresNow(first); + const cutoverAt = utcMidnightAfter(databaseNow); + const ticket = await createTicket(first, OWNER, databaseNow); + + const generated = await new PrismaBackendPlanetStore( + first, + () => databaseNow, + cutoverAt, + ).generatePlanet(proofForTicket(ticket.ticket, OWNER)); + + expect(new Date(generated.generatedAt).getTime()).toBeLessThan(cutoverAt.getTime()); + expect(await first.backendPlanet.count({ where: { ticketPurchaseId: ticket.ticket.id } })).toBe( + 1, + ); + expect(await first.mineralAccount.findUnique({ where: { ownerAddress: OWNER } })).toBeNull(); + expect(await first.mineralEconomyCutover.findUnique({ where: { id: 1 } })).toBeNull(); + }); + + it('charges one concurrent upgrade exactly once', async () => { + const databaseNow = await postgresNow(first); + const cutoverAt = await ensurePersistedCutover(first, utcMidnightBefore(databaseNow)); + const fixture = await createFixture(first, OWNER, cutoverAt); + await seedAccount(first, OWNER, databaseNow); + + try { + let releaseLock!: () => void; + const releasePromise = new Promise((resolve) => { + releaseLock = resolve; + }); + let lockHeld!: () => void; + const lockHeldPromise = new Promise((resolve) => { + lockHeld = resolve; + }); + const blockerTransaction = blocker.$transaction(async (transaction) => { + await transaction.$queryRaw( + Prisma.sql`SELECT "id" FROM "mineral_accounts" WHERE "ownerAddress" = ${OWNER} FOR UPDATE`, + ); + lockHeld(); + await releasePromise; + }); + await lockHeldPromise; + + let completed = 0; + const firstUpgrade = purchasePlanetUpgrade(first, { + planetId: fixture.planet.id, + targetLevel: 1, + cutoverAt, + }).then((result) => { + completed += 1; + return result; + }); + const secondUpgrade = purchasePlanetUpgrade(second, { + planetId: fixture.planet.id, + targetLevel: 1, + cutoverAt, + }).then((result) => { + completed += 1; + return result; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(completed).toBe(0); + releaseLock(); + await blockerTransaction; + + const results = await Promise.all([firstUpgrade, secondUpgrade]); + + expect(results[0]?.targetLevel).toBe(1); + expect(results[1]?.targetLevel).toBe(1); + expect( + await first.planetUpgradePurchase.count({ + where: { planetId: fixture.planet.id, targetLevel: 1 }, + }), + ).toBe(1); + const balance = (await first.mineralAccount.findUnique({ where: { ownerAddress: OWNER } })) + ?.balanceMicros; + expect(balance).toBeGreaterThanOrEqual(9_800_000n); + expect(balance).toBeLessThan(10_000_000n); + } finally { + await clearTestDatabase(first); + } + }); + + it('serializes different Planet upgrades for one wallet', async () => { + const databaseNow = await postgresNow(first); + const cutoverAt = await ensurePersistedCutover(first, utcMidnightBefore(databaseNow)); + const firstFixture = await createFixture(first, OWNER, cutoverAt); + const secondFixture = await createFixture(first, OWNER, cutoverAt); + await seedAccount(first, OWNER, databaseNow); + + const [firstPurchase, secondPurchase] = await Promise.all([ + purchasePlanetUpgrade(first, { planetId: firstFixture.planet.id, targetLevel: 1, cutoverAt }), + purchasePlanetUpgrade(second, { + planetId: secondFixture.planet.id, + targetLevel: 1, + cutoverAt, + }), + ]); + + expect(firstPurchase.targetLevel).toBe(1); + expect(secondPurchase.targetLevel).toBe(1); + expect(await first.planetUpgradePurchase.count({ where: { walletAddress: OWNER } })).toBe(2); + const balance = (await first.mineralAccount.findUnique({ where: { ownerAddress: OWNER } })) + ?.balanceMicros; + expect(balance).toBeGreaterThanOrEqual(9_600_000n); + expect(balance).toBeLessThan(10_000_000n); + }); + + it('orders generation and upgrade through the shared economy barrier', async () => { + const databaseNow = await postgresNow(first); + const cutoverAt = await ensurePersistedCutover(first, utcMidnightBefore(databaseNow)); + const existing = await createFixture(first, OWNER, cutoverAt); + const generationTicket = await createTicket(first, OWNER, cutoverAt); + await seedAccount(first, OWNER, databaseNow); + + const generation = new PrismaBackendPlanetStore( + second, + () => databaseNow, + cutoverAt, + ).generatePlanet(proofForTicket(generationTicket.ticket, OWNER)); + const upgrade = purchasePlanetUpgrade(first, { + planetId: existing.planet.id, + targetLevel: 1, + cutoverAt, + }); + const [generated, purchase] = await Promise.all([generation, upgrade]); + + expect(generated.status).toBe('READY'); + expect(purchase.targetLevel).toBe(1); + expect( + await first.backendPlanet.count({ where: { ownerAddress: OWNER, status: 'READY' } }), + ).toBe(2); + expect(await first.planetUpgradePurchase.count({ where: { walletAddress: OWNER } })).toBe(1); + }); + + it('serializes two concurrent generations for one wallet', async () => { + const databaseNow = await postgresNow(first); + const cutoverAt = await ensurePersistedCutover(first, utcMidnightBefore(databaseNow)); + const firstTicket = await createTicket(first, OWNER, cutoverAt); + const secondTicket = await createTicket(first, OWNER, cutoverAt); + await seedAccount(first, OWNER, databaseNow); + + const [firstPlanet, secondPlanet] = await Promise.all([ + new PrismaBackendPlanetStore(first, () => databaseNow, cutoverAt).generatePlanet( + proofForTicket(firstTicket.ticket, OWNER), + ), + new PrismaBackendPlanetStore(second, () => databaseNow, cutoverAt).generatePlanet( + proofForTicket(secondTicket.ticket, OWNER), + ), + ]); + + expect(firstPlanet.status).toBe('READY'); + expect(secondPlanet.status).toBe('READY'); + expect( + await first.backendPlanet.count({ where: { ownerAddress: OWNER, status: 'READY' } }), + ).toBe(2); + expect(await first.mineralAccount.count({ where: { ownerAddress: OWNER } })).toBe(1); + const account = await first.mineralAccount.findUnique({ where: { ownerAddress: OWNER } }); + const planets = await first.backendPlanet.findMany({ + where: { ownerAddress: OWNER, status: 'READY' }, + select: { + id: true, + ownerAddress: true, + planetType: true, + baseMineralsPerDay: true, + generatedAt: true, + }, + }); + expect(account?.balanceMicros).toBe( + 10_000_000n + calculateHistoricalProduction({ + planets, + purchases: [], + from: databaseNow, + to: account?.lastSettledAt ?? databaseNow, + anchor: cutoverAt, + }), + ); + }); + + it('timestamps generation after waiting on the wallet lock', async () => { + const databaseNow = await postgresNow(first); + const cutoverAt = await ensurePersistedCutover(first, utcMidnightBefore(databaseNow)); + const ticket = await createTicket(first, OWNER, databaseNow); + await seedAccount(first, OWNER, databaseNow); + + let releaseWallet!: () => void; + const release = new Promise((resolve) => { + releaseWallet = resolve; + }); + let walletHeld!: () => void; + const held = new Promise((resolve) => { + walletHeld = resolve; + }); + const blockerTransaction = blocker.$transaction(async (transaction) => { + await acquireMineralEconomySharedBarrier(transaction); + await acquireMineralWalletLock(transaction, OWNER); + walletHeld(); + await release; + }); + await held; + + let completed = false; + const generation = new PrismaBackendPlanetStore( + second, + () => databaseNow, + cutoverAt, + ).generatePlanet(proofForTicket(ticket.ticket, OWNER)).then((result) => { + completed = true; + return result; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(completed).toBe(false); + const releasedAt = await postgresNow(first); + releaseWallet(); + await Promise.all([blockerTransaction, generation]); + + const generated = await first.backendPlanet.findUnique({ + where: { ticketPurchaseId: ticket.ticket.id }, + }); + expect(generated?.generatedAt.getTime()).toBeGreaterThanOrEqual(releasedAt.getTime()); + const account = await first.mineralAccount.findUnique({ where: { ownerAddress: OWNER } }); + const planets = await first.backendPlanet.findMany({ + where: { ownerAddress: OWNER, status: 'READY' }, + select: { + id: true, + ownerAddress: true, + planetType: true, + baseMineralsPerDay: true, + generatedAt: true, + }, + }); + expect(account?.balanceMicros).toBe( + 10_000_000n + calculateHistoricalProduction({ + planets, + purchases: [], + from: databaseNow, + to: account?.lastSettledAt ?? databaseNow, + anchor: cutoverAt, + }), + ); + }); + + it('keeps backfill and in-flight generation ordered by the economy barrier', async () => { + const databaseNow = await postgresNow(first); + const cutoverAt = await ensurePersistedCutover(first, utcMidnightBefore(databaseNow)); + const ticket = await createTicket(first, OWNER, cutoverAt); + + let releaseBarrier!: () => void; + const release = new Promise((resolve) => { + releaseBarrier = resolve; + }); + let barrierHeld!: () => void; + const held = new Promise((resolve) => { + barrierHeld = resolve; + }); + const blockerTransaction = blocker.$transaction(async (transaction) => { + await acquireMineralEconomyExclusiveBarrier(transaction); + barrierHeld(); + await release; + }); + await held; + + const generation = new PrismaBackendPlanetStore( + second, + () => databaseNow, + cutoverAt, + ).generatePlanet(proofForTicket(ticket.ticket, OWNER)); + const backfill = runMineralAccountsBackfill(first, cutoverAt); + releaseBarrier(); + await Promise.all([blockerTransaction, generation, backfill]); + + expect( + await first.backendPlanet.count({ where: { ownerAddress: OWNER, status: 'READY' } }), + ).toBe(1); + expect(await first.mineralAccount.count({ where: { ownerAddress: OWNER } })).toBe(1); + }); + + it('lets finalization drain a late pre-boundary mutation before its snapshot', async () => { + const databaseNow = await postgresNow(first); + const cutoverAt = utcMidnightBefore(databaseNow); + const fixture = await createFixture(first, OWNER, cutoverAt); + await seedAccount(first, OWNER, databaseNow); + const persistedCutoverAt = await ensurePersistedCutover(first, cutoverAt); + const period = { + id: persistedCutoverAt.toISOString().slice(0, 10), + startsAt: persistedCutoverAt, + endsAt: new Date(persistedCutoverAt.getTime() + 86_400_000), + }; + + let releaseMutation!: () => void; + const release = new Promise((resolve) => { + releaseMutation = resolve; + }); + let mutationHeld!: () => void; + const held = new Promise((resolve) => { + mutationHeld = resolve; + }); + const mutation = first.$transaction(async (transaction) => { + await acquireMineralEconomySharedBarrier(transaction); + mutationHeld(); + await release; + await transaction.planetUpgradePurchase.create({ + data: { + planetId: fixture.planet.id, + walletAddress: OWNER, + targetLevel: 1, + bonusBpsAfter: 1_000, + costMicros: 200_000n, + purchasedAt: new Date(period.endsAt.getTime() - 1), + }, + }); + }); + await held; + let finalized = false; + const finalization = finalizeLeaderboardPeriod(second, period, databaseNow, { + mineralEconomyCutoverAt: persistedCutoverAt, + }).then((result) => { + finalized = true; + return result; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(finalized).toBe(false); + releaseMutation(); + await Promise.all([mutation, finalization]); + + expect( + await first.planetUpgradePurchase.count({ where: { planetId: fixture.planet.id } }), + ).toBe(1); + expect( + (await first.leaderboardPeriod.findUnique({ where: { id: period.id } }))?.finalizedAt, + ).not.toBeNull(); + const archived = await first.leaderboardEntry.findUnique({ + where: { periodId_walletAddress: { periodId: period.id, walletAddress: OWNER } }, + }); + expect(archived?.scoreMicros).toBe(10_800_000n); + }); + + it('uses a persisted cutover when configuration is missing', async () => { + const cutoverAt = await ensurePersistedCutover( + first, + utcMidnightBefore(await postgresNow(first)), + ); + + await expect(resolveMineralEconomyCutover(first, null)).resolves.toEqual({ + state: 'V2', + cutoverAt, + source: 'database', + }); + }); + + it('fails closed when configured and persisted cutovers conflict', async () => { + const persistedCutoverAt = await ensurePersistedCutover( + first, + utcMidnightBefore(await postgresNow(first)), + ); + + await expect( + resolveMineralEconomyCutover(first, new Date(persistedCutoverAt.getTime() - 86_400_000)), + ).rejects.toThrow('conflicts with the persisted database cutover'); + }); +}); diff --git a/server/api/mineralCutover.test.ts b/server/api/mineralCutover.test.ts new file mode 100644 index 0000000..41cb9a5 --- /dev/null +++ b/server/api/mineralCutover.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PrismaClient } from './generated/prisma/client.js'; +import { + ensureMineralEconomyCutover, + prepareMineralEconomyCutover, + resolveMineralEconomyForOperation, + resolveMineralEconomyCutover, +} from './mineralAccounts.js'; + +const CUTOVER = new Date('2026-08-20T00:00:00.000Z'); + +describe('Mineral economy cutover invariant', () => { + it('resolves a configured future cutover as staged without persisting it', async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const createMany = vi.fn(); + const prisma = { + mineralEconomyCutover: { findUnique, createMany }, + } as unknown as PrismaClient; + + await expect(resolveMineralEconomyCutover(prisma, CUTOVER)).resolves.toEqual({ + state: 'STAGED_V2', + cutoverAt: CUTOVER, + source: 'config', + }); + expect(createMany).not.toHaveBeenCalled(); + }); + + it('uses the persisted cutover when configuration is absent', async () => { + const row = { id: 1, cutoverAt: CUTOVER }; + const prisma = { + mineralEconomyCutover: { findUnique: vi.fn().mockResolvedValue(row) }, + } as unknown as PrismaClient; + + await expect(resolveMineralEconomyCutover(prisma, null)).resolves.toEqual({ + state: 'V2', + cutoverAt: CUTOVER, + source: 'database', + }); + }); + + it('fails closed when configured and persisted cutovers conflict', async () => { + const prisma = { + mineralEconomyCutover: { + findUnique: vi.fn().mockResolvedValue({ + id: 1, + cutoverAt: new Date('2026-08-21T00:00:00.000Z'), + }), + }, + } as unknown as PrismaClient; + + await expect(resolveMineralEconomyCutover(prisma, CUTOVER)).rejects.toThrow( + 'conflicts with the persisted database cutover', + ); + }); + + it('fails a staged cutover once PostgreSQL reaches it instead of activating V2', async () => { + const prisma = { + mineralEconomyCutover: { findUnique: vi.fn().mockResolvedValue(null) }, + $queryRaw: vi.fn().mockResolvedValue([{ now: CUTOVER }]), + } as unknown as PrismaClient; + + await expect(resolveMineralEconomyForOperation(prisma, CUTOVER)).rejects.toThrow( + 'configured mineral economy cutover is not persisted', + ); + }); + + it('keeps a configured staged cutover in V1 before PostgreSQL reaches it', async () => { + const prisma = { + mineralEconomyCutover: { findUnique: vi.fn().mockResolvedValue(null) }, + $queryRaw: vi.fn().mockResolvedValue([{ now: new Date(CUTOVER.getTime() - 1) }]), + } as unknown as PrismaClient; + + await expect(resolveMineralEconomyForOperation(prisma, CUTOVER)).resolves.toMatchObject({ + state: 'STAGED_V2', + cutoverAt: CUTOVER, + }); + }); + + it('persists a prepared future cutover only inside the explicit command', async () => { + const create = vi.fn().mockResolvedValue({ id: 1, cutoverAt: CUTOVER }); + const transaction = { + $queryRaw: vi.fn() + .mockResolvedValueOnce([{ locked: 1 }]) + .mockResolvedValueOnce([{ now: new Date('2026-08-19T00:00:00.000Z') }]), + mineralEconomyCutover: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 1, cutoverAt: CUTOVER }), + create, + }, + }; + const prisma = { + $transaction: vi.fn(async (callback: (value: typeof transaction) => unknown) => callback(transaction)), + } as unknown as PrismaClient; + + await expect(prepareMineralEconomyCutover(prisma, CUTOVER)).resolves.toEqual({ + id: 1, + cutoverAt: CUTOVER, + }); + expect(create).toHaveBeenCalledWith({ data: { id: 1, cutoverAt: CUTOVER } }); + }); + + it('creates the singleton once and rejects a configured conflict', async () => { + const row = { id: 1, cutoverAt: CUTOVER }; + const findUnique = vi.fn().mockResolvedValueOnce(row).mockResolvedValueOnce(row); + const createMany = vi.fn().mockResolvedValue({ count: 1 }); + const prisma = { mineralEconomyCutover: { findUnique, createMany } } as unknown as PrismaClient; + + await expect(ensureMineralEconomyCutover(prisma, CUTOVER)).resolves.toEqual(row); + await expect( + ensureMineralEconomyCutover(prisma, new Date('2026-08-21T00:00:00.000Z')), + ).rejects.toThrow('conflicts with the persisted database cutover'); + expect(createMany).toHaveBeenCalledTimes(2); + }); +}); diff --git a/server/api/mineralCutoverPrepare.test.ts b/server/api/mineralCutoverPrepare.test.ts new file mode 100644 index 0000000..7028181 --- /dev/null +++ b/server/api/mineralCutoverPrepare.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PrismaClient } from './generated/prisma/client.js'; +import { + formatMineralCutoverPrepareResult, + parseMineralCutoverPrepareArgs, + prepareMineralCutover, +} from './mineralCutoverPrepare.js'; + +const CUTOVER = new Date('2026-08-24T00:00:00.000Z'); +const DATABASE_NOW = new Date('2026-08-20T12:00:00.000Z'); + +function makePrisma(databaseNow = DATABASE_NOW) { + const findUnique = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValue({ id: 1, cutoverAt: CUTOVER }); + const createMany = vi.fn().mockResolvedValue({ count: 1 }); + const transaction = { + $queryRaw: vi.fn().mockResolvedValue([{ now: databaseNow }]), + mineralEconomyCutover: { findUnique, createMany }, + }; + const prisma = { + $transaction: vi.fn(async (callback: (value: typeof transaction) => unknown) => + callback(transaction), + ), + } as unknown as PrismaClient; + return { prisma, findUnique, createMany }; +} + +describe('Mineral cutover prepare command', () => { + it('requires an explicit exact UTC-midnight cutover argument', () => { + expect(parseMineralCutoverPrepareArgs(['--cutover-at', CUTOVER.toISOString()])).toEqual({ + cutoverAt: CUTOVER, + dryRun: false, + }); + expect( + parseMineralCutoverPrepareArgs(['--dry-run', '--cutover-at', CUTOVER.toISOString()]), + ).toEqual({ + cutoverAt: CUTOVER, + dryRun: true, + }); + expect(() => parseMineralCutoverPrepareArgs([])).toThrow('Missing required --cutover-at'); + expect(() => + parseMineralCutoverPrepareArgs(['--cutover-at', '2026-08-24T12:00:00.000Z']), + ).toThrow('exact UTC midnight'); + expect(() => + parseMineralCutoverPrepareArgs(['--cutover-at', CUTOVER.toISOString(), '--force']), + ).toThrow('Unknown argument: --force'); + }); + + it('dry-runs against PostgreSQL time without persisting the cutover', async () => { + const { prisma, createMany } = makePrisma(); + + await expect(prepareMineralCutover(prisma, CUTOVER, { dryRun: true })).resolves.toMatchObject({ + cutoverAt: CUTOVER, + databaseNow: DATABASE_NOW, + dryRun: true, + persisted: false, + }); + expect(createMany).not.toHaveBeenCalled(); + }); + + it('persists the chosen future cutover through the existing singleton export', async () => { + const { prisma, createMany } = makePrisma(); + + await expect(prepareMineralCutover(prisma, CUTOVER)).resolves.toMatchObject({ + cutoverAt: CUTOVER, + databaseNow: DATABASE_NOW, + dryRun: false, + persisted: true, + }); + expect(createMany).toHaveBeenCalledWith({ + data: [{ id: 1, cutoverAt: CUTOVER }], + skipDuplicates: true, + }); + }); + + it('rejects a cutover that is not in the future of PostgreSQL time', async () => { + const { prisma, createMany } = makePrisma(new Date('2026-08-24T00:00:00.000Z')); + + await expect(prepareMineralCutover(prisma, CUTOVER)).rejects.toThrow( + 'must be in the future of PostgreSQL time', + ); + expect(createMany).not.toHaveBeenCalled(); + }); + + it('formats a safe result without connection details', () => { + const output = formatMineralCutoverPrepareResult({ + cutoverAt: CUTOVER, + databaseNow: DATABASE_NOW, + dryRun: true, + persisted: false, + }); + + expect(output).toContain('Mineral economy cutover dry run.'); + expect(output).toContain('Cutover: 2026-08-24T00:00:00.000Z'); + expect(output).toContain('PostgreSQL time: 2026-08-20T12:00:00.000Z'); + expect(output).not.toContain('postgresql://'); + }); +}); diff --git a/server/api/mineralCutoverPrepare.ts b/server/api/mineralCutoverPrepare.ts new file mode 100644 index 0000000..9748843 --- /dev/null +++ b/server/api/mineralCutoverPrepare.ts @@ -0,0 +1,135 @@ +import { pathToFileURL } from 'node:url'; +import { loadMineralEconomyConfig } from './backendConfig.js'; +import { disconnectPrisma, getPrismaClient } from './database.js'; +import type { PrismaClient } from './generated/prisma/client.js'; +import { + getPostgresClockTimestamp, + prepareMineralEconomyCutover, + readMineralEconomyCutover, +} from './mineralAccounts.js'; + +export type MineralCutoverPrepareCliOptions = { + cutoverAt: Date; + dryRun: boolean; +}; + +export type MineralCutoverPrepareResult = { + cutoverAt: Date; + databaseNow: Date; + dryRun: boolean; + persisted: boolean; +}; + +function parseCutoverAt(raw: string): Date { + const value = loadMineralEconomyConfig({ + MINERAL_ECONOMY_CUTOVER_AT: raw, + }).mineralEconomyCutoverAt; + if (!value) throw new Error('A cutover timestamp is required.'); + return value; +} + +export function parseMineralCutoverPrepareArgs( + args: readonly string[], +): MineralCutoverPrepareCliOptions { + let cutoverAt: Date | undefined; + let dryRun = false; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--dry-run') { + dryRun = true; + continue; + } + if (arg === '--cutover-at') { + if (cutoverAt) throw new Error('The --cutover-at argument may only be provided once.'); + const raw = args[index + 1]; + if (!raw || raw.startsWith('--')) throw new Error('Missing value for --cutover-at.'); + cutoverAt = parseCutoverAt(raw); + index += 1; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (!cutoverAt) throw new Error('Missing required --cutover-at argument.'); + return { cutoverAt, dryRun }; +} + +function validateCutoverAt(cutoverAt: Date): Date { + if (!(cutoverAt instanceof Date) || !Number.isFinite(cutoverAt.getTime())) { + throw new Error('Mineral economy cutover timestamp is invalid.'); + } + return parseCutoverAt(cutoverAt.toISOString()); +} + +export async function prepareMineralCutover( + prisma: PrismaClient, + cutoverAt: Date, + options: { dryRun?: boolean } = {}, +): Promise { + const normalizedCutoverAt = validateCutoverAt(cutoverAt); + const dryRun = options.dryRun ?? false; + + if (dryRun) { + return prisma.$transaction(async (transaction) => { + const databaseNow = await getPostgresClockTimestamp(transaction); + if (databaseNow.getTime() >= normalizedCutoverAt.getTime()) { + throw new Error('Mineral economy cutover must be in the future of PostgreSQL time.'); + } + const persisted = await readMineralEconomyCutover(transaction); + if (persisted && persisted.getTime() !== normalizedCutoverAt.getTime()) { + throw new Error( + 'Configured mineral economy cutover conflicts with the persisted database cutover.', + ); + } + return { cutoverAt: normalizedCutoverAt, databaseNow, dryRun, persisted: false }; + }); + } + + await prepareMineralEconomyCutover(prisma, normalizedCutoverAt); + const databaseNow = await prisma.$transaction((transaction) => + getPostgresClockTimestamp(transaction), + ); + return { cutoverAt: normalizedCutoverAt, databaseNow, dryRun, persisted: true }; +} + +export function formatMineralCutoverPrepareResult(result: MineralCutoverPrepareResult): string { + return [ + `Mineral economy cutover ${result.dryRun ? 'dry run' : 'prepared'}.`, + `Cutover: ${result.cutoverAt.toISOString()}`, + `PostgreSQL time: ${result.databaseNow.toISOString()}`, + `Persisted: ${result.persisted ? 'yes' : 'no'}`, + ].join('\n'); +} + +function requiredDatabaseUrl(env: Record): string { + const value = env.DIRECT_URL?.trim() || env.DATABASE_URL?.trim(); + if (!value) + throw new Error('Missing required server environment variable DIRECT_URL or DATABASE_URL.'); + return value; +} + +export async function runMineralCutoverPrepareCommand( + env: Record = process.env, + args: readonly string[] = process.argv.slice(2), +): Promise { + const options = parseMineralCutoverPrepareArgs(args); + const prisma = getPrismaClient(requiredDatabaseUrl(env)); + try { + return await prepareMineralCutover(prisma, options.cutoverAt, options); + } finally { + await disconnectPrisma(); + } +} + +export async function main(): Promise { + const result = await runMineralCutoverPrepareCommand(); + process.stdout.write(`${formatMineralCutoverPrepareResult(result)}\n`); +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + main().catch(() => { + process.stderr.write('Mineral economy cutover prepare failed.\n'); + process.exitCode = 1; + }); +} diff --git a/server/api/mineralEconomy.test.ts b/server/api/mineralEconomy.test.ts new file mode 100644 index 0000000..e070ef8 --- /dev/null +++ b/server/api/mineralEconomy.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateCollectionProduction, + calculateConstantRateSegment, + calculateHistoricalPlanetProduction, + calculateProductionSegments, + collectionBonusBpsAt, + type MineralCollectionPlanet, +} from './mineralEconomy.js'; + +const ANCHOR = new Date('2026-08-10T00:00:00.000Z'); + +describe('Minerals Economy v2 temporal calculations', () => { + it('requires one explicit anchor for every settlement calculation', () => { + expect(() => + calculateProductionSegments({ + rateMicrosPerDay: 1n, + segments: [{ from: ANCHOR, to: new Date('2026-08-11T00:00:00.000Z') }], + } as unknown as Parameters[0]), + ).toThrow('Production anchor is required'); + + const planet: MineralCollectionPlanet = { + id: 'anchored', + ownerAddress: '0xabc', + planetType: 'Gaia', + baseMineralsPerDay: 1n, + activatedAt: ANCHOR, + }; + expect(() => + calculateCollectionProduction({ + planet, + planets: [planet], + from: ANCHOR, + to: new Date('2026-08-11T00:00:00.000Z'), + } as unknown as Parameters[0]), + ).toThrow('Production anchor is required'); + }); + + it('uses one anchored floor so splitting a constant segment is exact', () => { + const from = new Date('2026-08-10T00:00:00.001Z'); + const split = new Date('2026-08-10T12:00:00.001Z'); + const to = new Date('2026-08-11T00:00:00.001Z'); + const whole = calculateConstantRateSegment({ + rateMicrosPerDay: 101n, + from, + to, + anchor: ANCHOR, + }); + const parts = calculateProductionSegments({ + rateMicrosPerDay: 101n, + anchor: ANCHOR, + segments: [ + { from, to: split }, + { from: split, to }, + ], + }); + + expect(parts).toBe(whole); + }); + + it('keeps split collection settlement exact with a fixed anchor and a remainder', () => { + const anchor = new Date('2026-08-01T00:00:00.000Z'); + const from = new Date('2026-08-01T00:00:00.001Z'); + const split = new Date('2026-08-01T12:34:56.789Z'); + const to = new Date('2026-08-02T00:00:00.001Z'); + const planet: MineralCollectionPlanet = { + id: 'split', + ownerAddress: '0xabc', + planetType: 'Gaia', + baseMineralsPerDay: 1n, + activatedAt: anchor, + }; + const input = { planet, planets: [planet], anchor }; + const whole = calculateCollectionProduction({ ...input, from, to }); + const first = calculateCollectionProduction({ ...input, from, to: split }); + const second = calculateCollectionProduction({ ...input, from: split, to }); + + expect(whole).toBe(1_000_000n); + expect(first + second).toBe(whole); + }); + + it('keeps an event at the end of a half-open interval out of that interval', () => { + const upgradeAt = new Date('2026-08-10T12:00:00.000Z'); + expect( + calculateProductionSegments({ + anchor: ANCHOR, + segments: [ + { from: ANCHOR, to: upgradeAt, rateMicrosPerDay: 100n }, + { from: upgradeAt, to: new Date('2026-08-11T00:00:00.000Z'), rateMicrosPerDay: 200n }, + ], + }), + ).toBe(150n); + }); + + it('keeps split settlement exact across an upgrade boundary with a nontrivial remainder', () => { + const purchaseAt = new Date('2026-08-10T12:34:56.789Z'); + const to = new Date('2026-08-11T00:00:00.001Z'); + const planet: MineralCollectionPlanet = { + id: 'upgraded', + ownerAddress: '0xabc', + planetType: 'Gaia', + baseMineralsPerDay: 1n, + activatedAt: ANCHOR, + }; + const input = { + planet, + planets: [planet], + purchases: [{ planetId: planet.id, targetLevel: 1, bonusBpsAfter: 1_000, purchasedAt: purchaseAt }], + anchor: ANCHOR, + }; + const whole = calculateHistoricalPlanetProduction({ ...input, from: ANCHOR, to }); + const first = calculateHistoricalPlanetProduction({ ...input, from: ANCHOR, to: purchaseAt }); + const second = calculateHistoricalPlanetProduction({ ...input, from: purchaseAt, to }); + + expect(first + second).toBe(whole); + expect(whole).toBe(1_047_573n); + }); + + it('uses the highest upgrade sequence when L1 and L2 share a millisecond boundary', () => { + const upgradeAt = new Date('2026-08-10T12:00:00.000Z'); + const to = new Date('2026-08-11T00:00:00.000Z'); + const planet: MineralCollectionPlanet = { + id: 'same-millisecond-upgrades', + ownerAddress: '0xabc', + planetType: 'Gaia', + baseMineralsPerDay: 1n, + activatedAt: ANCHOR, + }; + const input = { + planet, + planets: [planet], + purchases: [ + { planetId: planet.id, targetLevel: 2, bonusBpsAfter: 2_500, purchasedAt: upgradeAt }, + { planetId: planet.id, targetLevel: 1, bonusBpsAfter: 1_000, purchasedAt: upgradeAt }, + ], + anchor: ANCHOR, + }; + const whole = calculateHistoricalPlanetProduction({ ...input, from: ANCHOR, to }); + const first = calculateHistoricalPlanetProduction({ ...input, from: ANCHOR, to: upgradeAt }); + const second = calculateHistoricalPlanetProduction({ ...input, from: upgradeAt, to }); + + expect(first).toBe(500_000n); + expect(second).toBe(625_000n); + expect(whole).toBe(1_125_000n); + expect(first + second).toBe(whole); + }); + + it('activates collection bonuses at the historical third activation', () => { + const planets: MineralCollectionPlanet[] = [ + { + id: 'one', + ownerAddress: '0xabc', + planetType: 'Gaia', + baseMineralsPerDay: 100n, + activatedAt: new Date('2026-08-01T00:00:00.000Z'), + }, + { + id: 'two', + ownerAddress: '0xabc', + planetType: 'Gaia', + baseMineralsPerDay: 100n, + activatedAt: new Date('2026-08-02T00:00:00.000Z'), + }, + { + id: 'three', + ownerAddress: '0xabc', + planetType: 'Gaia', + baseMineralsPerDay: 100n, + activatedAt: new Date('2026-08-03T00:00:00.000Z'), + }, + ]; + + expect(collectionBonusBpsAt(planets, new Date('2026-08-02T23:59:59.999Z'))).toBe(0); + expect(collectionBonusBpsAt(planets, new Date('2026-08-03T00:00:00.000Z'))).toBe(500); + expect( + calculateCollectionProduction({ + planet: planets[0], + planets, + from: new Date('2026-08-01T00:00:00.000Z'), + to: new Date('2026-08-03T00:00:00.000Z'), + anchor: new Date('2026-08-01T00:00:00.000Z'), + }), + ).toBe(200_000_000n); + expect( + calculateCollectionProduction({ + planet: planets[0], + planets, + from: new Date('2026-08-01T00:00:00.000Z'), + to: new Date('2026-08-04T00:00:00.000Z'), + anchor: new Date('2026-08-01T00:00:00.000Z'), + }), + ).toBe(305_000_000n); + }); + + it('rejects negative rates and reversed intervals', () => { + expect(() => + calculateConstantRateSegment({ + rateMicrosPerDay: -1n, + from: ANCHOR, + to: new Date('2026-08-11T00:00:00.000Z'), + anchor: ANCHOR, + }), + ).toThrow('rateMicrosPerDay cannot be negative'); + expect(() => + calculateConstantRateSegment({ + rateMicrosPerDay: 1n, + from: new Date('2026-08-11T00:00:00.000Z'), + to: ANCHOR, + anchor: ANCHOR, + }), + ).toThrow('Production interval cannot end before it starts'); + }); +}); diff --git a/server/api/mineralEconomy.ts b/server/api/mineralEconomy.ts new file mode 100644 index 0000000..32d90f2 --- /dev/null +++ b/server/api/mineralEconomy.ts @@ -0,0 +1,304 @@ +import { + calculateEffectiveMineralsPerDayMicros, + collectionBonusBpsForCount, +} from './collectionMining.js'; + +export const MILLISECONDS_PER_DAY = 86_400_000n; + +export type MineralProductionSegment = { + from: Date; + to: Date; + rateMicrosPerDay?: bigint; +}; + +export type MineralCollectionPlanet = { + id: string; + ownerAddress: string; + planetType: string; + baseMineralsPerDay: bigint; + activatedAt?: Date; + generatedAt?: Date; +}; + +export type MineralUpgradePurchase = { + planetId: string; + targetLevel: number; + bonusBpsAfter: number; + purchasedAt: Date; +}; + +function activationAt(planet: MineralCollectionPlanet): Date { + const value = planet.activatedAt ?? planet.generatedAt; + if (!value) throw new Error(`Planet ${planet.id} activation timestamp is missing.`); + return value; +} + +function timestamp(value: Date, name: string): number { + const milliseconds = value.getTime(); + if (!Number.isFinite(milliseconds)) throw new Error(`${name} timestamp is invalid.`); + return milliseconds; +} + +function assertNonNegative(name: string, value: bigint): void { + if (value < 0n) throw new Error(`${name} cannot be negative.`); +} + +/** + * Calculates one constant-rate interval with a shared integer floor anchor. + * The anchor must be at or before the interval start so split intervals remain exact. + */ +export function calculateConstantRateSegment(input: { + rateMicrosPerDay: bigint; + from: Date; + to: Date; + anchor: Date; +}): bigint { + assertNonNegative('rateMicrosPerDay', input.rateMicrosPerDay); + const from = timestamp(input.from, 'Production start'); + const to = timestamp(input.to, 'Production end'); + if (to < from) throw new Error('Production interval cannot end before it starts.'); + if (!input.anchor) throw new Error('Production anchor is required.'); + const anchor = timestamp(input.anchor, 'Production anchor'); + if (anchor > from) throw new Error('Production anchor cannot be after the interval start.'); + return ( + (input.rateMicrosPerDay * BigInt(to - anchor)) / MILLISECONDS_PER_DAY - + (input.rateMicrosPerDay * BigInt(from - anchor)) / MILLISECONDS_PER_DAY + ); +} + +/** Sums disjoint or adjacent segments against one anchor without per-segment truncation. */ +export function calculateProductionSegments(input: { + rateMicrosPerDay?: bigint; + segments: readonly MineralProductionSegment[]; + anchor: Date; +}): bigint { + if (!input.anchor) throw new Error('Production anchor is required.'); + const anchor = input.anchor; + let total = 0n; + for (const segment of input.segments) { + const rateMicrosPerDay = segment.rateMicrosPerDay ?? input.rateMicrosPerDay; + if (rateMicrosPerDay === undefined) { + throw new Error('A rateMicrosPerDay is required for every production segment.'); + } + total += calculateConstantRateSegment({ + rateMicrosPerDay, + from: segment.from, + to: segment.to, + anchor, + }); + } + return total; +} + +function compareActivations(left: MineralCollectionPlanet, right: MineralCollectionPlanet): number { + return ( + activationAt(left).getTime() - activationAt(right).getTime() || left.id.localeCompare(right.id) + ); +} + +function matchingPlanets( + planet: MineralCollectionPlanet, + planets: readonly MineralCollectionPlanet[], +): MineralCollectionPlanet[] { + const ownerAddress = planet.ownerAddress.toLowerCase(); + return planets + .filter( + (candidate) => + candidate.ownerAddress.toLowerCase() === ownerAddress && + candidate.planetType === planet.planetType, + ) + .sort(compareActivations); +} + +/** Returns the collection tier active at an instant, including events at that instant. */ +export function collectionBonusBpsAt( + planets: readonly MineralCollectionPlanet[], + at: Date, + target?: Pick, +): number { + const atMilliseconds = timestamp(at, 'Collection lookup'); + const selected = target ?? planets[0]; + if (!selected) return 0; + const matching = planets.filter((planet) => { + const activatedAt = activationAt(planet); + timestamp(activatedAt, `Planet ${planet.id} activation`); + return ( + planet.ownerAddress.toLowerCase() === selected.ownerAddress.toLowerCase() && + planet.planetType === selected.planetType && + activatedAt.getTime() <= atMilliseconds + ); + }); + return collectionBonusBpsForCount(matching.length); +} + +/** + * Calculates one Planet's historical collection production over [from, to). + * Milestone activations at `to` are intentionally applied only to the next interval. + */ +export function calculateCollectionProduction(input: { + planet: MineralCollectionPlanet; + planets: readonly MineralCollectionPlanet[]; + from: Date; + to: Date; + anchor: Date; +}): bigint { + const fromMilliseconds = timestamp(input.from, 'Production start'); + const toMilliseconds = timestamp(input.to, 'Production end'); + if (toMilliseconds < fromMilliseconds) { + throw new Error('Production interval cannot end before it starts.'); + } + const activatedAt = timestamp(activationAt(input.planet), `Planet ${input.planet.id} activation`); + if (!input.anchor) throw new Error('Production anchor is required.'); + const startMilliseconds = Math.max(fromMilliseconds, activatedAt); + if (startMilliseconds >= toMilliseconds) return 0n; + const anchor = input.anchor; + const group = matchingPlanets(input.planet, input.planets); + let cursor = new Date(startMilliseconds); + let activeCount = 0; + for (const candidate of group) { + const candidateActivation = activationAt(candidate); + const candidateTime = candidateActivation.getTime(); + if (candidateTime > startMilliseconds) break; + activeCount += 1; + } + let activeBonusBps = collectionBonusBpsForCount(activeCount); + + let total = 0n; + for (const candidate of group) { + const candidateActivation = activationAt(candidate); + const candidateTime = candidateActivation.getTime(); + if (candidateTime <= startMilliseconds || candidateTime >= toMilliseconds) continue; + total += calculateConstantRateSegment({ + rateMicrosPerDay: calculateEffectiveMineralsPerDayMicros( + input.planet.baseMineralsPerDay, + activeBonusBps, + ), + from: cursor, + to: candidateActivation, + anchor, + }); + cursor = candidateActivation; + activeCount += 1; + activeBonusBps = collectionBonusBpsForCount(activeCount); + } + total += calculateConstantRateSegment({ + rateMicrosPerDay: calculateEffectiveMineralsPerDayMicros( + input.planet.baseMineralsPerDay, + activeBonusBps, + ), + from: cursor, + to: input.to, + anchor, + }); + return total; +} + +export function upgradeBonusBpsAt( + purchases: readonly MineralUpgradePurchase[], + planetId: string, + atMilliseconds: number, +): number { + let bonusBps = 0; + let latestPurchaseAt = Number.NEGATIVE_INFINITY; + let latestTargetLevel = -1; + for (const purchase of purchases) { + const purchaseTime = purchase.purchasedAt.getTime(); + if (purchase.planetId !== planetId || purchaseTime > atMilliseconds) continue; + if (!Number.isInteger(purchase.bonusBpsAfter) || purchase.bonusBpsAfter < 0) { + throw new Error('Upgrade purchase bonus must be a non-negative integer.'); + } + const targetLevel = purchase.targetLevel; + if ( + purchaseTime > latestPurchaseAt || + (purchaseTime === latestPurchaseAt && targetLevel > latestTargetLevel) + ) { + latestPurchaseAt = purchaseTime; + latestTargetLevel = targetLevel; + bonusBps = purchase.bonusBpsAfter; + } + } + return bonusBps; +} + +/** Calculates historical production with collection and upgrade events over [from, to). */ +export function calculateHistoricalPlanetProduction(input: { + planet: MineralCollectionPlanet; + planets: readonly MineralCollectionPlanet[]; + purchases: readonly MineralUpgradePurchase[]; + from: Date; + to: Date; + anchor: Date; +}): bigint { + const fromMilliseconds = timestamp(input.from, 'Production start'); + const toMilliseconds = timestamp(input.to, 'Production end'); + if (toMilliseconds < fromMilliseconds) { + throw new Error('Production interval cannot end before it starts.'); + } + if (!input.anchor) throw new Error('Production anchor is required.'); + const activatedAt = timestamp(activationAt(input.planet), `Planet ${input.planet.id} activation`); + const startMilliseconds = Math.max(fromMilliseconds, activatedAt); + if (startMilliseconds >= toMilliseconds) return 0n; + + const group = matchingPlanets(input.planet, input.planets); + const eventTimes = new Set(); + for (const candidate of group) { + const candidateTime = activationAt(candidate).getTime(); + if (candidateTime > startMilliseconds && candidateTime < toMilliseconds) eventTimes.add(candidateTime); + } + for (const purchase of input.purchases) { + const purchaseTime = timestamp(purchase.purchasedAt, 'Upgrade purchase'); + if (purchase.planetId === input.planet.id && purchaseTime > startMilliseconds && purchaseTime < toMilliseconds) { + eventTimes.add(purchaseTime); + } + } + + const orderedEvents = [...eventTimes].sort((left, right) => left - right); + let cursor = new Date(startMilliseconds); + let activeCount = group.filter((candidate) => activationAt(candidate).getTime() <= startMilliseconds).length; + let activeBonusBps = collectionBonusBpsForCount(activeCount); + let activeUpgradeBonusBps = upgradeBonusBpsAt(input.purchases, input.planet.id, startMilliseconds); + let total = 0n; + + for (const eventTime of orderedEvents) { + total += calculateConstantRateSegment({ + rateMicrosPerDay: calculateEffectiveMineralsPerDayMicros( + input.planet.baseMineralsPerDay, + activeBonusBps + activeUpgradeBonusBps, + ), + from: cursor, + to: new Date(eventTime), + anchor: input.anchor, + }); + const eventActivations = group.filter((candidate) => activationAt(candidate).getTime() === eventTime).length; + activeCount += eventActivations; + activeBonusBps = collectionBonusBpsForCount(activeCount); + activeUpgradeBonusBps = upgradeBonusBpsAt(input.purchases, input.planet.id, eventTime); + cursor = new Date(eventTime); + } + + total += calculateConstantRateSegment({ + rateMicrosPerDay: calculateEffectiveMineralsPerDayMicros( + input.planet.baseMineralsPerDay, + activeBonusBps + activeUpgradeBonusBps, + ), + from: cursor, + to: input.to, + anchor: input.anchor, + }); + return total; +} + +/** Sums historical production for one owner's bounded Planet batch. */ +export function calculateHistoricalProduction(input: { + planets: readonly MineralCollectionPlanet[]; + purchases: readonly MineralUpgradePurchase[]; + from: Date; + to: Date; + anchor: Date; +}): bigint { + let total = 0n; + for (const planet of input.planets) { + total += calculateHistoricalPlanetProduction({ ...input, planet }); + } + return total; +} diff --git a/server/api/mineralMutations.test.ts b/server/api/mineralMutations.test.ts new file mode 100644 index 0000000..b90e4be --- /dev/null +++ b/server/api/mineralMutations.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PrismaClient } from './generated/prisma/client.js'; +import { purchasePlanetUpgrade, settleMineralAccount } from './mineralAccounts.js'; + +const OWNER = '0x1111111111111111111111111111111111111111'; +const CUTOVER = new Date('2026-08-20T00:00:00.000Z'); +const PURCHASED_AT = new Date('2026-08-21T00:00:00.000Z'); + +function makePlanet() { + return { + id: 'planet-1', + ownerAddress: OWNER, + planetType: 'Gaia', + baseMineralsPerDay: 1n, + upgradeLevel: 0, + upgradeBonusBps: 0, + generatedAt: CUTOVER, + status: 'READY' as const, + }; +} + +function makePrisma(overrides: { + account?: Record; + existingPurchase?: Record | null; + clockAt?: Date; +} = {}) { + const planet = makePlanet(); + const balanceMicros = typeof overrides.account?.balanceMicros === 'bigint' ? overrides.account.balanceMicros : 500_000n; + const lastSettledAt = overrides.account?.lastSettledAt instanceof Date ? overrides.account.lastSettledAt : CUTOVER; + const account = { + id: 'account-1', + ownerAddress: OWNER, + openingBalanceMicros: 0n, + balanceMicros, + lastSettledAt, + }; + const purchase = overrides.existingPurchase ?? null; + const clockAt = overrides.clockAt ?? PURCHASED_AT; + const tx = { + $queryRaw: vi + .fn() + .mockResolvedValueOnce([{ locked: 1 }]) + .mockResolvedValueOnce([{ locked: 1 }]) + .mockResolvedValueOnce([{ now: clockAt }]) + .mockResolvedValueOnce([{ id: planet.id }]), + mineralEconomyCutover: { + findUnique: vi.fn().mockResolvedValue({ id: 1, cutoverAt: CUTOVER }), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + mineralAccount: { + findUnique: vi.fn().mockResolvedValue(account), + upsert: vi.fn().mockResolvedValue(account), + update: vi.fn().mockImplementation(({ data }: { data: Partial }) => { + Object.assign(account, data); + return account; + }), + }, + backendPlanet: { + findUnique: vi.fn().mockResolvedValue(planet), + findMany: vi.fn().mockResolvedValue([planet]), + update: vi.fn().mockImplementation(({ data }: { data: Partial }) => { + Object.assign(planet, data); + return planet; + }), + }, + planetUpgradePurchase: { + findUnique: vi.fn().mockResolvedValue(purchase), + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn().mockImplementation(({ data }: { data: Record }) => ({ id: 'purchase-1', ...data })), + }, + }; + return { + prisma: { + $transaction: vi.fn(async (callback: (value: typeof tx) => unknown) => callback(tx)), + } as unknown as PrismaClient, + tx, + account, + planet, + }; +} + +describe('mineral upgrade mutations', () => { + it('keeps split account settlement equal to one continuous anchored settlement', async () => { + const fixture = makePrisma({ account: { balanceMicros: 0n } }); + const firstAt = new Date('2026-08-20T12:34:56.789Z'); + const endAt = new Date('2026-08-21T00:00:00.001Z'); + const planet = makePlanet(); + const purchases = [{ planetId: planet.id, targetLevel: 1, bonusBpsAfter: 1_000, purchasedAt: firstAt }]; + const first = await settleMineralAccount({ + prisma: fixture.tx as never, + account: fixture.account, + planets: [planet], + purchases, + settledAt: firstAt, + anchor: CUTOVER, + }); + const second = await settleMineralAccount({ + prisma: fixture.tx as never, + account: first, + planets: [planet], + purchases, + settledAt: endAt, + anchor: CUTOVER, + }); + const whole = makePrisma({ account: { balanceMicros: 0n } }); + const continuous = await settleMineralAccount({ + prisma: whole.tx as never, + account: whole.account, + planets: [planet], + purchases, + settledAt: endAt, + anchor: CUTOVER, + }); + + expect(second.balanceMicros).toBe(continuous.balanceMicros); + expect(second.balanceMicros).toBe(1_047_573n); + }); + + it('leaves account, Planet, and purchase unchanged when funds are insufficient', async () => { + const fixture = makePrisma({ account: { balanceMicros: 0n }, clockAt: CUTOVER }); + + await expect( + purchasePlanetUpgrade(fixture.prisma, { + planetId: 'planet-1', + targetLevel: 1, + cutoverAt: CUTOVER, + }), + ).rejects.toThrow('Insufficient mineral balance'); + + expect(fixture.tx.mineralAccount.update).not.toHaveBeenCalled(); + expect(fixture.tx.backendPlanet.update).not.toHaveBeenCalled(); + expect(fixture.tx.planetUpgradePurchase.create).not.toHaveBeenCalled(); + expect(fixture.account.balanceMicros).toBe(0n); + expect(fixture.planet.upgradeLevel).toBe(0); + }); + + it('returns an existing purchase on retry without debiting again', async () => { + const first = makePrisma(); + const result = await purchasePlanetUpgrade(first.prisma, { + planetId: 'planet-1', + targetLevel: 1, + cutoverAt: CUTOVER, + }); + const persisted = first.tx.planetUpgradePurchase.create.mock.results[0]?.value; + expect(result).toMatchObject({ targetLevel: 1, costMicros: '200000' }); + expect(result).not.toHaveProperty('currentBalanceMicros'); + expect(persisted.purchasedAt).toBe(PURCHASED_AT); + expect(first.tx.mineralAccount.update).toHaveBeenCalledTimes(2); + expect(first.tx.planetUpgradePurchase.create).toHaveBeenCalledOnce(); + + const retry = makePrisma({ + existingPurchase: persisted, + account: { + balanceMicros: first.account.balanceMicros, + lastSettledAt: PURCHASED_AT, + }, + }); + const retryResult = await purchasePlanetUpgrade(retry.prisma, { + planetId: 'planet-1', + targetLevel: 1, + cutoverAt: CUTOVER, + }); + expect(retryResult).toEqual(result); + expect(retryResult).not.toHaveProperty('currentBalanceMicros'); + expect(retry.tx.mineralAccount.update).not.toHaveBeenCalled(); + expect(retry.tx.backendPlanet.update).not.toHaveBeenCalled(); + expect(retry.tx.planetUpgradePurchase.create).not.toHaveBeenCalled(); + }); +}); diff --git a/server/api/mineralUpgrades.test.ts b/server/api/mineralUpgrades.test.ts new file mode 100644 index 0000000..b250b27 --- /dev/null +++ b/server/api/mineralUpgrades.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { calculateUpgradeCostMicros, MINERAL_UPGRADE_CONFIG } from './mineralUpgrades.js'; + +describe('mineral upgrade pricing', () => { + it('keeps the cumulative targets and progressive payback prices exact', () => { + expect(MINERAL_UPGRADE_CONFIG).toEqual([ + { targetLevel: 1, bonusBpsAfter: 1_000, targetPaybackHours: 48 }, + { targetLevel: 2, bonusBpsAfter: 2_500, targetPaybackHours: 60 }, + { targetLevel: 3, bonusBpsAfter: 5_000, targetPaybackHours: 72 }, + ]); + expect(calculateUpgradeCostMicros({ baseMineralsPerDay: 100n, upgradeBonusBps: 0, targetLevel: 1 })).toBe(20_000_000n); + expect(calculateUpgradeCostMicros({ baseMineralsPerDay: 100n, upgradeBonusBps: 1_000, targetLevel: 2 })).toBe(37_500_000n); + expect(calculateUpgradeCostMicros({ baseMineralsPerDay: 100n, upgradeBonusBps: 2_500, targetLevel: 3 })).toBe(75_000_000n); + }); + + it('prices from the actual stored bonus instead of assuming a standard previous level', () => { + expect( + calculateUpgradeCostMicros({ baseMineralsPerDay: 37n, upgradeBonusBps: 275, targetLevel: 2 }), + ).toBe(20_581_250n); + }); + + it('rejects a non-positive incremental bonus', () => { + expect(() => calculateUpgradeCostMicros({ baseMineralsPerDay: 100n, upgradeBonusBps: 2_500, targetLevel: 2 })).toThrow( + 'Upgrade bonus delta must be positive', + ); + }); +}); diff --git a/server/api/mineralUpgrades.ts b/server/api/mineralUpgrades.ts new file mode 100644 index 0000000..a19beda --- /dev/null +++ b/server/api/mineralUpgrades.ts @@ -0,0 +1,62 @@ +import { MINERAL_SCALE } from './mining.js'; + +const BONUS_DENOMINATOR_BPS = 10_000n; + +export type MineralUpgradeConfig = { + targetLevel: 1 | 2 | 3; + bonusBpsAfter: number; + targetPaybackHours: number; +}; + +export const MINERAL_UPGRADE_CONFIG: readonly MineralUpgradeConfig[] = Object.freeze([ + { targetLevel: 1, bonusBpsAfter: 1_000, targetPaybackHours: 48 }, + { targetLevel: 2, bonusBpsAfter: 2_500, targetPaybackHours: 60 }, + { targetLevel: 3, bonusBpsAfter: 5_000, targetPaybackHours: 72 }, +]); + +export const UPGRADE_CONFIG = MINERAL_UPGRADE_CONFIG; + +function assertTargetLevel(targetLevel: number): asserts targetLevel is 1 | 2 | 3 { + if (!Number.isInteger(targetLevel) || targetLevel < 1 || targetLevel > 3) { + throw new Error('Upgrade target level must be an integer between 1 and 3.'); + } +} + +export function getMineralUpgradeConfig(targetLevel: number): MineralUpgradeConfig { + assertTargetLevel(targetLevel); + const config = MINERAL_UPGRADE_CONFIG[targetLevel - 1]; + if (!config) throw new Error(`Upgrade configuration for level ${targetLevel} is missing.`); + return config; +} + +export function calculateUpgradeCostMicros(input: { + baseMineralsPerDay: bigint; + upgradeBonusBps: number; + targetLevel: number; +}): bigint { + if (input.baseMineralsPerDay < 0n) throw new Error('baseMineralsPerDay cannot be negative.'); + if (!Number.isInteger(input.upgradeBonusBps) || input.upgradeBonusBps < 0) { + throw new Error('upgradeBonusBps must be a non-negative integer.'); + } + const config = getMineralUpgradeConfig(input.targetLevel); + const deltaBps = config.bonusBpsAfter - input.upgradeBonusBps; + if (deltaBps <= 0) throw new Error('Upgrade bonus delta must be positive.'); + const incrementalRateMicrosPerDay = + (input.baseMineralsPerDay * MINERAL_SCALE * BigInt(deltaBps)) / BONUS_DENOMINATOR_BPS; + return (incrementalRateMicrosPerDay * BigInt(config.targetPaybackHours)) / 24n; +} + +export function getNextMineralUpgrade(input: { + baseMineralsPerDay: bigint; + upgradeLevel: number; + upgradeBonusBps: number; +}): { targetLevel: number; bonusBpsAfter: number; costMicros: bigint } | null { + if (input.upgradeLevel >= 3) return null; + const targetLevel = input.upgradeLevel + 1; + const config = getMineralUpgradeConfig(targetLevel); + return { + targetLevel, + bonusBpsAfter: config.bonusBpsAfter, + costMicros: calculateUpgradeCostMicros({ ...input, targetLevel }), + }; +} diff --git a/server/api/miningStore.test.ts b/server/api/miningStore.test.ts index 9e67787..a1dfd40 100644 --- a/server/api/miningStore.test.ts +++ b/server/api/miningStore.test.ts @@ -1,8 +1,94 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { PrismaClient } from './generated/prisma/client.js'; -import { getBackendPlanetMiningSnapshot, getBackendWalletMiningSnapshot } from './miningStore.js'; +import { getBackendWalletMiningSnapshot } from './miningStore.js'; +import { getCurrentLeaderboard } from './leaderboardStore.js'; describe('backend Planet mining snapshots', () => { + it('rechecks a staged cutover after entering the PostgreSQL transaction', async () => { + const cutoverAt = new Date('2026-08-20T00:00:00.000Z'); + const rootClock = vi.fn().mockResolvedValue([{ now: new Date(cutoverAt.getTime() - 1) }]); + const transactionClock = vi.fn().mockResolvedValue([{ now: cutoverAt }]); + const prisma = { + $queryRaw: rootClock, + mineralEconomyCutover: { findUnique: vi.fn().mockResolvedValue(null) }, + backendPlanet: { findMany: vi.fn().mockResolvedValue([]) }, + $transaction: vi.fn(async (callback: (client: unknown) => unknown) => callback({ + $queryRaw: transactionClock, + mineralEconomyCutover: { findUnique: vi.fn().mockResolvedValue(null) }, + })), + } as unknown as PrismaClient; + + await expect( + getBackendWalletMiningSnapshot(prisma, '0x0000000000000000000000000000000000000001', new Date(), { + mineralEconomyCutoverAt: cutoverAt, + }), + ).rejects.toThrow('configured mineral economy cutover is not persisted'); + }); + + it('keeps wallet balance and live leaderboard score equal at PostgreSQL asOf', async () => { + const owner = '0x0000000000000000000000000000000000000001'; + const cutoverAt = new Date('2026-08-20T00:00:00.000Z'); + const databaseNow = new Date('2026-08-21T00:00:00.000Z'); + const requestedNow = new Date('2026-08-30T00:00:00.000Z'); + const planet = { + id: 'planet-invariant', + ownerAddress: owner, + planetType: 'Gaia', + planetName: 'Invariant Planet', + seed: `0x${'11'.repeat(32)}`, + traitsHash: `0x${'22'.repeat(32)}`, + generatorVersion: 1, + terrain: 'Plains', + rarity: 'Common', + satelliteCount: 0, + hasRing: false, + baseMineralsPerDay: 100n, + upgradeLevel: 0, + upgradeBonusBps: 0, + generatedAt: cutoverAt, + }; + const account = { + ownerAddress: owner, + openingBalanceMicros: 100_000_000n, + balanceMicros: 100_000_000n, + lastSettledAt: cutoverAt, + }; + const walletTransaction = { + $queryRaw: vi.fn().mockResolvedValue([{ now: databaseNow }]), + mineralAccount: { findUnique: vi.fn().mockResolvedValue(account) }, + backendPlanet: { findMany: vi.fn().mockResolvedValue([planet]) }, + planetUpgradePurchase: { findMany: vi.fn().mockResolvedValue([]) }, + }; + const walletPrisma = { + $transaction: vi.fn(async (callback: (value: typeof walletTransaction) => unknown) => callback(walletTransaction)), + } as unknown as PrismaClient; + const leaderboardTransaction = { + $queryRaw: vi.fn().mockResolvedValue([{ now: databaseNow }]), + backendPlanet: { findMany: vi.fn().mockResolvedValue([planet]) }, + mineralAccount: { findMany: vi.fn().mockResolvedValue([account]) }, + planetUpgradePurchase: { findMany: vi.fn().mockResolvedValue([]) }, + }; + const leaderboardPrisma = { + $transaction: vi.fn(async (callback: (value: typeof leaderboardTransaction) => unknown) => callback(leaderboardTransaction)), + } as unknown as PrismaClient; + + const wallet = await getBackendWalletMiningSnapshot(walletPrisma, owner, requestedNow, { + mineralEconomyCutoverAt: cutoverAt, + }); + const leaderboard = await getCurrentLeaderboard( + leaderboardPrisma, + requestedNow, + { offset: 0, limit: 50 }, + { mineralEconomyCutoverAt: cutoverAt }, + ); + + expect(wallet.asOf).toBe(databaseNow.toISOString()); + expect(leaderboard.asOf).toBe(databaseNow); + expect(wallet.currentBalanceMicros).toBe('200000000'); + expect(leaderboard.rows[0]?.scoreMicros).toBe(200_000_000n); + expect(BigInt(wallet.currentBalanceMicros)).toBe(leaderboard.rows[0]?.scoreMicros); + }); + it('calculates lifetime production from backend-generatedAt', async () => { const prisma = { backendPlanet: { @@ -14,7 +100,8 @@ describe('backend Planet mining snapshots', () => { } as unknown as PrismaClient; const snapshot = await getBackendWalletMiningSnapshot(prisma, '0x0000000000000000000000000000000000000001', new Date('2026-08-10T00:00:01.000Z')); expect(snapshot.ownedPlanetCount).toBe(2); - expect(snapshot.earnedMicros).toBe('172803000000'); + expect((snapshot as { earnedMicros: string }).earnedMicros).toBe('172803000000'); + expect(snapshot).toMatchObject({ currentBalanceMicros: '172803000000', upgradesEnabled: false }); expect(snapshot.planets[0]?.planetId).toBe('planet-1'); expect(snapshot.planets[0]).toMatchObject({ planetType: 'Nebula', sameTypeCount: 2, collectionBonusBps: 0 }); }); @@ -49,21 +136,219 @@ describe('backend Planet mining snapshots', () => { expect(snapshot.planets.find((planet) => planet.planetId === 'planet-4')).toMatchObject({ sameTypeCount: 1, collectionBonusBps: 0, effectiveMineralsPerDayMicros: '100000000' }); }); - it('reads one backend Planet by id with its wallet collection context', async () => { + it('reads a virtual post-cutover account without creating or settling it', async () => { + const mineralAccount = { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn(), + update: vi.fn(), + upsert: vi.fn(), + }; + const transaction = { + backendPlanet: { + findMany: vi.fn().mockResolvedValue([ + { + id: 'planet-v2', + ownerAddress: '0x0000000000000000000000000000000000000001', + planetType: 'Nebula', + planetName: 'V2 Planet', + seed: `0x${'11'.repeat(32)}`, + traitsHash: `0x${'22'.repeat(32)}`, + generatorVersion: 1, + terrain: 'Plains', + rarity: 'Common', + satelliteCount: 0, + hasRing: false, + baseMineralsPerDay: 1n, + upgradeLevel: 0, + upgradeBonusBps: 0, + generatedAt: new Date('2026-08-19T00:00:00.000Z'), + }, + ]), + }, + mineralAccount, + planetUpgradePurchase: { findMany: vi.fn().mockResolvedValue([]) }, + }; const prisma = { + $transaction: vi.fn(async (callback: (client: typeof transaction) => unknown) => callback(transaction)), + } as unknown as PrismaClient; + + const snapshot = await getBackendWalletMiningSnapshot( + prisma, + '0x0000000000000000000000000000000000000001', + new Date('2026-08-21T00:00:00.000Z'), + { + mineralEconomyCutoverAt: new Date('2026-08-20T00:00:00.000Z'), + mineralUpgradesEnabled: true, + }, + ); + + expect(snapshot).toMatchObject({ + ownerAddress: '0x0000000000000000000000000000000000000001', + currentBalanceMicros: '2000000', + effectiveMineralsPerDayMicros: '1000000', + ownedPlanetCount: 1, + upgradesEnabled: false, + }); + expect(snapshot).not.toHaveProperty('earnedMicros'); + expect(snapshot.planets[0]).toMatchObject({ + planetId: 'planet-v2', + upgradeLevel: 0, + upgradeBonusBps: 0, + nextUpgrade: { targetLevel: 1, bonusBpsAfter: 1000, costMicros: '200000' }, + }); + expect(mineralAccount.findUnique).toHaveBeenCalledOnce(); + expect(mineralAccount.create).not.toHaveBeenCalled(); + expect(mineralAccount.update).not.toHaveBeenCalled(); + expect(mineralAccount.upsert).not.toHaveBeenCalled(); + }); + + it('uses one repeatable-read snapshot and advances asOf when the account is already settled later', async () => { + const cutoverAt = new Date('2026-08-20T00:00:00.000Z'); + const requestedAsOf = new Date('2026-08-21T00:00:00.000Z'); + const authoritativeAsOf = new Date('2026-08-22T00:00:00.000Z'); + const ownerAddress = '0x0000000000000000000000000000000000000001'; + const planet = { + id: 'planet-snapshot', + ownerAddress, + planetType: 'Nebula', + planetName: 'Snapshot Planet', + seed: `0x${'11'.repeat(32)}`, + traitsHash: `0x${'22'.repeat(32)}`, + generatorVersion: 1, + terrain: 'Plains', + rarity: 'Common', + satelliteCount: 0, + hasRing: false, + baseMineralsPerDay: 1n, + upgradeLevel: 0, + upgradeBonusBps: 0, + generatedAt: cutoverAt, + }; + const account = { + ownerAddress, + balanceMicros: 123n, + lastSettledAt: authoritativeAsOf, + }; + const rootReads = { + backendPlanet: { findMany: vi.fn().mockResolvedValue([planet]) }, + mineralAccount: { findUnique: vi.fn().mockResolvedValue(account) }, + planetUpgradePurchase: { findMany: vi.fn().mockResolvedValue([]) }, + }; + const transactionWrites = { + backendPlanet: { create: vi.fn(), update: vi.fn() }, + mineralAccount: { create: vi.fn(), update: vi.fn(), upsert: vi.fn() }, + planetUpgradePurchase: { create: vi.fn(), update: vi.fn() }, + }; + const transaction = { backendPlanet: { - findFirst: async () => ({ - id: 'planet-7', ownerAddress: '0x0000000000000000000000000000000000000002', - planetType: 'Gaia', baseMineralsPerDay: 10n, generatedAt: new Date('2026-08-10T00:00:00.000Z'), + ...transactionWrites.backendPlanet, + findMany: vi.fn().mockResolvedValue([planet]), + }, + mineralAccount: { + ...transactionWrites.mineralAccount, + findUnique: vi.fn().mockResolvedValue(account), + }, + planetUpgradePurchase: { + ...transactionWrites.planetUpgradePurchase, + findMany: vi.fn().mockResolvedValue([]), + }, + }; + let transactionOptions: unknown; + const prisma = { + ...rootReads, + $transaction: vi.fn(async (callback: (client: typeof transaction) => unknown, options: unknown) => { + transactionOptions = options; + return callback(transaction); + }), + } as unknown as PrismaClient; + + const snapshot = await getBackendWalletMiningSnapshot(prisma, ownerAddress, requestedAsOf, { + mineralEconomyCutoverAt: cutoverAt, + mineralUpgradesEnabled: true, + }); + + expect(snapshot).toMatchObject({ + asOf: authoritativeAsOf.toISOString(), + currentBalanceMicros: '123', + ownedPlanetCount: 1, + }); + expect(transactionOptions).toMatchObject({ isolationLevel: 'RepeatableRead' }); + expect(prisma.$transaction).toHaveBeenCalledOnce(); + expect(rootReads.backendPlanet.findMany).not.toHaveBeenCalled(); + expect(rootReads.mineralAccount.findUnique).not.toHaveBeenCalled(); + expect(rootReads.planetUpgradePurchase.findMany).not.toHaveBeenCalled(); + expect(transaction.backendPlanet.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ generatedAt: { lte: authoritativeAsOf } }) }), + ); + expect(transaction.planetUpgradePurchase.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ purchasedAt: { lte: authoritativeAsOf } }) }), + ); + expect(transactionWrites.backendPlanet.create).not.toHaveBeenCalled(); + expect(transactionWrites.backendPlanet.update).not.toHaveBeenCalled(); + expect(transactionWrites.mineralAccount.create).not.toHaveBeenCalled(); + expect(transactionWrites.mineralAccount.update).not.toHaveBeenCalled(); + expect(transactionWrites.mineralAccount.upsert).not.toHaveBeenCalled(); + expect(transactionWrites.planetUpgradePurchase.create).not.toHaveBeenCalled(); + expect(transactionWrites.planetUpgradePurchase.update).not.toHaveBeenCalled(); + }); + + it('uses purchase history for post-cutover production and current upgrade rate', async () => { + const cutoverAt = new Date('2026-08-20T00:00:00.000Z'); + const purchaseAt = new Date('2026-08-20T12:34:56.789Z'); + const asOf = new Date('2026-08-21T00:00:00.001Z'); + const transaction = { + backendPlanet: { + findMany: vi.fn().mockResolvedValue([ + { + id: 'planet-history', + ownerAddress: '0x0000000000000000000000000000000000000001', + planetType: 'Nebula', + planetName: 'History Planet', + seed: `0x${'11'.repeat(32)}`, + traitsHash: `0x${'22'.repeat(32)}`, + generatorVersion: 1, + terrain: 'Plains', + rarity: 'Common', + satelliteCount: 0, + hasRing: false, + baseMineralsPerDay: 1n, + upgradeLevel: 2, + upgradeBonusBps: 2_500, + generatedAt: cutoverAt, + }, + ]), + }, + mineralAccount: { + findUnique: vi.fn().mockResolvedValue({ + ownerAddress: '0x0000000000000000000000000000000000000001', + balanceMicros: 0n, + lastSettledAt: cutoverAt, }), - findMany: async () => [ - { id: 'planet-7', ownerAddress: '0x0000000000000000000000000000000000000002', planetType: 'Gaia', baseMineralsPerDay: 10n, generatedAt: new Date('2026-08-10T00:00:00.000Z') }, - { id: 'planet-8', ownerAddress: '0x0000000000000000000000000000000000000002', planetType: 'Gaia', baseMineralsPerDay: 10n, generatedAt: new Date('2026-08-11T00:00:00.000Z') }, - { id: 'planet-9', ownerAddress: '0x0000000000000000000000000000000000000002', planetType: 'Gaia', baseMineralsPerDay: 10n, generatedAt: new Date('2026-08-12T00:00:00.000Z') }, - ], }, + planetUpgradePurchase: { + findMany: vi.fn().mockResolvedValue([ + { planetId: 'planet-history', targetLevel: 2, bonusBpsAfter: 2_500, purchasedAt: purchaseAt }, + { planetId: 'planet-history', targetLevel: 1, bonusBpsAfter: 1_000, purchasedAt: purchaseAt }, + ]), + }, + }; + const prisma = { + $transaction: vi.fn(async (callback: (client: typeof transaction) => unknown) => callback(transaction)), } as unknown as PrismaClient; - const snapshot = await getBackendPlanetMiningSnapshot(prisma, 'planet-7', new Date('2026-08-13T00:00:00.000Z')); - expect(snapshot).toMatchObject({ planetId: 'planet-7', earnedMicros: '30500000', planetType: 'Gaia', sameTypeCount: 3, collectionBonusBps: 500, effectiveMineralsPerDayMicros: '10500000' }); + + const snapshot = await getBackendWalletMiningSnapshot( + prisma, + '0x0000000000000000000000000000000000000001', + asOf, + { mineralEconomyCutoverAt: cutoverAt, mineralUpgradesEnabled: true }, + ); + + expect((snapshot as { currentBalanceMicros: string }).currentBalanceMicros).toBe('1118933'); + expect(snapshot.planets[0]).toMatchObject({ + effectiveMineralsPerDayMicros: '1250000', + earnedMicros: '1118933', + upgradeLevel: 2, + upgradeBonusBps: 2500, + }); }); }); diff --git a/server/api/miningStore.ts b/server/api/miningStore.ts index eb66ab9..d41062c 100644 --- a/server/api/miningStore.ts +++ b/server/api/miningStore.ts @@ -1,45 +1,205 @@ -import type { PrismaClient } from './generated/prisma/client.js'; +import { Prisma, type PrismaClient } from './generated/prisma/client.js'; import { + calculateEffectiveMineralsPerDayMicros, calculateCollectionMining, type CollectionMiningPlanet, } from './collectionMining.js'; +import { + calculateHistoricalPlanetProduction, + collectionBonusBpsAt, + type MineralCollectionPlanet, + type MineralUpgradePurchase, +} from './mineralEconomy.js'; +import { + calculateCurrentMineralBalanceMicros, + calculateV1WalletOpeningBalance, + getPostgresClockTimestamp, + resolveMineralEconomyForOperation, +} from './mineralAccounts.js'; +import { getNextMineralUpgrade } from './mineralUpgrades.js'; type BackendMiningPlanetRow = CollectionMiningPlanet; -export async function getBackendPlanetMiningSnapshot( - prisma: PrismaClient, - planetId: string, - now: Date, -) { - const planet = await prisma.backendPlanet.findFirst({ - where: { id: planetId, status: 'READY' }, - select: { id: true, ownerAddress: true, planetType: true, baseMineralsPerDay: true, generatedAt: true }, - }); - if (!planet) return undefined; - const planets = await prisma.backendPlanet.findMany({ - where: { ownerAddress: planet.ownerAddress, status: 'READY' }, - select: { id: true, ownerAddress: true, planetType: true, baseMineralsPerDay: true, generatedAt: true }, - }); - const calculated = calculateCollectionMining({ planets: planets as BackendMiningPlanetRow[], asOf: now }).get(planet.id); - if (!calculated) return undefined; - return { - planetId: planet.id, - ownerAddress: planet.ownerAddress, - baseMineralsPerDay: planet.baseMineralsPerDay.toString(), - planetType: calculated.planetType, - sameTypeCount: calculated.sameTypeCount, - collectionBonusBps: calculated.collectionBonusBps, - effectiveMineralsPerDayMicros: calculated.effectiveMineralsPerDayMicros.toString(), - earnedMicros: calculated.earnedMicros.toString(), - activeSince: planet.generatedAt.toISOString(), - }; -} +export type WalletMiningEconomyOptions = { + mineralEconomyCutoverAt?: Date | null; + mineralUpgradesEnabled?: boolean; +}; export async function getBackendWalletMiningSnapshot( prisma: PrismaClient, ownerAddress: string, now: Date, + options: WalletMiningEconomyOptions = {}, ) { + const resolution = await resolveMineralEconomyForOperation( + prisma, + options.mineralEconomyCutoverAt, + ); + if (resolution.state === 'V1') return getV1WalletMiningSnapshot(prisma, ownerAddress, now); + + return prisma.$transaction( + async (transaction) => { + const hasPostgresClock = typeof transaction.$queryRaw === 'function'; + let asOf = hasPostgresClock ? await getPostgresClockTimestamp(transaction) : now; + const transactionResolution = await resolveMineralEconomyForOperation( + transaction, + options.mineralEconomyCutoverAt, + asOf, + ); + // Unit-only Prisma doubles lack PostgreSQL's clock; production clients require a persisted row. + const cutoverAt = transactionResolution.state === 'V2' || !hasPostgresClock || !transaction.mineralEconomyCutover + ? transactionResolution.cutoverAt + : null; + if (!cutoverAt) { + return getV1WalletMiningSnapshot( + transaction as unknown as PrismaClient, + ownerAddress, + asOf, + ); + } + const account = await transaction.mineralAccount.findUnique({ + where: { ownerAddress: ownerAddress.toLowerCase() }, + }); + if (!hasPostgresClock && account?.lastSettledAt && account.lastSettledAt > asOf) { + asOf = account.lastSettledAt; + } + if (asOf < cutoverAt) { + return getV1WalletMiningSnapshot( + transaction as unknown as PrismaClient, + ownerAddress, + asOf, + ); + } + const planets = await transaction.backendPlanet.findMany({ + where: { ownerAddress: ownerAddress.toLowerCase(), status: 'READY', generatedAt: { lte: asOf } }, + select: { + id: true, + ownerAddress: true, + planetType: true, + planetName: true, + seed: true, + traitsHash: true, + generatorVersion: true, + terrain: true, + rarity: true, + satelliteCount: true, + hasRing: true, + baseMineralsPerDay: true, + upgradeLevel: true, + upgradeBonusBps: true, + generatedAt: true, + }, + orderBy: [{ generatedAt: 'desc' }, { id: 'asc' }], + }); + const activePlanets = planets.filter((planet) => planet.generatedAt.getTime() <= asOf.getTime()); + const economyPlanets = activePlanets as unknown as MineralCollectionPlanet[]; + const purchaseRows = await transaction.planetUpgradePurchase.findMany({ + where: { walletAddress: ownerAddress.toLowerCase(), purchasedAt: { lte: asOf } }, + orderBy: [{ purchasedAt: 'asc' }, { id: 'asc' }], + select: { planetId: true, targetLevel: true, bonusBpsAfter: true, purchasedAt: true }, + }); + const purchases = purchaseRows.filter((purchase) => purchase.purchasedAt.getTime() <= asOf.getTime()) as MineralUpgradePurchase[]; + const openingPlanets = economyPlanets.filter((planet) => planet.generatedAt && planet.generatedAt <= cutoverAt); + const openingBalanceMicros = calculateV1WalletOpeningBalance( + openingPlanets.map((planet) => ({ + id: planet.id, + ownerAddress: planet.ownerAddress, + planetType: planet.planetType, + baseMineralsPerDay: planet.baseMineralsPerDay, + generatedAt: planet.generatedAt as Date, + })), + cutoverAt, + ); + const currentBalanceMicros = calculateCurrentMineralBalanceMicros({ + account, + openingBalanceMicros, + cutoverAt, + asOf, + planets: economyPlanets, + purchases, + }); + const preCutoverCalculations = calculateCollectionMining({ + planets: openingPlanets as BackendMiningPlanetRow[], + asOf: cutoverAt, + }); + let effectiveMineralsPerDayMicros = 0n; + const snapshots = activePlanets.map((planet) => { + const collectionBonusBps = collectionBonusBpsAt(economyPlanets, asOf, planet); + const sameTypeCount = economyPlanets.filter( + (candidate) => + candidate.ownerAddress.toLowerCase() === planet.ownerAddress.toLowerCase() && + candidate.planetType === planet.planetType && + candidate.generatedAt && candidate.generatedAt.getTime() <= asOf.getTime(), + ).length; + const upgradeBonusBps = planet.upgradeBonusBps; + const effectiveRate = calculateEffectiveMineralsPerDayMicros( + planet.baseMineralsPerDay, + collectionBonusBps + upgradeBonusBps, + ); + effectiveMineralsPerDayMicros += effectiveRate; + const preCutoverEarned = preCutoverCalculations.get(planet.id)?.earnedMicros ?? 0n; + const postCutoverEarned = calculateHistoricalPlanetProduction({ + planet, + planets: economyPlanets, + purchases, + from: cutoverAt, + to: asOf, + anchor: cutoverAt, + }); + let nextUpgrade = null; + try { + nextUpgrade = getNextMineralUpgrade({ + baseMineralsPerDay: planet.baseMineralsPerDay, + upgradeLevel: planet.upgradeLevel, + upgradeBonusBps: planet.upgradeBonusBps, + }); + } catch (error) { + if (!(error instanceof Error) || !error.message.includes('bonus delta')) throw error; + } + return { + planetId: planet.id, + planetName: planet.planetName, + seed: planet.seed, + traitsHash: planet.traitsHash, + generatorVersion: planet.generatorVersion, + terrain: planet.terrain, + rarity: planet.rarity, + satelliteCount: planet.satelliteCount, + hasRing: planet.hasRing, + baseMineralsPerDay: planet.baseMineralsPerDay.toString(), + planetType: planet.planetType, + sameTypeCount, + collectionBonusBps, + upgradeLevel: planet.upgradeLevel, + upgradeBonusBps: planet.upgradeBonusBps, + effectiveMineralsPerDayMicros: effectiveRate.toString(), + earnedMicros: (preCutoverEarned + postCutoverEarned).toString(), + activeSince: planet.generatedAt.toISOString(), + nextUpgrade: nextUpgrade + ? { + targetLevel: nextUpgrade.targetLevel, + bonusBpsAfter: nextUpgrade.bonusBpsAfter, + costMicros: nextUpgrade.costMicros.toString(), + } + : null, + }; + }); + return { + ownerAddress: ownerAddress.toLowerCase(), + asOf: asOf.toISOString(), + ownedPlanetCount: snapshots.length, + currentBalanceMicros: currentBalanceMicros.toString(), + effectiveMineralsPerDayMicros: effectiveMineralsPerDayMicros.toString(), + // ponytail: expose upgrades only after the public route gains owner-bound authentication. + upgradesEnabled: false, + planets: snapshots, + }; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); +} + +async function getV1WalletMiningSnapshot(prisma: PrismaClient, ownerAddress: string, now: Date) { const planets = await prisma.backendPlanet.findMany({ where: { ownerAddress: ownerAddress.toLowerCase(), status: 'READY' }, select: { id: true, ownerAddress: true, planetType: true, baseMineralsPerDay: true, generatedAt: true }, @@ -69,7 +229,9 @@ export async function getBackendWalletMiningSnapshot( asOf: now.toISOString(), ownedPlanetCount: snapshots.length, earnedMicros: earnedMicros.toString(), + currentBalanceMicros: earnedMicros.toString(), effectiveMineralsPerDayMicros: effectiveMineralsPerDayMicros.toString(), + upgradesEnabled: false, planets: snapshots, }; } diff --git a/server/api/prismaBackendPlanetStore.test.ts b/server/api/prismaBackendPlanetStore.test.ts index 9914bfd..05d0f53 100644 --- a/server/api/prismaBackendPlanetStore.test.ts +++ b/server/api/prismaBackendPlanetStore.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; +import { keccak256 } from 'viem'; +import { GENERATOR_VERSION } from '@megaplanets/planet-generator'; import type { PrismaClient } from './generated/prisma/client.js'; -import { PrismaBackendPlanetStore } from './backendPlanet.js'; +import { deriveBackendPlanet, PrismaBackendPlanetStore } from './backendPlanet.js'; import type { MegasteraProof } from './eligibility.js'; const proof = { @@ -46,6 +48,404 @@ describe('PrismaBackendPlanetStore', () => { await expect(store.generatePlanet(proof)).rejects.toThrow('conflicts with persisted ticket'); }); + it('fails closed instead of repairing a READY row from an unsupported generator version', async () => { + const persistedTicket = { + id: 'ticket-row', + ticketId: { toFixed: () => '1' }, + drawingId: { toFixed: () => '1' }, + recipient: proof.recipient, + bonusBall: proof.bonusBall, + normals: proof.normals, + originTxHash: proof.originTxHash, + logIndex: 0, + purchasedAt: new Date('2026-08-13T11:00:00.000Z'), + }; + const existingPlanet = { + id: 'planet-row', + chainId: 8453, + ticketId: { toFixed: () => '1' }, + ownerAddress: proof.recipient, + planetName: 'Existing Planet', + seed: `0x${'11'.repeat(32)}`, + traitsHash: `0x${'22'.repeat(32)}`, + generatorVersion: 999, + planetType: 'Gaia', + terrain: 'Plains', + rarity: 'Common', + satelliteCount: 0, + hasRing: false, + baseMineralsPerDay: 1n, + generatedAt: new Date('2026-08-13T12:00:00.000Z'), + status: 'READY' as const, + gifData: null, + gifHash: null, + ticketPurchase: persistedTicket, + }; + const update = vi.fn().mockImplementation(({ data }: { data: Record }) => ({ + ...existingPlanet, + ...data, + ticketPurchase: persistedTicket, + })); + const prisma = { + ticketPurchase: { findUnique: vi.fn().mockResolvedValue(persistedTicket) }, + backendPlanet: { findUnique: vi.fn().mockResolvedValue(existingPlanet), update }, + } as unknown as PrismaClient; + + await expect(new PrismaBackendPlanetStore(prisma).generatePlanet(proof)).rejects.toThrow( + 'generator version', + ); + expect(update).not.toHaveBeenCalled(); + }); + + it('fails closed instead of repairing a READY row with a mismatched GIF hash', async () => { + const draft = deriveBackendPlanet(proof, new Date('2026-08-13T12:00:00.000Z')); + const persistedTicket = { + id: 'ticket-row', + ticketId: { toFixed: () => '1' }, + drawingId: { toFixed: () => '1' }, + recipient: proof.recipient, + bonusBall: proof.bonusBall, + normals: proof.normals, + originTxHash: proof.originTxHash, + logIndex: 0, + purchasedAt: new Date('2026-08-13T11:00:00.000Z'), + }; + const existingPlanet = { + id: 'planet-row', + chainId: draft.chainId, + ticketId: { toFixed: () => draft.ticketId }, + ownerAddress: draft.ownerAddress, + planetName: draft.name, + seed: draft.seed, + traitsHash: draft.traitsHash, + generatorVersion: draft.generatorVersion, + planetType: draft.planetType, + terrain: draft.terrain, + rarity: draft.rarity, + satelliteCount: draft.satelliteCount, + hasRing: draft.hasRing, + baseMineralsPerDay: BigInt(draft.baseMineralsPerDay), + generatedAt: new Date(draft.generatedAt), + status: 'READY' as const, + gifData: Buffer.from('corrupt'), + gifHash: draft.gifHash, + ticketPurchase: persistedTicket, + }; + const update = vi.fn(); + const prisma = { + ticketPurchase: { findUnique: vi.fn().mockResolvedValue(persistedTicket) }, + backendPlanet: { findUnique: vi.fn().mockResolvedValue(existingPlanet), update }, + } as unknown as PrismaClient; + + await expect(new PrismaBackendPlanetStore(prisma).generatePlanet(proof)).rejects.toThrow( + 'GIF hash', + ); + expect(update).not.toHaveBeenCalled(); + }); + + it('does not overwrite a non-READY row after a concurrent winner is committed', async () => { + const draft = deriveBackendPlanet(proof, new Date('2026-08-13T12:00:00.000Z')); + const persistedTicket = { + id: 'ticket-row', + ticketId: { toFixed: () => '1' }, + drawingId: { toFixed: () => '1' }, + recipient: proof.recipient, + bonusBall: proof.bonusBall, + normals: proof.normals, + originTxHash: proof.originTxHash, + logIndex: 0, + purchasedAt: new Date('2026-08-13T11:00:00.000Z'), + }; + const failed = { + id: 'planet-row', + chainId: draft.chainId, + ticketId: { toFixed: () => draft.ticketId }, + ownerAddress: draft.ownerAddress, + planetName: draft.name, + seed: draft.seed, + traitsHash: draft.traitsHash, + generatorVersion: draft.generatorVersion, + planetType: draft.planetType, + terrain: draft.terrain, + rarity: draft.rarity, + satelliteCount: draft.satelliteCount, + hasRing: draft.hasRing, + baseMineralsPerDay: BigInt(draft.baseMineralsPerDay), + generatedAt: new Date(draft.generatedAt), + status: 'FAILED' as const, + gifData: null, + gifHash: null, + ticketPurchase: persistedTicket, + }; + const winner = { + ...failed, + status: 'READY' as const, + gifData: Buffer.from(draft.gifData), + gifHash: draft.gifHash, + }; + const updateMany = vi.fn().mockResolvedValue({ count: 0 }); + const prisma = { + ticketPurchase: { findUnique: vi.fn().mockResolvedValue(persistedTicket) }, + backendPlanet: { + findUnique: vi.fn().mockResolvedValueOnce(failed).mockResolvedValueOnce(winner), + updateMany, + }, + } as unknown as PrismaClient; + + await expect(new PrismaBackendPlanetStore(prisma).generatePlanet(proof)).resolves.toMatchObject({ + planetId: 'planet-row', + status: 'READY', + }); + expect(updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'planet-row', status: { not: 'READY' } }, + })); + }); + + it('repairs a READY Planet without moving its persisted generation time', async () => { + const generatedAt = new Date('2026-08-13T12:00:00.000Z'); + const draft = deriveBackendPlanet(proof, generatedAt); + const persistedTicket = { + id: 'ticket-row', + ticketId: { toFixed: () => '1' }, + drawingId: { toFixed: () => '1' }, + recipient: proof.recipient, + bonusBall: proof.bonusBall, + normals: proof.normals, + originTxHash: proof.originTxHash, + logIndex: 0, + purchasedAt: new Date('2026-08-13T11:00:00.000Z'), + }; + const existingPlanet = { + id: 'planet-row', + chainId: draft.chainId, + ticketId: { toFixed: () => draft.ticketId }, + ownerAddress: draft.ownerAddress, + planetName: draft.name, + seed: draft.seed, + traitsHash: draft.traitsHash, + generatorVersion: draft.generatorVersion, + planetType: draft.planetType, + terrain: draft.terrain, + rarity: draft.rarity, + satelliteCount: draft.satelliteCount, + hasRing: draft.hasRing, + baseMineralsPerDay: BigInt(draft.baseMineralsPerDay), + generatedAt, + status: 'READY' as const, + gifData: null, + gifHash: null, + ticketPurchase: persistedTicket, + }; + const update = vi.fn().mockImplementation(({ data }: { data: Record }) => ({ + ...existingPlanet, + ...data, + ticketId: existingPlanet.ticketId, + ticketPurchase: persistedTicket, + })); + const prisma = { + ticketPurchase: { findUnique: vi.fn().mockResolvedValue(persistedTicket) }, + backendPlanet: { findUnique: vi.fn().mockResolvedValue(existingPlanet), update }, + } as unknown as PrismaClient; + const store = new PrismaBackendPlanetStore(prisma, () => new Date('2026-08-14T12:00:00.000Z')); + + const repaired = await store.generatePlanet(proof); + + expect(repaired.generatedAt).toBe(generatedAt.toISOString()); + expect(update).toHaveBeenCalledWith(expect.objectContaining({ + data: { gifData: expect.anything(), gifHash: expect.anything(), generationError: null }, + })); + expect(update.mock.calls[0]?.[0].data).not.toHaveProperty('generatedAt'); + expect(update.mock.calls[0]?.[0].data).not.toHaveProperty('planetName'); + }); + + it('reloads a row that became READY before the pre-cutover fallback', async () => { + const cutoverAt = new Date('2026-08-20T00:00:00.000Z'); + const effectiveAt = new Date('2026-08-19T00:00:00.000Z'); + const generatedAt = new Date('2026-08-18T12:00:00.000Z'); + const persistedTicket = { + id: 'ticket-row', + ticketId: { toFixed: () => '1' }, + drawingId: { toFixed: () => '1' }, + recipient: proof.recipient, + bonusBall: proof.bonusBall, + normals: proof.normals, + originTxHash: proof.originTxHash, + logIndex: 0, + purchasedAt: new Date('2026-08-18T11:00:00.000Z'), + }; + const failed = { + id: 'planet-row', + chainId: 8453, + ticketId: { toFixed: () => '1' }, + ownerAddress: proof.recipient, + planetName: 'Failed Planet', + seed: `0x${'11'.repeat(32)}`, + traitsHash: `0x${'22'.repeat(32)}`, + generatorVersion: GENERATOR_VERSION, + planetType: 'Gaia', + terrain: 'Plains', + rarity: 'Common', + satelliteCount: 0, + hasRing: false, + baseMineralsPerDay: 1n, + generatedAt, + status: 'FAILED' as const, + gifData: null, + gifHash: null, + ticketPurchase: persistedTicket, + }; + const ready = { + ...failed, + planetName: 'Ready Planet', + generatedAt, + status: 'READY' as const, + gifData: Buffer.from('gif'), + gifHash: keccak256(Buffer.from('gif')), + }; + const transaction = { + mineralEconomyCutover: { + findUnique: vi.fn().mockResolvedValue({ id: 1, cutoverAt }), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + $queryRaw: vi.fn().mockResolvedValue([{ now: effectiveAt }]), + mineralAccount: { + findUnique: vi.fn().mockResolvedValue({ + ownerAddress: proof.recipient.toLowerCase(), + balanceMicros: 0n, + lastSettledAt: cutoverAt, + }), + upsert: vi.fn().mockResolvedValue({ + ownerAddress: proof.recipient.toLowerCase(), + balanceMicros: 0n, + lastSettledAt: cutoverAt, + }), + }, + backendPlanet: { findUnique: vi.fn().mockResolvedValue(failed) }, + }; + let outerFindCalls = 0; + const update = vi.fn().mockImplementation(({ data }: { data: Record }) => ({ + ...failed, + ...data, + ticketId: failed.ticketId, + ticketPurchase: persistedTicket, + })); + const prisma = { + ticketPurchase: { findUnique: vi.fn().mockResolvedValue(persistedTicket) }, + backendPlanet: { + findUnique: vi.fn().mockImplementation(async () => { + outerFindCalls += 1; + return outerFindCalls === 1 ? failed : ready; + }), + update, + }, + $transaction: vi.fn(async (callback: (value: typeof transaction) => unknown) => callback(transaction)), + } as unknown as PrismaClient; + const store = new PrismaBackendPlanetStore(prisma, () => effectiveAt, cutoverAt); + + const result = await store.generatePlanet(proof); + + expect(result.generatedAt).toBe(generatedAt.toISOString()); + expect(result.gifHash).toBe(ready.gifHash); + expect(prisma.backendPlanet.findUnique).toHaveBeenCalledTimes(2); + expect(update).not.toHaveBeenCalled(); + }); + + it('does not overwrite a row that becomes READY after the fallback read', async () => { + const cutoverAt = new Date('2026-08-20T00:00:00.000Z'); + const effectiveAt = new Date('2026-08-19T00:00:00.000Z'); + const generatedAt = new Date('2026-08-18T12:00:00.000Z'); + const persistedTicket = { + id: 'ticket-row', + ticketId: { toFixed: () => '1' }, + drawingId: { toFixed: () => '1' }, + recipient: proof.recipient, + bonusBall: proof.bonusBall, + normals: proof.normals, + originTxHash: proof.originTxHash, + logIndex: 0, + purchasedAt: new Date('2026-08-18T11:00:00.000Z'), + }; + const failed = { + id: 'planet-row', + chainId: 8453, + ticketId: { toFixed: () => '1' }, + ownerAddress: proof.recipient, + planetName: 'Failed Planet', + seed: `0x${'11'.repeat(32)}`, + traitsHash: `0x${'22'.repeat(32)}`, + generatorVersion: GENERATOR_VERSION, + planetType: 'Gaia', + terrain: 'Plains', + rarity: 'Common', + satelliteCount: 0, + hasRing: false, + baseMineralsPerDay: 1n, + generatedAt, + status: 'FAILED' as const, + gifData: null, + gifHash: null, + ticketPurchase: persistedTicket, + }; + const ready = { + ...failed, + planetName: 'Ready Planet', + status: 'READY' as const, + gifData: Buffer.from('gif'), + gifHash: keccak256(Buffer.from('gif')), + }; + const transaction = { + mineralEconomyCutover: { + findUnique: vi.fn().mockResolvedValue({ id: 1, cutoverAt }), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + $queryRaw: vi.fn().mockResolvedValue([{ now: effectiveAt }]), + mineralAccount: { + findUnique: vi.fn().mockResolvedValue({ + ownerAddress: proof.recipient.toLowerCase(), + balanceMicros: 0n, + lastSettledAt: cutoverAt, + }), + upsert: vi.fn().mockResolvedValue({ + ownerAddress: proof.recipient.toLowerCase(), + balanceMicros: 0n, + lastSettledAt: cutoverAt, + }), + }, + backendPlanet: { findUnique: vi.fn().mockResolvedValue(failed) }, + }; + let outerFindCalls = 0; + const update = vi.fn().mockImplementation(({ data }: { data: Record }) => ({ + ...failed, + ...data, + ticketId: failed.ticketId, + ticketPurchase: persistedTicket, + })); + const updateMany = vi.fn().mockResolvedValue({ count: 0 }); + const prisma = { + ticketPurchase: { findUnique: vi.fn().mockResolvedValue(persistedTicket) }, + backendPlanet: { + findUnique: vi.fn().mockImplementation(async () => { + outerFindCalls += 1; + return outerFindCalls < 3 ? failed : ready; + }), + update, + updateMany, + }, + $transaction: vi.fn(async (callback: (value: typeof transaction) => unknown) => callback(transaction)), + } as unknown as PrismaClient; + const store = new PrismaBackendPlanetStore(prisma, () => effectiveAt, cutoverAt); + + const result = await store.generatePlanet(proof); + + expect(result.generatedAt).toBe(generatedAt.toISOString()); + expect(result.gifHash).toBe(ready.gifHash); + expect(updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'planet-row', status: { not: 'READY' } }, + })); + expect(update).not.toHaveBeenCalled(); + expect(prisma.backendPlanet.findUnique).toHaveBeenCalledTimes(3); + }); + it('returns the row won by a concurrent create instead of overwriting its generation time', async () => { const persistedTicket = { id: 'ticket-row', @@ -63,7 +463,7 @@ describe('PrismaBackendPlanetStore', () => { planetName: 'Existing Planet', seed: `0x${'11'.repeat(32)}`, traitsHash: `0x${'22'.repeat(32)}`, - generatorVersion: 1, + generatorVersion: GENERATOR_VERSION, planetType: 'Gaia', terrain: 'Plains', rarity: 'Common', @@ -73,7 +473,7 @@ describe('PrismaBackendPlanetStore', () => { generatedAt: new Date('2026-08-13T12:00:00.000Z'), status: 'READY', gifData: Buffer.from('gif'), - gifHash: `0x${'33'.repeat(32)}`, + gifHash: keccak256(Buffer.from('gif')), ticketPurchase: { ticketId: { toFixed: () => '1' }, drawingId: { toFixed: () => '1' }, @@ -104,4 +504,298 @@ describe('PrismaBackendPlanetStore', () => { }); expect(prisma.backendPlanet.create).toHaveBeenCalledOnce(); }); + + it('returns the concurrent winner when configured future cutover falls back to V1 creation', async () => { + const cutoverAt = new Date('2026-08-20T00:00:00.000Z'); + const draftAt = new Date('2026-08-19T00:00:00.000Z'); + const persistedTicket = { + id: 'ticket-row', + ticketId: { toFixed: () => '1' }, + drawingId: { toFixed: () => '1' }, + recipient: proof.recipient, + bonusBall: proof.bonusBall, + normals: proof.normals, + originTxHash: proof.originTxHash, + logIndex: 0, + purchasedAt: new Date('2026-08-19T00:00:00.000Z'), + }; + const winner = { + id: 'winner-planet', + chainId: 8453, + ticketId: { toFixed: () => '1' }, + ownerAddress: proof.recipient, + planetName: 'Winner Planet', + seed: `0x${'11'.repeat(32)}`, + traitsHash: `0x${'22'.repeat(32)}`, + generatorVersion: GENERATOR_VERSION, + planetType: 'Gaia', + terrain: 'Plains', + rarity: 'Common', + satelliteCount: 0, + hasRing: false, + baseMineralsPerDay: 1n, + generatedAt: draftAt, + status: 'READY' as const, + gifData: Buffer.from('gif'), + gifHash: keccak256(Buffer.from('gif')), + ticketPurchase: persistedTicket, + }; + const account = { + ownerAddress: proof.recipient.toLowerCase(), + balanceMicros: 0n, + lastSettledAt: cutoverAt, + }; + const transaction = { + mineralEconomyCutover: { + findUnique: vi.fn().mockResolvedValue({ id: 1, cutoverAt }), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + $queryRaw: vi.fn().mockResolvedValue([{ now: draftAt }]), + mineralAccount: { + findUnique: vi.fn().mockResolvedValue(account), + upsert: vi.fn().mockResolvedValue(account), + }, + backendPlanet: { findUnique: vi.fn().mockResolvedValue(null) }, + }; + let findCalls = 0; + let createCalls = 0; + let releaseCreates!: () => void; + const bothCreates = new Promise((resolve) => { + releaseCreates = resolve; + }); + const prisma = { + ticketPurchase: { findUnique: vi.fn().mockResolvedValue(persistedTicket) }, + backendPlanet: { + findUnique: vi.fn().mockImplementation(async () => { + findCalls += 1; + return findCalls <= 2 ? null : winner; + }), + create: vi.fn(async () => { + const call = ++createCalls; + if (call === 2) releaseCreates(); + await bothCreates; + if (call === 1) return winner; + throw { code: 'P2002' }; + }), + }, + $transaction: vi.fn(async (callback: (value: typeof transaction) => unknown) => callback(transaction)), + } as unknown as PrismaClient; + const createNow = () => { + let calls = 0; + return () => (calls++ === 0 ? draftAt : draftAt); + }; + const firstStore = new PrismaBackendPlanetStore(prisma, createNow(), cutoverAt); + const secondStore = new PrismaBackendPlanetStore(prisma, createNow(), cutoverAt); + + const results = await Promise.all([ + firstStore.generatePlanet(proof), + secondStore.generatePlanet(proof), + ]); + + expect(results.map((result) => result.planetId)).toEqual(['winner-planet', 'winner-planet']); + expect(prisma.backendPlanet.create).not.toHaveBeenCalled(); + expect(prisma.backendPlanet.findUnique).toHaveBeenCalledTimes(4); + }); + + it('reloads the READY winner when a concurrent post-cutover transaction create loses P2002', async () => { + const cutoverAt = new Date('2026-08-20T00:00:00.000Z'); + const effectiveAt = new Date('2026-08-21T00:00:00.000Z'); + const persistedTicket = { + id: 'ticket-row', + ticketId: { toFixed: () => '1' }, + drawingId: { toFixed: () => '1' }, + recipient: proof.recipient, + bonusBall: proof.bonusBall, + normals: proof.normals, + originTxHash: proof.originTxHash, + logIndex: 0, + purchasedAt: new Date('2026-08-19T00:00:00.000Z'), + }; + const winner = { + id: 'winner-post-cutover-planet', + chainId: 8453, + ticketId: { toFixed: () => '1' }, + ownerAddress: proof.recipient, + planetName: 'Post-cutover Winner Planet', + seed: `0x${'11'.repeat(32)}`, + traitsHash: `0x${'22'.repeat(32)}`, + generatorVersion: GENERATOR_VERSION, + planetType: 'Gaia', + terrain: 'Plains', + rarity: 'Common', + satelliteCount: 0, + hasRing: false, + baseMineralsPerDay: 1n, + generatedAt: effectiveAt, + status: 'READY' as const, + gifData: Buffer.from('gif'), + gifHash: keccak256(Buffer.from('gif')), + ticketPurchase: persistedTicket, + }; + const account = { + ownerAddress: proof.recipient.toLowerCase(), + balanceMicros: 0n, + lastSettledAt: effectiveAt, + }; + let createCalls = 0; + let releaseCreates!: () => void; + const bothCreates = new Promise((resolve) => { + releaseCreates = resolve; + }); + const transaction = { + mineralEconomyCutover: { + findUnique: vi.fn().mockResolvedValue({ id: 1, cutoverAt }), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + $queryRaw: vi.fn().mockResolvedValue([{ now: effectiveAt }]), + mineralAccount: { + findUnique: vi.fn().mockResolvedValue(account), + upsert: vi.fn().mockResolvedValue(account), + }, + backendPlanet: { + findUnique: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn(async () => { + const call = ++createCalls; + if (call === 2) releaseCreates(); + await bothCreates; + if (call === 1) return winner; + throw { code: 'P2002' }; + }), + }, + planetUpgradePurchase: { findMany: vi.fn().mockResolvedValue([]) }, + }; + let findCalls = 0; + const prisma = { + ticketPurchase: { findUnique: vi.fn().mockResolvedValue(persistedTicket) }, + backendPlanet: { + findUnique: vi.fn().mockImplementation(async () => { + findCalls += 1; + return findCalls <= 2 ? null : winner; + }), + }, + $transaction: vi.fn(async (callback: (client: typeof transaction) => unknown) => callback(transaction)), + } as unknown as PrismaClient; + const firstStore = new PrismaBackendPlanetStore(prisma, () => effectiveAt, cutoverAt); + const secondStore = new PrismaBackendPlanetStore(prisma, () => effectiveAt, cutoverAt); + + const results = await Promise.all([ + firstStore.generatePlanet(proof), + secondStore.generatePlanet(proof), + ]); + + expect(results.map((result) => result.planetId)).toEqual([ + 'winner-post-cutover-planet', + 'winner-post-cutover-planet', + ]); + expect(prisma.$transaction).toHaveBeenCalledTimes(2); + expect(transaction.backendPlanet.create).toHaveBeenCalledTimes(2); + expect(prisma.backendPlanet.findUnique).toHaveBeenCalledTimes(3); + }); + + it('settles existing production before activating a generated Planet at the locked effective time', async () => { + const cutoverAt = new Date('2026-08-20T00:00:00.000Z'); + const draftAt = new Date('2026-08-19T00:00:00.000Z'); + const effectiveAt = new Date('2026-08-21T00:00:00.000Z'); + const appClockAt = new Date('2099-01-01T00:00:00.000Z'); + const persistedTicket = { + id: 'ticket-row', + ticketId: { toFixed: () => '1' }, + drawingId: { toFixed: () => '1' }, + recipient: proof.recipient, + bonusBall: proof.bonusBall, + normals: proof.normals, + originTxHash: proof.originTxHash, + logIndex: 0, + purchasedAt: new Date('2026-08-19T00:00:00.000Z'), + }; + const oldPlanet = { + id: 'old-planet', + ownerAddress: proof.recipient.toLowerCase(), + planetType: 'Gaia', + baseMineralsPerDay: 1n, + generatedAt: cutoverAt, + upgradeLevel: 0, + upgradeBonusBps: 0, + status: 'READY' as const, + }; + const account = { + id: 'account-row', + ownerAddress: proof.recipient.toLowerCase(), + openingBalanceMicros: 0n, + balanceMicros: 0n, + lastSettledAt: cutoverAt, + }; + const events: string[] = []; + let nowCalls = 0; + const transaction = { + mineralEconomyCutover: { + findUnique: vi.fn().mockResolvedValue({ id: 1, cutoverAt }), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + $queryRaw: vi.fn().mockResolvedValue([{ now: effectiveAt }]), + mineralAccount: { + findUnique: vi.fn().mockResolvedValue(null), + upsert: vi.fn().mockImplementation(async () => { + events.push('account-lock'); + return account; + }), + update: vi.fn().mockImplementation(async ({ data }: { data: Partial }) => { + Object.assign(account, data); + return account; + }), + }, + backendPlanet: { + findUnique: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([oldPlanet]), + create: vi.fn().mockImplementation(async ({ data }: { data: Record }) => ({ + id: 'new-planet', + chainId: 8453, + ticketId: { toFixed: () => '1' }, + ownerAddress: data.ownerAddress, + planetName: data.planetName, + seed: data.seed, + traitsHash: data.traitsHash, + generatorVersion: data.generatorVersion, + planetType: data.planetType, + terrain: data.terrain, + rarity: data.rarity, + satelliteCount: data.satelliteCount, + hasRing: data.hasRing, + baseMineralsPerDay: data.baseMineralsPerDay, + generatedAt: data.generatedAt, + status: data.status, + gifData: data.gifData, + gifHash: data.gifHash, + ticketPurchase: persistedTicket, + })), + }, + planetUpgradePurchase: { findMany: vi.fn().mockResolvedValue([]) }, + }; + const prisma = { + ticketPurchase: { findUnique: vi.fn().mockResolvedValue(persistedTicket) }, + backendPlanet: { + findUnique: vi.fn().mockResolvedValue(null), + }, + $transaction: vi.fn(async (callback: (value: typeof transaction) => unknown) => callback(transaction)), + } as unknown as PrismaClient; + const store = new PrismaBackendPlanetStore( + prisma, + () => { + nowCalls += 1; + events.push(`now-${nowCalls}`); + return nowCalls === 1 ? draftAt : appClockAt; + }, + cutoverAt, + ); + + const generated = await store.generatePlanet(proof); + + expect(generated.generatedAt).toBe(effectiveAt.toISOString()); + expect(account.balanceMicros).toBe(1_000_000n); + expect(account.lastSettledAt).toBe(effectiveAt); + expect(events).toEqual(['now-1', 'account-lock']); + expect(transaction.mineralAccount.update).toHaveBeenCalledOnce(); + expect(transaction.backendPlanet.create).toHaveBeenCalledOnce(); + }); }); diff --git a/server/api/prismaTicketPurchase.test.ts b/server/api/prismaTicketPurchase.test.ts new file mode 100644 index 0000000..95d1338 --- /dev/null +++ b/server/api/prismaTicketPurchase.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PrismaClient } from './generated/prisma/client.js'; +import { saveMegasteraProof } from './prismaTicketPurchase.js'; +import { BASE_JACKPOT, type MegasteraProof } from './eligibility.js'; +import { MEGASTERA_SOURCE } from './config.js'; +import { stringToHex } from 'viem'; + +const proof = { + chainId: 8453, + recipient: '0x1111111111111111111111111111111111111111', + ticketId: 7n, + drawingId: 2n, + normals: [1, 2, 3, 4, 5], + bonusBall: 6, + originTxHash: `0x${'11'.repeat(32)}`, + blockNumber: 30_000_000n, + logIndex: 4n, + blockHash: `0x${'22'.repeat(32)}`, + purchasedAt: new Date('2026-08-21T00:00:00.000Z'), +} as unknown as MegasteraProof; + +function row() { + return { + id: 'ticket-row', + chainId: 8453, + jackpotAddress: BASE_JACKPOT.toLowerCase(), + ticketId: { toFixed: () => '7' }, + drawingId: { toFixed: () => '2' }, + recipient: proof.recipient, + normals: proof.normals, + bonusBall: proof.bonusBall, + source: stringToHex(MEGASTERA_SOURCE, { size: 32 }), + originTxHash: proof.originTxHash, + blockNumber: proof.blockNumber, + blockHash: proof.blockHash, + logIndex: 4, + purchasedAt: proof.purchasedAt, + }; +} + +describe('saveMegasteraProof cross-instance idempotency', () => { + it('accepts a concurrent unique conflict when both canonical keys reread the identical proof', async () => { + const winner = row(); + const findUnique = vi.fn().mockResolvedValue(winner); + const prisma = { + ticketPurchase: { findUnique }, + $transaction: vi.fn(async (callback: (client: typeof prisma) => unknown) => { + const transaction = { + ticketPurchase: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockRejectedValue({ code: 'P2002' }), + }, + }; + return callback(transaction as never); + }), + } as unknown as PrismaClient; + + await expect(saveMegasteraProof(prisma, proof)).resolves.toBeUndefined(); + expect(findUnique).toHaveBeenCalledTimes(2); + }); + + it('rejects a concurrent unique conflict when either canonical key rereads a mismatch', async () => { + const winner = { ...row(), ticketId: { toFixed: () => '8' } }; + const findUnique = vi.fn().mockResolvedValueOnce(winner).mockResolvedValueOnce(winner); + const prisma = { + ticketPurchase: { findUnique }, + $transaction: vi.fn(async (callback: (client: typeof prisma) => unknown) => { + const transaction = { + ticketPurchase: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockRejectedValue({ code: 'P2002' }), + }, + }; + return callback(transaction as never); + }), + } as unknown as PrismaClient; + + await expect(saveMegasteraProof(prisma, proof)).rejects.toThrow( + 'Ticket proof conflicts with existing immutable provenance.', + ); + }); +}); diff --git a/server/api/prismaTicketPurchase.ts b/server/api/prismaTicketPurchase.ts index 6844565..7f0e1ea 100644 --- a/server/api/prismaTicketPurchase.ts +++ b/server/api/prismaTicketPurchase.ts @@ -64,38 +64,81 @@ function matches(record: PersistedTicket, candidate: ReturnType, +): Promise { + const [byTicketId, byReceipt] = await Promise.all([ + prisma.ticketPurchase.findUnique({ + where: { + chainId_jackpotAddress_ticketId: { + chainId: candidate.chainId, + jackpotAddress: candidate.jackpotAddress, + ticketId: candidate.ticketId, + }, + }, + }), + prisma.ticketPurchase.findUnique({ + where: { + chainId_originTxHash_logIndex: { + chainId: candidate.chainId, + originTxHash: candidate.originTxHash, + logIndex: candidate.logIndex, + }, + }, + }), + ]); + const ticketRecord = byTicketId as PersistedTicket | null; + const receiptRecord = byReceipt as PersistedTicket | null; + if (!ticketRecord || !receiptRecord || ticketRecord.id !== receiptRecord.id) { + throw new Error('Ticket proof conflicts with existing immutable provenance.'); + } + if (!matches(ticketRecord, candidate) || !matches(receiptRecord, candidate)) { + throw new Error('Ticket proof conflicts with existing immutable provenance.'); + } +} + /** Persists only the canonical receipt row needed by backend Planet generation. */ export async function saveMegasteraProof(prisma: PrismaClient, proof: MegasteraProof): Promise { const candidate = persistenceData(proof); - await prisma.$transaction(async (transaction) => { - const [byTicketId, byReceipt] = await Promise.all([ - transaction.ticketPurchase.findUnique({ - where: { - chainId_jackpotAddress_ticketId: { - chainId: candidate.chainId, - jackpotAddress: candidate.jackpotAddress, - ticketId: candidate.ticketId, + try { + await prisma.$transaction(async (transaction) => { + const [byTicketId, byReceipt] = await Promise.all([ + transaction.ticketPurchase.findUnique({ + where: { + chainId_jackpotAddress_ticketId: { + chainId: candidate.chainId, + jackpotAddress: candidate.jackpotAddress, + ticketId: candidate.ticketId, + }, }, - }, - }), - transaction.ticketPurchase.findUnique({ - where: { - chainId_originTxHash_logIndex: { - chainId: candidate.chainId, - originTxHash: candidate.originTxHash, - logIndex: candidate.logIndex, + }), + transaction.ticketPurchase.findUnique({ + where: { + chainId_originTxHash_logIndex: { + chainId: candidate.chainId, + originTxHash: candidate.originTxHash, + logIndex: candidate.logIndex, + }, }, - }, - }), - ]); - const ticketRecord = byTicketId as PersistedTicket | null; - const receiptRecord = byReceipt as PersistedTicket | null; - if (ticketRecord && receiptRecord && ticketRecord.id !== receiptRecord.id) { - throw new Error('Ticket proof conflicts with existing immutable provenance.'); - } - for (const record of [ticketRecord, receiptRecord]) { - if (record && !matches(record, candidate)) throw new Error('Ticket proof conflicts with existing immutable provenance.'); - } - if (!ticketRecord && !receiptRecord) await transaction.ticketPurchase.create({ data: candidate }); - }); + }), + ]); + const ticketRecord = byTicketId as PersistedTicket | null; + const receiptRecord = byReceipt as PersistedTicket | null; + if (ticketRecord && receiptRecord && ticketRecord.id !== receiptRecord.id) { + throw new Error('Ticket proof conflicts with existing immutable provenance.'); + } + for (const record of [ticketRecord, receiptRecord]) { + if (record && !matches(record, candidate)) throw new Error('Ticket proof conflicts with existing immutable provenance.'); + } + if (!ticketRecord && !receiptRecord) await transaction.ticketPurchase.create({ data: candidate }); + }); + } catch (error) { + if (!isUniqueConstraintError(error)) throw error; + await rereadCanonicalRows(prisma, candidate); + } } diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..6d83192 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/components/layout/Layout', () => ({ + Layout: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock('@/pages/Landing', () => ({ Landing: () => null })); +vi.mock('@/pages/Leaderboard', () => ({ Leaderboard: () => null })); +vi.mock('@/pages/Play', () => ({ Play: () => null })); +vi.mock('@/pages/Tickets', () => ({ Tickets: () => null })); +vi.mock('@/pages/ComingSoon', () => ({ ComingSoon: () => null })); +vi.mock('@/pages/Planets', () => ({ + Planets: ({ onViewPlanet }: { onViewPlanet: (planetId: string) => void }) => ( + + ), +})); + +import App from './App'; + +describe('App navigation', () => { + afterEach(() => { + cleanup(); + window.history.replaceState({}, '', '/'); + vi.restoreAllMocks(); + }); + + it('keeps the current scroll position when opening a planet', async () => { + window.history.replaceState({}, '', '/my-planets'); + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined); + + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Open planet' })); + + expect(scrollTo).not.toHaveBeenCalled(); + expect(window.location.pathname).toBe('/planet/planet-1'); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 6b648bc..2242f2c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,7 +34,6 @@ export default function App() { const pathname = `/planet/${tokenId}`; window.history.pushState({}, '', pathname); setRoute(parseAppRoute(pathname)); - window.scrollTo({ top: 0, behavior: 'smooth' }); }, []); useEffect(() => { diff --git a/src/components/common/ApprovalButton.test.tsx b/src/components/common/ApprovalButton.test.tsx index 8260b9c..bfff581 100644 --- a/src/components/common/ApprovalButton.test.tsx +++ b/src/components/common/ApprovalButton.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import '@testing-library/jest-dom/vitest'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { maxUint256 } from 'viem'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -72,7 +72,19 @@ describe('ApprovalButton', () => { expect(screen.queryByRole('button', { name: /approve/i })).not.toBeInTheDocument(); }); - it('approves the route-specific spender once when allowance is insufficient', async () => { + it('does not expose the purchase action until the allowance has been read', () => { + state.allowance = undefined; + render( + + + , + ); + + expect(screen.getByRole('button', { name: 'Checking USDC approval…' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Explore' })).not.toBeInTheDocument(); + }); + + it('approves a reusable allowance when the current allowance is insufficient', async () => { const user = userEvent.setup(); render( @@ -82,10 +94,12 @@ describe('ApprovalButton', () => { await user.click(screen.getByRole('button', { name: 'Approve USDC' })); - expect(state.writeContract).toHaveBeenCalledWith(expect.objectContaining({ - functionName: 'approve', - args: [spender, maxUint256], - })); + expect(state.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: 'approve', + args: [spender, maxUint256], + }), + ); }); it('does not treat a reverted approval receipt as successful', () => { @@ -117,4 +131,20 @@ describe('ApprovalButton', () => { expect(onApproved).toHaveBeenCalledTimes(1); expect(state.refetch).toHaveBeenCalledTimes(1); }); + + it('keeps the purchase action visible while the allowance read catches up', async () => { + state.txHash = `0x${'ef'.repeat(32)}`; + state.receipt = { status: 'success' }; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Explore' })).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: 'Approve USDC' })).not.toBeInTheDocument(); + }); }); diff --git a/src/components/common/ApprovalButton.tsx b/src/components/common/ApprovalButton.tsx index 1f3ef89..59a5669 100644 --- a/src/components/common/ApprovalButton.tsx +++ b/src/components/common/ApprovalButton.tsx @@ -4,8 +4,8 @@ * @customize Generic USDC approve flow. Pass any `spender` so one wrapper * works across Jackpot / BatchPurchaseFacilitator / * JackpotAutoSubscription / JackpotLPManager. The gate compares - * allowance with the exact next purchase, while approval uses - * `maxUint256` for the documented "approve once" UX. + * allowance with the next purchase, then creates a reusable + * allowance for that spender. * * Children-passthrough pattern. Wrap the downstream submit * button inside ``; when allowance is @@ -19,12 +19,10 @@ * Children show through whenever: * - wallet is disconnected * - amount is 0n (nothing to approve yet) - * - allowance query is still loading - * - allowance is already ≥ amount + * - allowance is resolved and already ≥ amount * - * so the submit CTA stays visible (and the parent can keep it - * disabled via its own guard) in every state except the brief - * "approval required" gate. + * While the allowance is unresolved, the CTA is disabled until + * the current on-chain value is known. * --- */ import type { ReactNode } from 'react'; @@ -33,8 +31,11 @@ import { erc20Abi, maxUint256 } from 'viem'; import { useAccount, useWaitForTransactionReceipt, useWriteContract } from 'wagmi'; import { USDC_ADDRESS } from '@/config/contracts'; import { useUsdcAllowance } from '@/hooks/useUsdcAllowance'; +import { + getTransactionReceiptError, + isSuccessfulTransactionReceipt, +} from '@/lib/transactionReceipt'; import { Button } from './Button'; -import { getTransactionReceiptError, isSuccessfulTransactionReceipt } from '@/lib/transactionReceipt'; export function ApprovalButton({ spender, @@ -49,10 +50,12 @@ export function ApprovalButton({ children?: ReactNode; }) { const { address } = useAccount(); - const { allowance, error: allowanceError, isLoading: isAllowanceLoading, refetch } = useUsdcAllowance( - address, - spender, - ); + const { + allowance, + error: allowanceError, + isLoading: isAllowanceLoading, + refetch, + } = useUsdcAllowance(address, spender); const { writeContract, data: txHash, isPending, error, reset } = useWriteContract(); const { data: receipt, isLoading } = useWaitForTransactionReceipt({ @@ -61,6 +64,10 @@ export function ApprovalButton({ const isSuccess = isSuccessfulTransactionReceipt(receipt); const receiptError = getTransactionReceiptError(receipt); const [isRefreshingAllowance, setIsRefreshingAllowance] = useState(false); + const approvalKey = address + ? `${address.toLowerCase()}:${spender.toLowerCase()}:${amount.toString()}` + : null; + const [confirmedApprovalKey, setConfirmedApprovalKey] = useState(null); // Fire-once gate keyed on `txHash`. The parent may pass a fresh // `onApproved` reference each render, which would otherwise re-run @@ -73,34 +80,39 @@ export function ApprovalButton({ if (!isSuccess || !txHash) return; if (firedHashRef.current === txHash) return; firedHashRef.current = txHash; + // A successful receipt is authoritative; the allowance read can lag the + // receipt briefly on the RPC provider and otherwise show Approve again. + setConfirmedApprovalKey(approvalKey); onApproved?.(); setIsRefreshingAllowance(true); void Promise.resolve(refetch()).finally(() => { setIsRefreshingAllowance(false); reset(); }); - }, [isSuccess, txHash, refetch, onApproved, reset]); + }, [approvalKey, isSuccess, txHash, refetch, onApproved, reset]); const requiresAllowance = !!address && amount > 0n; - if (requiresAllowance && isAllowanceLoading) { - return ( - - ); - } - if (requiresAllowance && allowanceError) { return (
-

Retry after your RPC connection recovers.

+

+ Retry after your RPC connection recovers. +

); } + if (requiresAllowance && (isAllowanceLoading || allowance === undefined)) { + return ( + + ); + } + if (requiresAllowance && isRefreshingAllowance) { return ( {(error ?? receiptError) && (

diff --git a/src/components/explore/ExpeditionConfigurator.test.tsx b/src/components/explore/ExpeditionConfigurator.test.tsx index 531af6a..470d7f6 100644 --- a/src/components/explore/ExpeditionConfigurator.test.tsx +++ b/src/components/explore/ExpeditionConfigurator.test.tsx @@ -120,14 +120,14 @@ describe('ExpeditionConfigurator', () => { const [toggle] = screen.getAllByRole('button', { name: 'Open coordinates' }); expect(toggle).toHaveAttribute('aria-expanded', 'false'); - expect(toggle).toHaveClass('transition-colors'); + expect(toggle).toHaveClass('transition-[right,background-color]'); expect(toggle).toHaveClass('h-60', 'w-20'); - expect(toggle).toHaveStyle({ left: 'calc(50% + 472px)' }); + expect(toggle).toHaveClass('fixed'); + expect(toggle).toHaveStyle({ right: '0px' }); expect(toggle.querySelector('.text-\\[1rem\\]')).toBeInTheDocument(); expect(toggle.querySelector('.text-\\[2\\.25rem\\]')).toBeInTheDocument(); - expect(screen.getByTestId('coordinates-disclosure')).toHaveStyle({ - left: 'min(calc(50% + 552px), calc(50% + 50vw - 316px))', - }); + expect(screen.getByTestId('coordinates-disclosure')).toHaveClass('fixed'); + expect(screen.getByTestId('coordinates-disclosure')).toHaveStyle({ right: '0px' }); expect(screen.getAllByText('Bonus Ball influences Planet generation.')).toHaveLength(1); await user.click(toggle); @@ -136,9 +136,7 @@ describe('ExpeditionConfigurator', () => { expect(screen.getByTestId('coordinates-disclosure')).toHaveClass('w-[380px]'); const [closeToggle] = screen.getAllByRole('button', { name: 'Close coordinates' }); - expect(closeToggle).toHaveStyle({ - left: 'calc(50% + 472px)', - }); + expect(closeToggle).toHaveStyle({ right: '380px' }); await user.click(closeToggle); expect(toggle).toHaveAttribute('aria-expanded', 'false'); }); diff --git a/src/components/explore/ExpeditionConfigurator.tsx b/src/components/explore/ExpeditionConfigurator.tsx index 95ddfbf..2f20051 100644 --- a/src/components/explore/ExpeditionConfigurator.tsx +++ b/src/components/explore/ExpeditionConfigurator.tsx @@ -12,6 +12,25 @@ function formatJackpot(amount: bigint) { return Number(formatUnits(amount, 6)).toLocaleString('en-US', { maximumFractionDigits: 2 }); } +function formatPurchaseError(error: Error) { + const message = error.message.toLowerCase(); + if ( + /(insufficient|not enough|exceeds balance|balance too low)/.test(message) && + /(balance|fund|usdc|transfer)/.test(message) + ) { + return 'Not enough USDC balance for this purchase.'; + } + if ( + /(user rejected|user denied|rejected the request|denied transaction|cancelled)/.test(message) + ) { + return 'Transaction cancelled. Try again when you’re ready.'; + } + if (/(network|rpc|chain|disconnected)/.test(message)) { + return 'Wallet connection or network error. Check your wallet and try again.'; + } + return 'We couldn’t complete the ticket purchase. Check your USDC balance and try again.'; +} + export type JackpotStatus = 'loading' | 'ready' | 'error'; export function ExpeditionConfigurator({ @@ -24,6 +43,7 @@ export function ExpeditionConfigurator({ tickets, disabled, exploreLabel, + purchaseError, approvalSpender, approvalAmount, onApproved, @@ -40,6 +60,7 @@ export function ExpeditionConfigurator({ tickets: readonly CustomTicket[]; disabled: boolean; exploreLabel?: ReactNode; + purchaseError?: Error | null; approvalSpender?: `0x${string}`; approvalAmount?: bigint; onApproved?: () => void; @@ -137,6 +158,22 @@ export function ExpeditionConfigurator({ onClick={onExplore} /> )} + {purchaseError ? ( +

+

{formatPurchaseError(purchaseError)}

+

+ Need help?{' '} + + Message support if you have any issues + +

+
+ ) : null} @@ -146,8 +183,8 @@ export function ExpeditionConfigurator({ aria-label={coordinatesLabel} aria-expanded={coordinatesOpen} onClick={() => setCoordinatesOpen((open) => !open)} - className="absolute top-1/2 z-40 hidden h-60 w-20 -translate-y-1/2 items-center justify-center border-x border-[var(--border)] bg-[var(--background)] text-[var(--text-primary)] transition-colors duration-300 ease-out hover:bg-[var(--surface-raised)] xl:flex" - style={{ left: 'calc(50% + 472px)' }} + className="fixed top-1/2 right-0 z-40 hidden h-60 w-20 -translate-y-1/2 items-center justify-center border-x border-[var(--border)] bg-[var(--background)] text-[var(--text-primary)] transition-[right,background-color] duration-300 ease-out hover:bg-[var(--surface-raised)] xl:flex" + style={{ right: coordinatesOpen ? '380px' : '0px' }} > @@ -164,8 +201,8 @@ export function ExpeditionConfigurator({ data-state={coordinatesOpen ? 'open' : 'closed'} aria-hidden={!coordinatesOpen} inert={!coordinatesOpen || undefined} - className={`absolute top-0 z-30 hidden h-full max-h-[calc(100svh-7rem)] overflow-hidden transition-[width,opacity,transform] duration-300 ease-out xl:block ${coordinatesOpen ? 'pointer-events-auto w-[380px] translate-x-0 opacity-100' : 'pointer-events-none w-0 translate-x-3 opacity-0'}`} - style={{ left: 'min(calc(50% + 552px), calc(50% + 50vw - 316px))' }} + className={`fixed top-[7rem] right-0 z-30 hidden h-[calc(100svh-7rem)] overflow-hidden transition-[width,opacity,transform] duration-300 ease-out xl:block ${coordinatesOpen ? 'pointer-events-auto w-[380px] translate-x-0 opacity-100' : 'pointer-events-none w-0 translate-x-3 opacity-0'}`} + style={{ right: '0px' }} >
adds a spacer after the footer so it remains visible - * above the fixed bottom navigation. - */ -import { COPY } from '@/config/copy'; -import { DisclaimerLink } from './DisclaimerLink'; +/** Shared external-link footer for the app shell. */ + +export const FOOTER_LINKS = [ + { label: 'X Megastera', href: 'https://x.com/MegasteraGame' }, + { label: 'Megapot Docs', href: 'https://docs.megapot.io' }, + { label: 'Megapot site', href: 'https://megapot.io' }, + { label: 'Support', href: 'https://t.me/astralex163' }, +] as const; export function Footer() { return ( -