commit c54e8884586d881f290d1b481e1ca67749b83288 Author: Felix Faerber Date: Wed May 20 15:08:45 2026 +0300 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c071f14 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.env +.env.local +*.log +.git diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..adcb2dc --- /dev/null +++ b/.env.example @@ -0,0 +1,45 @@ +# --- Required --- + +# Postgres (Prisma) +DATABASE_URL=postgresql://hosted:hosted@localhost:5432/hosted_gateway?schema=public + +# Redis (BullMQ + locks + nonce) +REDIS_HOST=localhost +REDIS_PORT=6379 + +# Auth +JWT_SECRET_KEY=change-me-to-a-long-random-string +GOOGLE_OAUTH_CLIENT_ID= + +# Operator wallet (the ONLY on-chain identity) +OPERATOR_PRIVATE_KEY=0x... +GNOSIS_RPC_URL=https://rpc.gnosischain.com + +# T4T protocol contract addresses (Gnosis mainnet — managed by the t4t repo). +# These are the canonical live deployment; override only when pointing at a +# private fork or testnet (Chiado). +REGISTRY_ADDRESS=0xf81121AAbc2F7261224BaDd0Ed871711e6D1371E +ESCROW_ADDRESS=0x34Db8E014E71928f17E23eC1272B602582222c9c +XBZZ_ADDRESS=0xdBF3Ea6F5beE45c02255B2c26a16F300502F68da + +# Bee node (operator-owned, single postage batch) +BEE_API_URL=http://localhost:1633 +BEE_POSTAGE_BATCH_ID= +BEE_STAMP_MANAGE=true +BEE_STAMP_LABEL=hosted-gateway +BEE_STAMP_TTL_DAYS=30 + +# Pricing +MARKUP_BPS=2000 # 20% operator markup on xBZZ cost +WELCOME_XBZZ_WEI=50000000000000000000 # 50 xBZZ in wei + +# Concurrency +MAX_CONCURRENT_JOBS=200 + +# Server +HOSTED_GATEWAY_PORT=8080 +LOG_LEVEL=info + +# --- Phase 2 (Stripe) — leave blank in v1 --- +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0067c72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.env +.env.local +*.log +bun.lock.bak diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..19e457c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM oven/bun:1.2-alpine + +WORKDIR /app + +# Install dependencies +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile 2>/dev/null || bun install + +# Generate Prisma client +COPY prisma ./prisma +RUN bunx prisma generate + +# Copy source (ARG busts cache on code changes) +ARG CACHE_BUST=1 +COPY . . + +ARG COMMIT_SHA +ENV COMMIT_SHA=$COMMIT_SHA + +EXPOSE 8080 + +# Default command; docker-compose overrides for the worker process. +CMD ["bun", "run", "src/api.ts"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9988dbb --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Hosted T4T Gateway + +Multi-tenant frontend for the [Token4Token](https://github.com/token-for-token/t4t) inference network. Users sign up with Google / email, get 50 free virtual xBZZ, and call OpenAI-compatible endpoints without touching a wallet, Bee node, or Gnosis RPC. + +**Custody model:** one operator wallet handles all on-chain transactions and Swarm operations. Tenants are off-chain ledger entries denominated in xBZZ. Providers see one on-chain client address (the operator's), not per-tenant addresses. + +See [docs in t4t/](https://github.com/token-for-token/t4t/blob/main/docs/proposal-centralized-gateway.md) for the wider design context. + +## Stack + +- Bun + Elysia (HTTP) +- viem (Gnosis Chain bindings) — shared with `t4t-container` +- bee-js (Swarm) — shared with `t4t-container` +- Prisma + Postgres (multi-tenant ledger) +- BullMQ + Redis (queues + distributed locks + operator-wallet nonce) + +The gateway engine code (provider selection, chain I/O, PSS routing, ECIES envelope) is vendored from [`t4t/container/src/`](../t4t/container/src) into `src/t4t/`. See [src/t4t/VENDORED.md](src/t4t/VENDORED.md) for the drift policy. + +## Quick start + +```bash +cp .env.example .env +# fill in OPERATOR_PRIVATE_KEY, REGISTRY/ESCROW/XBZZ addresses, JWT_SECRET_KEY + +docker compose up -d postgres redis bee +bun install +bunx prisma migrate deploy +bun run dev:api +# in another shell: +bun run dev:worker +``` + +## Layout + +``` +src/ + api.ts # Elysia HTTP entry + worker.ts # BullMQ workers + claim watcher + db.ts # model. CUD wrappers (write + enqueue) + env.ts # zod-validated process.env + auth/ # JWT, Google OAuth, email signup + users/, accounts/, apiKeys/, pricing/, gateway/, chain/ + queue/ # connection, registry, lock, processor base, service + t4t/ # VENDORED from t4t-container +prisma/schema.prisma +test/ # E2E specs +``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..0495e78 --- /dev/null +++ b/bun.lock @@ -0,0 +1,610 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "hosted-gateway", + "dependencies": { + "@elysiajs/bearer": "^1.2.0", + "@elysiajs/cors": "^1.2.0", + "@elysiajs/swagger": "^1.2.0", + "@ethersphere/bee-js": "^11.0.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.0", + "@noble/hashes": "^1.8.0", + "@prisma/adapter-pg": "^7.0.0", + "@prisma/client": "^7.0.0", + "argon2": "^0.41.1", + "bullmq": "^5.34.0", + "elysia": "^1.2.25", + "google-auth-library": "^9.15.0", + "ioredis": "^5.6.0", + "jsonwebtoken": "^9.0.2", + "pino": "^9.5.0", + "pino-pretty": "^11.2.2", + "viem": "^2.21.0", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.9", + "bun-types": "latest", + "prisma": "^7.0.0", + "typescript": "^5.7", + }, + }, + }, + "packages": { + "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], + + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + + "@electric-sql/pglite": ["@electric-sql/pglite@0.4.1", "", {}, "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q=="], + + "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.1.1", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.1" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw=="], + + "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.3.1", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.1" } }, "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA=="], + + "@elysiajs/bearer": ["@elysiajs/bearer@1.4.4", "", { "peerDependencies": { "elysia": ">= 1.4.3" } }, "sha512-NCMiTEwLmqfxkbaTHYvZXvmO+CHmHVNlLLNa+0RcJviK88abVeSnn6wswgABGGTRJJTkfw3Ks3fw/7um3bxeRQ=="], + + "@elysiajs/cors": ["@elysiajs/cors@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-FTCcbH35brTLigF1W7BYySRZomgI/dBEMK9BgK9RP9Nez7zmpGh4koL/Yr1BFv8nYz7CfhRvcM8d/c+XnwMaVQ=="], + + "@elysiajs/swagger": ["@elysiajs/swagger@1.3.1", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-LcbLHa0zE6FJKWPWKsIC/f+62wbDv3aXydqcNPVPyqNcaUgwvCajIi+5kHEU6GO3oXUCpzKaMsb3gsjt8sLzFQ=="], + + "@ethersphere/bee-js": ["@ethersphere/bee-js@11.2.0", "", { "dependencies": { "axios": "^0.30.2", "cafe-utility": "^33.6.1", "debug": "^4.4.1", "isomorphic-ws": "^4.0.1", "semver": "^7.3.5", "ws": "^8.7.0" } }, "sha512-Cn1NCy6/g+LzTXBWxFftT7OWfCj4CXGK5lo3VHVp0v3erytFqoBQkKLZ1TiwE4En5/voN7GCe5kjy3El8l8lRA=="], + + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + + "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], + + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@prisma/adapter-pg": ["@prisma/adapter-pg@7.8.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.8.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg=="], + + "@prisma/client": ["@prisma/client@7.8.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.8.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw=="], + + "@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.8.0", "", {}, "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw=="], + + "@prisma/config": ["@prisma/config@7.8.0", "", { "dependencies": { "c12": "3.3.4", "deepmerge-ts": "7.1.5", "effect": "3.20.0", "empathic": "2.0.0" } }, "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw=="], + + "@prisma/debug": ["@prisma/debug@7.8.0", "", {}, "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA=="], + + "@prisma/dev": ["@prisma/dev@0.24.3", "", { "dependencies": { "@electric-sql/pglite": "0.4.1", "@electric-sql/pglite-socket": "0.1.1", "@electric-sql/pglite-tools": "0.3.1", "@hono/node-server": "1.19.11", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "@prisma/streams-local": "0.1.2", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "hono": "^4.12.8", "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.2.0", "zeptomatch": "2.1.0" } }, "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg=="], + + "@prisma/driver-adapter-utils": ["@prisma/driver-adapter-utils@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0" } }, "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A=="], + + "@prisma/engines": ["@prisma/engines@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0", "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "@prisma/fetch-engine": "7.8.0", "@prisma/get-platform": "7.8.0" } }, "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw=="], + + "@prisma/engines-version": ["@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "", {}, "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0", "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "@prisma/get-platform": "7.8.0" } }, "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ=="], + + "@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="], + + "@prisma/query-plan-executor": ["@prisma/query-plan-executor@7.2.0", "", {}, "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ=="], + + "@prisma/streams-local": ["@prisma/streams-local@0.1.2", "", { "dependencies": { "ajv": "^8.12.0", "better-result": "^2.7.0", "env-paths": "^3.0.0", "proper-lockfile": "^4.1.2" } }, "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg=="], + + "@prisma/studio-core": ["@prisma/studio-core@0.27.3", "", { "dependencies": { "@radix-ui/react-toggle": "1.1.10", "chart.js": "4.5.1" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="], + + "@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="], + + "@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="], + + "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], + + "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], + + "@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + + "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="], + + "abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "argon2": ["argon2@0.41.1", "", { "dependencies": { "@phc/format": "^1.0.0", "node-addon-api": "^8.1.0", "node-gyp-build": "^4.8.1" } }, "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + + "axios": ["axios@0.30.3", "", { "dependencies": { "follow-redirects": "^1.15.4", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "better-result": ["better-result@2.9.2", "", {}, "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bullmq": ["bullmq@5.76.10", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", "msgpackr": "2.0.1", "node-abort-controller": "3.1.1", "semver": "7.8.0", "tslib": "2.8.1" } }, "sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], + + "cafe-utility": ["cafe-utility@33.9.0", "", {}, "sha512-MrTuoypjKKnfJg7CqbPbN2ugXuPp0qG5mLdJMjG9GzeGhMDBxD3xhM4EnmPh7+rH4sFqx29c4caxg3oU+eT/Zw=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="], + + "elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], + + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + + "fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "file-type": ["file-type@22.0.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="], + + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="], + + "gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="], + + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "giget": ["giget@3.2.0", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A=="], + + "google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], + + "google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "grammex": ["grammex@3.1.12", "", {}, "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ=="], + + "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], + + "gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + + "hono": ["hono@4.12.21", "", {}, "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ=="], + + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + + "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], + + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "isomorphic-ws": ["isomorphic-ws@4.0.1", "", { "peerDependencies": { "ws": "*" } }, "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w=="], + + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msgpackr": ["msgpackr@2.0.1", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + + "mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="], + + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + + "nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], + + "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], + + "node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "ox": ["ox@0.14.22", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-nb5msL8qWbPglhIfZbGJAfw3cqiJjFMiWmACt7kgyWtLib12tcctbHufMT9Hb0Lr6Pt4k9I3dbpueTpbhvbqvA=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + + "pg": ["pg@8.21.0", "", { "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], + + "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], + + "pino-pretty": ["pino-pretty@11.3.0", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^3.0.2", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pump": "^3.0.0", "readable-stream": "^4.0.0", "secure-json-parse": "^2.4.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^3.1.1" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + + "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + + "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], + + "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "prisma": ["prisma@7.8.0", "", { "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", "@prisma/engines": "7.8.0", "@prisma/studio-core": "0.27.3", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], + + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + + "remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], + + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + + "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], + + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], + + "viem": ["viem@2.50.4", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.22", "ws": "8.20.1" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-rf98F4s3Vlb+uJZEKfay3IbBw3CNCbVtx5Y3UIljlO2tSX420g/J0WQSYsjzBSasUFgxgsXabji14O9kGbiqgg=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], + + "zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@prisma/dev/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0" } }, "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw=="], + + "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0" } }, "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw=="], + + "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + + "@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="], + + "c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + + "pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "viem/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + + "@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..bfd8e49 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +timeout = 120000 diff --git a/package.json b/package.json new file mode 100644 index 0000000..c9b2fe5 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "hosted-gateway", + "version": "0.1.0", + "private": true, + "description": "Hosted multi-tenant frontend for the Token4Token inference network. One operator wallet; xBZZ-denominated off-chain ledger.", + "scripts": { + "dev:api": "bun run --watch src/api.ts", + "dev:worker": "bun run --watch src/worker.ts", + "start:api": "bun run src/api.ts", + "start:worker": "bun run src/worker.ts", + "db:generate": "bunx prisma generate", + "db:push": "bunx prisma db push", + "db:migrate": "bunx prisma migrate deploy", + "db:seed": "bun run prisma/seed/index.ts", + "test:e2e": "bun test --preload ./test/setup.ts ./test/**/*.e2e-spec.ts", + "typecheck": "bunx tsc --noEmit" + }, + "//comment": "Prisma 7 reads its config from prisma.config.ts at the package root, not from package.json#prisma.", + "dependencies": { + "@elysiajs/bearer": "^1.2.0", + "@elysiajs/cors": "^1.2.0", + "@elysiajs/swagger": "^1.2.0", + "@ethersphere/bee-js": "^11.0.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.0", + "@noble/hashes": "^1.8.0", + "@prisma/adapter-pg": "^7.0.0", + "@prisma/client": "^7.0.0", + "argon2": "^0.41.1", + "bullmq": "^5.34.0", + "elysia": "^1.2.25", + "google-auth-library": "^9.15.0", + "ioredis": "^5.6.0", + "jsonwebtoken": "^9.0.2", + "pino": "^9.5.0", + "pino-pretty": "^11.2.2", + "viem": "^2.21.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.9", + "bun-types": "latest", + "prisma": "^7.0.0", + "typescript": "^5.7" + }, + "//prisma-config": "Prisma 7 — see prisma.config.ts" +} diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..c4a1c69 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +// Prisma 7 config. Replaces the old `datasource.url` field in +// prisma/schema.prisma and the `package.json#prisma` key. See +// https://pris.ly/d/config-datasource. +import { defineConfig } from 'prisma/config'; + +export default defineConfig({ + schema: './prisma/schema.prisma', + datasource: { + url: process.env.DATABASE_URL, + }, + migrations: { + seed: 'bun run prisma/seed/index.ts', + }, +}); diff --git a/prisma/migrations/20260520091204_init/migration.sql b/prisma/migrations/20260520091204_init/migration.sql new file mode 100644 index 0000000..a349892 --- /dev/null +++ b/prisma/migrations/20260520091204_init/migration.sql @@ -0,0 +1,201 @@ +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN'); + +-- CreateEnum +CREATE TYPE "AuthProvider" AS ENUM ('google', 'email'); + +-- CreateEnum +CREATE TYPE "AccountStatus" AS ENUM ('active', 'disabled'); + +-- CreateEnum +CREATE TYPE "LedgerKind" AS ENUM ('welcomeGrant', 'settle', 'refund', 'topup', 'adjustment'); + +-- CreateEnum +CREATE TYPE "GatewayJobStatus" AS ENUM ('posted', 'acked', 'delivered', 'claimed', 'cancelled', 'timed_out', 'failed'); + +-- CreateEnum +CREATE TYPE "TopupStatus" AS ENUM ('requires_payment', 'processing', 'succeeded', 'credited', 'failed', 'refunded'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "verificationToken" TEXT, + "authProvider" "AuthProvider" NOT NULL, + "googleSub" TEXT, + "passwordHash" TEXT, + "role" "UserRole" NOT NULL DEFAULT 'USER', + "stripeCustomerId" TEXT, + "country" TEXT, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "key" TEXT NOT NULL, + "name" TEXT NOT NULL DEFAULT '', + "lastUsedAt" TIMESTAMP(3), + "scopeMaxXbzzPerDay" DECIMAL(78,0), + "modelAllowlist" TEXT[] DEFAULT ARRAY[]::TEXT[], + "userId" TEXT NOT NULL, + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "status" "AccountStatus" NOT NULL DEFAULT 'active', + "balanceXbzzWei" DECIMAL(78,0) NOT NULL DEFAULT 0, + "reservedXbzzWei" DECIMAL(78,0) NOT NULL DEFAULT 0, + "userId" TEXT NOT NULL, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LedgerEntry" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "kind" "LedgerKind" NOT NULL, + "deltaXbzzWei" DECIMAL(78,0) NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "note" TEXT, + "accountId" TEXT NOT NULL, + "gatewayJobId" TEXT, + "topupId" TEXT, + + CONSTRAINT "LedgerEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GatewayJob" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "routingId" TEXT NOT NULL, + "onChainJobId" TEXT, + "requestHash" TEXT, + "responseHash" TEXT, + "provider" TEXT NOT NULL, + "modelId" TEXT NOT NULL, + "estimatedMaxXbzzWei" DECIMAL(78,0) NOT NULL, + "actualXbzzWei" DECIMAL(78,0), + "status" "GatewayJobStatus" NOT NULL DEFAULT 'posted', + "postedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ackedAt" TIMESTAMP(3), + "deliveredAt" TIMESTAMP(3), + "claimedAt" TIMESTAMP(3), + "ackDeadline" TIMESTAMP(3), + "deliveryDeadline" TIMESTAMP(3), + "promptTokens" INTEGER, + "completionTokens" INTEGER, + "promptRedacted" TEXT, + "responseRedacted" TEXT, + "errorMessage" TEXT, + "userId" TEXT NOT NULL, + + CONSTRAINT "GatewayJob_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Topup" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "status" "TopupStatus" NOT NULL DEFAULT 'requires_payment', + "stripePaymentIntent" TEXT NOT NULL, + "amountUsdCents" INTEGER NOT NULL, + "creditedXbzzWei" DECIMAL(78,0), + "usdPerXbzzAtCredit" DECIMAL(18,8), + "error" TEXT, + "userId" TEXT NOT NULL, + + CONSTRAINT "Topup_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_verificationToken_key" ON "User"("verificationToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_googleSub_key" ON "User"("googleSub"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_stripeCustomerId_key" ON "User"("stripeCustomerId"); + +-- CreateIndex +CREATE INDEX "User_authProvider_googleSub_idx" ON "User"("authProvider", "googleSub"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_key_key" ON "ApiKey"("key"); + +-- CreateIndex +CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_userId_key" ON "Account"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LedgerEntry_idempotencyKey_key" ON "LedgerEntry"("idempotencyKey"); + +-- CreateIndex +CREATE INDEX "LedgerEntry_accountId_createdAt_idx" ON "LedgerEntry"("accountId", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "LedgerEntry_kind_idx" ON "LedgerEntry"("kind"); + +-- CreateIndex +CREATE UNIQUE INDEX "GatewayJob_routingId_key" ON "GatewayJob"("routingId"); + +-- CreateIndex +CREATE UNIQUE INDEX "GatewayJob_onChainJobId_key" ON "GatewayJob"("onChainJobId"); + +-- CreateIndex +CREATE INDEX "GatewayJob_userId_createdAt_idx" ON "GatewayJob"("userId", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "GatewayJob_status_idx" ON "GatewayJob"("status"); + +-- CreateIndex +CREATE INDEX "GatewayJob_onChainJobId_idx" ON "GatewayJob"("onChainJobId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Topup_stripePaymentIntent_key" ON "Topup"("stripePaymentIntent"); + +-- CreateIndex +CREATE INDEX "Topup_userId_createdAt_idx" ON "Topup"("userId", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "Topup_status_idx" ON "Topup"("status"); + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_gatewayJobId_fkey" FOREIGN KEY ("gatewayJobId") REFERENCES "GatewayJob"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_topupId_fkey" FOREIGN KEY ("topupId") REFERENCES "Topup"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GatewayJob" ADD CONSTRAINT "GatewayJob_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Topup" ADD CONSTRAINT "Topup_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..7434f21 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,223 @@ +// Hosted T4T Gateway — Prisma schema. +// +// One operator wallet handles all on-chain transactions. Tenants are off-chain +// ledger entries denominated in xBZZ wei (Decimal(78, 0) holds any uint256). +// USD is never in the billing path — only in `Topup` rows for Phase 2 Stripe. + +generator client { + provider = "prisma-client-js" +} + +// Connection URL + seed config live in prisma.config.ts (Prisma 7). +datasource db { + provider = "postgresql" +} + +// ----------------------------------------------------------------------------- +// Enums +// ----------------------------------------------------------------------------- + +enum UserRole { + USER + ADMIN +} + +enum AuthProvider { + google + email +} + +enum AccountStatus { + active + disabled +} + +enum LedgerKind { + welcomeGrant + settle + refund + topup + adjustment +} + +enum GatewayJobStatus { + posted + acked + delivered + claimed + cancelled + timed_out + failed +} + +enum TopupStatus { + requires_payment + processing + succeeded + credited + failed + refunded +} + +// ----------------------------------------------------------------------------- +// User + auth +// ----------------------------------------------------------------------------- + +model User { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique + emailVerified Boolean @default(false) + // Single-use email verification token (NULL once consumed). For Phase 1 we + // surface this in the signup response in dev/test mode; Phase 1.5 wires up + // a real outbound mailer and stops echoing the token back. + verificationToken String? @unique + authProvider AuthProvider + googleSub String? @unique + passwordHash String? + role UserRole @default(USER) + + // Phase 2 — Stripe handles billing address + sanctions screening + stripeCustomerId String? @unique + country String? // ISO 3166-1 alpha-2, mirrored from Stripe + + account Account? + apiKeys ApiKey[] + gatewayJobs GatewayJob[] + topups Topup[] + + @@index([authProvider, googleSub]) +} + +model ApiKey { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + key String @unique @default(uuid()) + name String @default("") + lastUsedAt DateTime? + scopeMaxXbzzPerDay Decimal? @db.Decimal(78, 0) + modelAllowlist String[] @default([]) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + @@index([userId]) +} + +// ----------------------------------------------------------------------------- +// Account + ledger +// ----------------------------------------------------------------------------- + +model Account { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + status AccountStatus @default(active) + balanceXbzzWei Decimal @default(0) @db.Decimal(78, 0) + reservedXbzzWei Decimal @default(0) @db.Decimal(78, 0) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String @unique + + ledgerEntries LedgerEntry[] +} + +model LedgerEntry { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + kind LedgerKind + deltaXbzzWei Decimal @db.Decimal(78, 0) + idempotencyKey String @unique + note String? + + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + accountId String + + gatewayJob GatewayJob? @relation(fields: [gatewayJobId], references: [id], onDelete: SetNull) + gatewayJobId String? + + topup Topup? @relation(fields: [topupId], references: [id], onDelete: SetNull) + topupId String? + + @@index([accountId, createdAt(sort: Desc)]) + @@index([kind]) +} + +// ----------------------------------------------------------------------------- +// Gateway jobs (per-chat-completion, settled on JobClaimed) +// ----------------------------------------------------------------------------- + +model GatewayJob { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Routing + routingId String @unique // local routing id (keccak of request hash etc.) + onChainJobId String? @unique // bytes32 from JobEscrow.JobPosted + requestHash String? // Swarm chunk ref of encrypted request + responseHash String? // Swarm chunk ref of encrypted response + + // Counterparties / model + provider String // 0x address selected by the gateway + modelId String + + // Billing (xBZZ-denominated; markup baked in) + estimatedMaxXbzzWei Decimal @db.Decimal(78, 0) + actualXbzzWei Decimal? @db.Decimal(78, 0) + + // Lifecycle + status GatewayJobStatus @default(posted) + postedAt DateTime @default(now()) + ackedAt DateTime? + deliveredAt DateTime? + claimedAt DateTime? + ackDeadline DateTime? + deliveryDeadline DateTime? + + // Token accounting (from JobClaimed actualPayment) + promptTokens Int? + completionTokens Int? + + // Optional payload retention (off by default; T4T_PERSIST_PAYLOADS-style) + promptRedacted String? + responseRedacted String? + errorMessage String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + ledgerEntries LedgerEntry[] + + @@index([userId, createdAt(sort: Desc)]) + @@index([status]) + @@index([onChainJobId]) +} + +// ----------------------------------------------------------------------------- +// Stripe top-ups (Phase 2) +// ----------------------------------------------------------------------------- + +model Topup { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + status TopupStatus @default(requires_payment) + + stripePaymentIntent String @unique + amountUsdCents Int + + creditedXbzzWei Decimal? @db.Decimal(78, 0) + usdPerXbzzAtCredit Decimal? @db.Decimal(18, 8) + + error String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + ledgerEntries LedgerEntry[] + + @@index([userId, createdAt(sort: Desc)]) + @@index([status]) +} diff --git a/prisma/seed/index.ts b/prisma/seed/index.ts new file mode 100644 index 0000000..45aad97 --- /dev/null +++ b/prisma/seed/index.ts @@ -0,0 +1,18 @@ +// Phase 1 has no seed data — accounts are created on signup. +// Placeholder for future Config table (e.g. operator metadata) or test fixtures. +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('No seed data to insert in v1.'); +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/accounts/ledger.ts b/src/accounts/ledger.ts new file mode 100644 index 0000000..ff3e1d1 --- /dev/null +++ b/src/accounts/ledger.ts @@ -0,0 +1,145 @@ +import { Prisma } from '@prisma/client'; +import { prisma } from '../db'; +import { env } from '../env'; +import { logger } from '../logger'; + +/** + * Off-chain xBZZ ledger for tenant accounts. All deltas live in `LedgerEntry` + * (append-only, idempotent by `idempotencyKey`); `Account.balanceXbzzWei` and + * `Account.reservedXbzzWei` are denormalized running totals updated in the + * same transaction. + * + * Reservations are tracked as `Account.reservedXbzzWei` (a field on the + * account), not as ledger rows — the ledger means "money moved" not "money + * locked". On `JobClaimed` the reservation is released AND a `settle` ledger + * row debits the actual cost. + */ + +export class InsufficientBalanceError extends Error { + constructor() { + super('Insufficient xBZZ credit'); + } +} + +export async function creditWelcomeGrant(accountId: string): Promise { + const key = `welcome:${accountId}`; + try { + await prisma.$transaction(async (tx) => { + await tx.ledgerEntry.create({ + data: { + accountId, + kind: 'welcomeGrant', + deltaXbzzWei: new Prisma.Decimal(env.WELCOME_XBZZ_WEI), + idempotencyKey: key, + }, + }); + await tx.account.update({ + where: { id: accountId }, + data: { balanceXbzzWei: { increment: new Prisma.Decimal(env.WELCOME_XBZZ_WEI) } }, + }); + }); + logger.info({ accountId, xbzz: env.WELCOME_XBZZ_WEI }, 'welcome grant credited'); + } catch (err: any) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + // Unique constraint violation on idempotencyKey — already credited. + logger.debug({ accountId }, 'welcome grant already credited (idempotent)'); + return; + } + throw err; + } +} + +/** + * Reserve xBZZ for an in-flight chat completion. Atomic with `SELECT FOR UPDATE` + * to prevent two concurrent requests from over-spending the same user's balance. + * Throws `InsufficientBalanceError` if available < amount. + */ +export async function reserveXbzz(userId: string, amountWei: bigint): Promise { + await prisma.$transaction(async (tx) => { + // Postgres `SELECT FOR UPDATE` via raw SQL — Prisma's `update` is also + // atomic but doesn't expose the row-level lock semantics we want. + const rows = await tx.$queryRaw< + { id: string; balance_xbzz_wei: string; reserved_xbzz_wei: string; status: string }[] + >`SELECT id, "balanceXbzzWei" AS balance_xbzz_wei, "reservedXbzzWei" AS reserved_xbzz_wei, status::text AS status + FROM "Account" WHERE "userId" = ${userId} FOR UPDATE`; + const acc = rows[0]; + if (!acc) throw new Error('Account not found'); + if (acc.status !== 'active') throw new Error(`Account is ${acc.status}`); + + const balance = BigInt(acc.balance_xbzz_wei); + const reserved = BigInt(acc.reserved_xbzz_wei); + if (balance - reserved < amountWei) { + throw new InsufficientBalanceError(); + } + await tx.account.update({ + where: { id: acc.id }, + data: { reservedXbzzWei: { increment: new Prisma.Decimal(amountWei.toString()) } }, + }); + }); +} + +/** + * Settle a completed job: release the reservation and debit the actual cost. + * Idempotent via the `settle:` ledger key. + */ +export async function settleXbzz( + userId: string, + gatewayJobId: string, + estimateXbzzWei: bigint, + actualXbzzWei: bigint, +): Promise { + const key = `settle:${gatewayJobId}`; + try { + await prisma.$transaction(async (tx) => { + await tx.ledgerEntry.create({ + data: { + accountId: (await tx.account.findUniqueOrThrow({ where: { userId }, select: { id: true } })).id, + kind: 'settle', + deltaXbzzWei: new Prisma.Decimal((-actualXbzzWei).toString()), + gatewayJobId, + idempotencyKey: key, + }, + }); + await tx.account.update({ + where: { userId }, + data: { + balanceXbzzWei: { decrement: new Prisma.Decimal(actualXbzzWei.toString()) }, + reservedXbzzWei: { decrement: new Prisma.Decimal(estimateXbzzWei.toString()) }, + }, + }); + }); + } catch (err: any) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + logger.debug({ gatewayJobId }, 'settle already recorded (idempotent)'); + return; + } + throw err; + } +} + +/** + * Release a reservation without debiting (job cancelled or timed out). + * Idempotent via `release:` — but we don't write a ledger row, + * we only adjust `reservedXbzzWei` and rely on the GatewayJob status + * transition to mark the release as having happened. + */ +export async function releaseReservation( + userId: string, + gatewayJobId: string, + estimateXbzzWei: bigint, +): Promise { + await prisma.$transaction(async (tx) => { + const job = await tx.gatewayJob.findUniqueOrThrow({ + where: { id: gatewayJobId }, + select: { status: true }, + }); + // Only release once — `cancelled` / `timed_out` / `failed` are terminal. + if (job.status === 'cancelled' || job.status === 'timed_out' || job.status === 'failed') { + return; + } + await tx.account.update({ + where: { userId }, + data: { reservedXbzzWei: { decrement: new Prisma.Decimal(estimateXbzzWei.toString()) } }, + }); + }); +} diff --git a/src/accounts/processor.ts b/src/accounts/processor.ts new file mode 100644 index 0000000..45dadce --- /dev/null +++ b/src/accounts/processor.ts @@ -0,0 +1,36 @@ +import type { Job } from 'bullmq'; +import type { Account } from '@prisma/client'; +import { BaseProcessor } from '../queue/processor'; +import { prisma } from '../db'; +import { creditWelcomeGrant } from './ledger'; +import { logger } from '../logger'; + +const ACCOUNT_FIELDS = ['status', 'balanceXbzzWei', 'reservedXbzzWei'] as const; + +class AccountsProcessor extends BaseProcessor { + constructor() { + super('account', ACCOUNT_FIELDS); + } + + /** + * On account creation, credit the welcome grant — but only if the user's + * email is verified (Google sub is verified by default; email-signup users + * must complete the verify link first). + */ + protected async onCreated(_job: Job, account: Account): Promise { + const user = await prisma.user.findUniqueOrThrow({ + where: { id: account.userId }, + select: { emailVerified: true }, + }); + if (!user.emailVerified) { + logger.info( + { accountId: account.id, userId: account.userId }, + 'welcome grant deferred — email not verified', + ); + return; + } + await creditWelcomeGrant(account.id); + } +} + +export const accountsProcessor = new AccountsProcessor(); diff --git a/src/accounts/routes.ts b/src/accounts/routes.ts new file mode 100644 index 0000000..3803ce0 --- /dev/null +++ b/src/accounts/routes.ts @@ -0,0 +1,61 @@ +import Elysia from 'elysia'; +import { prisma } from '../db'; +import { jwtGuard } from '../auth/jwt'; +import { getUsdEquivalent } from '../pricing/usdDisplay'; + +export const accountsRoutes = new Elysia({ prefix: '/accounts' }) + .use(jwtGuard) + .get('/me', async ({ user }) => { + const account = await prisma.account.findUniqueOrThrow({ + where: { userId: user.userId }, + select: { + id: true, + status: true, + balanceXbzzWei: true, + reservedXbzzWei: true, + createdAt: true, + updatedAt: true, + }, + }); + + const balanceWei = BigInt(account.balanceXbzzWei.toString()); + const reservedWei = BigInt(account.reservedXbzzWei.toString()); + const available = balanceWei - reservedWei; + + const balanceUsd = await getUsdEquivalent(balanceWei).catch(() => null); + const availableUsd = await getUsdEquivalent(available).catch(() => null); + + return { + id: account.id, + status: account.status, + balanceXbzzWei: account.balanceXbzzWei.toString(), + reservedXbzzWei: account.reservedXbzzWei.toString(), + availableXbzzWei: available.toString(), + balanceUsd, // cosmetic; null if oracle unavailable + availableUsd, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + }; + }) + .get('/me/ledger', async ({ user, query }) => { + const limit = Math.min(Number(query.limit ?? 50), 200); + const account = await prisma.account.findUniqueOrThrow({ + where: { userId: user.userId }, + select: { id: true }, + }); + const entries = await prisma.ledgerEntry.findMany({ + where: { accountId: account.id }, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + kind: true, + deltaXbzzWei: true, + createdAt: true, + gatewayJobId: true, + topupId: true, + note: true, + }, + }); + return entries.map((e) => ({ ...e, deltaXbzzWei: e.deltaXbzzWei.toString() })); + }); diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..3f4127c --- /dev/null +++ b/src/api.ts @@ -0,0 +1,42 @@ +import { Elysia } from 'elysia'; +import { cors } from '@elysiajs/cors'; +import { swagger } from '@elysiajs/swagger'; +import { env } from './env'; +import { logger } from './logger'; +import { authRoutes } from './auth/routes'; +import { usersRoutes } from './users/routes'; +import { accountsRoutes } from './accounts/routes'; +import { apiKeysRoutes } from './apiKeys/routes'; +import { gatewayRoutes } from './gateway/routes'; +import { getGatewayContext } from './gateway/context'; + +const app = new Elysia() + .use(cors()) + .use(swagger()) + .onError(({ error, set }) => { + const status = set.status ?? 500; + const message = (error as Error).message ?? 'Internal error'; + logger.warn({ status, message }, 'request errored'); + return { error: message }; + }) + .get('/health', () => ({ ok: true, ts: Date.now() })) + .use(authRoutes) + .use(usersRoutes) + .use(accountsRoutes) + .use(apiKeysRoutes) + .use(gatewayRoutes); + +// Start the HTTP server immediately so auth / accounts / api-keys routes are +// reachable without waiting on Bee + Gnosis RPC. The gateway context is booted +// lazily (in the background) — if it fails, only `/v1/*` routes degrade. This +// makes auth-only test runs decouple from the chain/Bee stack. +app.listen(env.HOSTED_GATEWAY_PORT, () => { + logger.info({ port: env.HOSTED_GATEWAY_PORT }, 'hosted gateway api ready'); +}); + +getGatewayContext().catch((err) => { + logger.error( + { err: err.message }, + 'gateway context boot failed — /v1 chat completions will return 503 until this is resolved', + ); +}); diff --git a/src/apiKeys/routes.ts b/src/apiKeys/routes.ts new file mode 100644 index 0000000..ab6a2a4 --- /dev/null +++ b/src/apiKeys/routes.ts @@ -0,0 +1,60 @@ +import Elysia, { t } from 'elysia'; +import { prisma } from '../db'; +import { jwtGuard } from '../auth/jwt'; + +export const apiKeysRoutes = new Elysia({ prefix: '/api-keys' }) + .use(jwtGuard) + .get('/', async ({ user }) => { + const keys = await prisma.apiKey.findMany({ + where: { userId: user.userId }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + createdAt: true, + lastUsedAt: true, + scopeMaxXbzzPerDay: true, + modelAllowlist: true, + }, + }); + // Never echo the secret value after creation. + return keys.map((k) => ({ + ...k, + scopeMaxXbzzPerDay: k.scopeMaxXbzzPerDay?.toString() ?? null, + })); + }) + .post( + '/', + async ({ user, body }) => { + const created = await prisma.apiKey.create({ + data: { + userId: user.userId, + name: body.name ?? '', + }, + }); + // Return the full key string ONCE; this is the only chance the user has + // to capture it. The dashboard MUST surface a "copy" affordance. + return { + id: created.id, + name: created.name, + key: `t4t-live-${created.key}`, + createdAt: created.createdAt, + }; + }, + { + body: t.Object({ name: t.Optional(t.String()) }), + }, + ) + .delete( + '/:id', + async ({ user, params, set }) => { + const row = await prisma.apiKey.findUnique({ where: { id: params.id } }); + if (!row || row.userId !== user.userId) { + set.status = 404; + throw new Error('API key not found'); + } + await prisma.apiKey.delete({ where: { id: params.id } }); + return { ok: true }; + }, + { params: t.Object({ id: t.String() }) }, + ); diff --git a/src/auth/google.ts b/src/auth/google.ts new file mode 100644 index 0000000..285516c --- /dev/null +++ b/src/auth/google.ts @@ -0,0 +1,37 @@ +import { OAuth2Client } from 'google-auth-library'; +import { env } from '../env'; + +const client = new OAuth2Client(); + +export interface GoogleProfile { + sub: string; // Google's stable user id + email: string; + emailVerified: boolean; + name?: string; + picture?: string; +} + +/** + * Verifies a Google ID token (from the One Tap / Sign in with Google flow on + * the client) and returns the verified profile. Throws on invalid token. + */ +export async function verifyGoogleIdToken(idToken: string): Promise { + if (!env.GOOGLE_OAUTH_CLIENT_ID) { + throw new Error('GOOGLE_OAUTH_CLIENT_ID is not configured'); + } + const ticket = await client.verifyIdToken({ + idToken, + audience: env.GOOGLE_OAUTH_CLIENT_ID, + }); + const payload = ticket.getPayload(); + if (!payload?.sub || !payload.email) { + throw new Error('Google id token missing required claims'); + } + return { + sub: payload.sub, + email: payload.email, + emailVerified: payload.email_verified === true, + name: payload.name, + picture: payload.picture, + }; +} diff --git a/src/auth/jwt.ts b/src/auth/jwt.ts new file mode 100644 index 0000000..e538c7b --- /dev/null +++ b/src/auth/jwt.ts @@ -0,0 +1,75 @@ +import jwt from 'jsonwebtoken'; +import Elysia from 'elysia'; +import { prisma } from '../db'; +import { env } from '../env'; +import type { UserRole } from '@prisma/client'; + +export interface JwtPayload { + userId: string; + role: UserRole; + email: string; +} + +export function signToken(payload: JwtPayload): string { + return jwt.sign(payload, env.JWT_SECRET_KEY, { expiresIn: '7d' }); +} + +export function verifyToken(token: string): JwtPayload { + return jwt.verify(token, env.JWT_SECRET_KEY) as JwtPayload; +} + +function extractBearer(header: string | null | undefined): string | null { + if (!header) return null; + const [scheme, token] = header.split(' '); + if (scheme !== 'Bearer' || !token) return null; + return token; +} + +/** + * Elysia plugin: resolves the authenticated user from a JWT or API key. + * Throws 401 on missing or invalid credentials. Mirrors + * cipherdolls/core/src/auth/jwt.ts (Bearer → JWT, then API-key fallback). + */ +export const jwtGuard = new Elysia({ name: 'jwtGuard' }).derive( + { as: 'scoped' }, + async ({ headers, set }) => { + const token = extractBearer(headers.authorization); + if (!token) { + set.status = 401; + throw new Error('Missing authorization token'); + } + + // Try JWT first + try { + const payload = verifyToken(token); + return { user: payload }; + } catch { + // not a valid JWT — fall through + } + + // Try API key (matches against the raw `t4t-live-` value) + const stripped = token.startsWith('t4t-live-') ? token.slice('t4t-live-'.length) : token; + const apiKey = await prisma.apiKey.findUnique({ + where: { key: stripped }, + include: { user: true }, + }); + + if (!apiKey) { + set.status = 401; + throw new Error('Invalid authorization token'); + } + + // Touch lastUsedAt; fire-and-forget. + prisma.apiKey + .update({ where: { id: apiKey.id }, data: { lastUsedAt: new Date() } }) + .catch(() => undefined); + + return { + user: { + userId: apiKey.user.id, + role: apiKey.user.role, + email: apiKey.user.email, + } satisfies JwtPayload, + }; + }, +); diff --git a/src/auth/routes.ts b/src/auth/routes.ts new file mode 100644 index 0000000..e7b05ed --- /dev/null +++ b/src/auth/routes.ts @@ -0,0 +1,139 @@ +import Elysia, { t } from 'elysia'; +import argon2 from 'argon2'; +import { randomBytes } from 'crypto'; +import { prisma, model } from '../db'; +import { signToken } from './jwt'; +import { verifyGoogleIdToken } from './google'; +import { logger } from '../logger'; + +const ECHO_VERIFICATION_TOKEN = process.env.NODE_ENV !== 'production'; + +export const authRoutes = new Elysia({ prefix: '/auth' }) + .post( + '/google', + async ({ body, set }) => { + const profile = await verifyGoogleIdToken(body.idToken).catch((err) => { + logger.warn({ err: err.message }, 'google id token verification failed'); + set.status = 401; + throw new Error('Invalid Google id token'); + }); + + // Upsert user + let user = await prisma.user.findUnique({ where: { googleSub: profile.sub } }); + if (!user) { + // Use model.user.create so the user.created queue job fires (which + // triggers wallet/account creation + welcome grant). + user = await model.user.create({ + data: { + email: profile.email, + emailVerified: profile.emailVerified, + authProvider: 'google', + googleSub: profile.sub, + }, + }); + } + + return { + token: signToken({ userId: user.id, role: user.role, email: user.email }), + user: { id: user.id, email: user.email, role: user.role }, + }; + }, + { + body: t.Object({ idToken: t.String() }), + }, + ) + + .post( + '/email/signup', + async ({ body, set }) => { + const existing = await prisma.user.findUnique({ where: { email: body.email } }); + if (existing) { + set.status = 409; + throw new Error('Email already registered'); + } + const passwordHash = await argon2.hash(body.password); + const verificationToken = randomBytes(32).toString('hex'); + const user = await model.user.create({ + data: { + email: body.email, + emailVerified: false, + authProvider: 'email', + passwordHash, + verificationToken, + }, + }); + + // TODO Phase 1.5: real outbound mailer. In dev/test we echo the token + // back so the client (or test harness) can finish the flow synchronously. + logger.info( + { userId: user.id, echoed: ECHO_VERIFICATION_TOKEN }, + 'email signup — verification token issued', + ); + + return { + token: signToken({ userId: user.id, role: user.role, email: user.email }), + user: { id: user.id, email: user.email, role: user.role, emailVerified: false }, + verificationToken: ECHO_VERIFICATION_TOKEN ? verificationToken : undefined, + }; + }, + { + body: t.Object({ + email: t.String({ format: 'email' }), + password: t.String({ minLength: 8 }), + }), + }, + ) + + .post( + '/email/verify', + async ({ body, set }) => { + const user = await prisma.user.findUnique({ + where: { verificationToken: body.token }, + }); + if (!user) { + set.status = 400; + throw new Error('Invalid or expired verification token'); + } + if (user.emailVerified) { + return { ok: true, alreadyVerified: true }; + } + // Flip via model.user.update so the user.updated job fires; the accounts + // processor watches the `emailVerified` field and credits the welcome + // grant when it flips false → true. + await model.user.update( + { + where: { id: user.id }, + data: { emailVerified: true, verificationToken: null }, + }, + user, + ); + return { ok: true }; + }, + { body: t.Object({ token: t.String() }) }, + ) + + .post( + '/email/login', + async ({ body, set }) => { + const user = await prisma.user.findUnique({ where: { email: body.email } }); + if (!user || !user.passwordHash) { + set.status = 401; + throw new Error('Invalid credentials'); + } + const ok = await argon2.verify(user.passwordHash, body.password); + if (!ok) { + set.status = 401; + throw new Error('Invalid credentials'); + } + return { + token: signToken({ userId: user.id, role: user.role, email: user.email }), + user: { id: user.id, email: user.email, role: user.role, emailVerified: user.emailVerified }, + }; + }, + { + body: t.Object({ + email: t.String({ format: 'email' }), + password: t.String(), + }), + }, + ); diff --git a/src/chain/balanceMonitor.ts b/src/chain/balanceMonitor.ts new file mode 100644 index 0000000..900a15b --- /dev/null +++ b/src/chain/balanceMonitor.ts @@ -0,0 +1,56 @@ +/** + * Polls the operator wallet's xBZZ + xDAI balances and alerts on low headroom. + * The operator wallet backs ALL virtual xBZZ on user accounts; if its xBZZ + * float runs dry, every chat completion fails at `postJob`. xDAI runs gas. + * + * v1 just logs at WARN; Phase 2 wires a Slack / PagerDuty webhook. + */ +import { erc20Abi } from 'viem'; +import { logger } from '../logger'; +import { getGatewayContext } from '../gateway/context'; + +const POLL_INTERVAL_MS = 5 * 60_000; +const LOW_XBZZ_THRESHOLD_WEI = 100n * 10n ** 18n; // 100 xBZZ +const LOW_XDAI_THRESHOLD_WEI = 1n * 10n ** 17n; // 0.1 xDAI + +export function startBalanceMonitor(): void { + setInterval(() => { + poll().catch((err) => logger.warn({ err: err.message }, 'balance monitor tick failed')); + }, POLL_INTERVAL_MS).unref(); + // Fire once on startup so the first reading is in the logs immediately. + poll().catch((err) => logger.warn({ err: err.message }, 'initial balance monitor tick failed')); + logger.info('balance monitor started'); +} + +async function poll(): Promise { + const ctx = await getGatewayContext(); + const xbzz = await ctx.chain.pub.readContract({ + address: ctx.chain.xbzz, + abi: erc20Abi, + functionName: 'balanceOf', + args: [ctx.chain.address], + }); + const xdai = await ctx.chain.pub.getBalance({ address: ctx.chain.address }); + + logger.info( + { + operator: ctx.chain.address, + xbzzWei: (xbzz as bigint).toString(), + xdaiWei: xdai.toString(), + }, + 'operator wallet balances', + ); + + if ((xbzz as bigint) < LOW_XBZZ_THRESHOLD_WEI) { + logger.warn( + { xbzzWei: (xbzz as bigint).toString(), thresholdWei: LOW_XBZZ_THRESHOLD_WEI.toString() }, + 'operator xBZZ balance is low — refill the float', + ); + } + if (xdai < LOW_XDAI_THRESHOLD_WEI) { + logger.warn( + { xdaiWei: xdai.toString(), thresholdWei: LOW_XDAI_THRESHOLD_WEI.toString() }, + 'operator xDAI balance is low — refill for gas', + ); + } +} diff --git a/src/chain/claimWatcher.ts b/src/chain/claimWatcher.ts new file mode 100644 index 0000000..73ad7d8 --- /dev/null +++ b/src/chain/claimWatcher.ts @@ -0,0 +1,108 @@ +/** + * Polls `JobEscrow.JobClaimed` events (the on-chain source of truth for + * `actualPayment`) and settles the off-chain ledger by joining on + * `GatewayJob.onChainJobId`. + * + * Leader-locked via `withLock('watcher:claim')` so multiple worker replicas + * don't double-settle. Uses `getContractEvents` over a sliding [last, current] + * block window — survives stateless public RPCs that forget filter ids + * (rpc.gnosischain.com behavior). + */ +import type { Hex } from 'viem'; +import { jobEscrowAbi } from '../t4t/lib/abi'; +import { withLock } from '../queue/lock'; +import { redisConnection } from '../queue/connection'; +import { logger } from '../logger'; +import { prisma } from '../db'; +import { settleXbzz } from '../accounts/ledger'; +import { applyMarkup } from '../pricing/estimate'; +import { getGatewayContext } from '../gateway/context'; + +const POLL_INTERVAL_MS = 10_000; +const LEADER_LOCK = 'watcher:claim'; +const LEADER_TTL_MS = 30_000; +const LAST_BLOCK_KEY = 'watcher:claim:lastBlock'; + +export function startClaimWatcher(): void { + setInterval(() => { + tick().catch((err) => logger.warn({ err: err.message }, 'claimWatcher tick failed')); + }, POLL_INTERVAL_MS).unref(); + logger.info('claimWatcher started'); +} + +async function tick(): Promise { + // Run only if we hold the leader lock; otherwise another replica is the + // active watcher. withLock retries internally, which is fine — the interval + // tick is idempotent. + await withLock(LEADER_LOCK, doPoll, LEADER_TTL_MS); +} + +async function doPoll(): Promise { + const ctx = await getGatewayContext(); + const chain = ctx.chain; + + const stored = await redisConnection.get(LAST_BLOCK_KEY); + let lastBlock: bigint; + if (stored) { + lastBlock = BigInt(stored); + } else { + lastBlock = await chain.pub.getBlockNumber(); + await redisConnection.set(LAST_BLOCK_KEY, lastBlock.toString()); + } + + const current = await chain.pub.getBlockNumber(); + if (current <= lastBlock) return; + + // Cap each batch to 5_000 blocks so we don't hammer the RPC after a long + // restart pause (5_000 blocks at ~5s/block ≈ 7 hours of lookback). + const toBlock = current > lastBlock + 5_000n ? lastBlock + 5_000n : current; + + // JobClaimed → settle ledger. The JobEscrow ABI only exposes JobPosted and + // JobClaimed events; cancel/timeout settlement is reconciled in-process by + // the failure timers in `gateway/context.ts`. A future contract revision + // can add `JobCancelled` / `JobTimedOut` and this watcher will pick them up + // — for now the only event we tail is JobClaimed. + const claimed = await chain.pub.getContractEvents({ + address: chain.escrow, + abi: jobEscrowAbi, + eventName: 'JobClaimed', + fromBlock: lastBlock + 1n, + toBlock, + }); + for (const ev of claimed) { + await handleClaimed(ev.args.jobId as Hex | undefined, ev.args.paid as bigint | undefined); + } + + await redisConnection.set(LAST_BLOCK_KEY, toBlock.toString()); +} + +async function handleClaimed(jobId: Hex | undefined, paid: bigint | undefined): Promise { + if (!jobId || paid === undefined) return; + const job = await prisma.gatewayJob.findUnique({ + where: { onChainJobId: jobId }, + select: { id: true, userId: true, status: true, estimatedMaxXbzzWei: true }, + }); + if (!job) { + logger.debug({ jobId }, 'JobClaimed for unknown onChainJobId — skipping'); + return; + } + if (job.status === 'claimed') return; // idempotent + + const estimateWithMarkup = BigInt(job.estimatedMaxXbzzWei.toString()); + const actualWithMarkup = applyMarkup(paid); + + await settleXbzz(job.userId, job.id, estimateWithMarkup, actualWithMarkup); + await prisma.gatewayJob.update({ + where: { id: job.id }, + data: { + status: 'claimed', + actualXbzzWei: actualWithMarkup.toString(), + claimedAt: new Date(), + }, + }); + logger.info( + { gatewayJobId: job.id, userId: job.userId, paid: paid.toString(), actualWithMarkup: actualWithMarkup.toString() }, + 'JobClaimed settled', + ); +} + diff --git a/src/chain/operatorChain.ts b/src/chain/operatorChain.ts new file mode 100644 index 0000000..0356338 --- /dev/null +++ b/src/chain/operatorChain.ts @@ -0,0 +1,47 @@ +/** + * Operator-wallet chain writes, serialized via `withLock('chain:operator')`. + * + * One operator wallet = one nonce stream. Many concurrent users hitting + * `/v1/chat/completions` means many `postJob` / `ensureAllowance` calls fan in; + * without serialization, viem submits them concurrently and the second tx + * collides on nonce. The lock holds only for submission (until the tx hash + * comes back), not until the receipt — so this never bottlenecks PSS-flight or + * Swarm transfers. + */ +import type { Hex } from 'viem'; +import type { Address } from 'viem'; +import type { ChainClient } from '../t4t/lib/chain'; +import { + cancelJob as cancelJobRaw, + ensureAllowance as ensureAllowanceRaw, + postJob as postJobRaw, + timeoutJob as timeoutJobRaw, +} from '../t4t/lib/chain'; +import { withLock } from '../queue/lock'; + +const OPERATOR_LOCK = 'chain:operator'; + +export function ensureAllowance(c: ChainClient, spender: Address, needed: bigint): Promise { + return withLock(OPERATOR_LOCK, () => ensureAllowanceRaw(c, spender, needed)); +} + +export function postJob( + c: ChainClient, + args: { + provider: Address; + requestHash: Hex; + modelId: string; + maxPayment: bigint; + deliveryDeadline: number; + }, +): Promise<{ txHash: Hex; jobId: Hex }> { + return withLock(OPERATOR_LOCK, () => postJobRaw(c, args)); +} + +export function cancelJob(c: ChainClient, jobId: Hex): Promise { + return withLock(OPERATOR_LOCK, () => cancelJobRaw(c, jobId)); +} + +export function timeoutJob(c: ChainClient, jobId: Hex): Promise { + return withLock(OPERATOR_LOCK, () => timeoutJobRaw(c, jobId)); +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..2709973 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,34 @@ +// `model..{create,update,delete}` wrappers that persist via Prisma AND +// enqueue a BullMQ job atomically. Raw reads still go through `prisma.x`. +// +// Pattern ported from cipherdolls/core/src/db.ts. + +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { createCudService } from './queue/service'; +import { env } from './env'; + +// Make BigInt JSON-serializable (used by BullMQ when stringifying job payloads). +// Safe because every BigInt we send through queues is xBZZ wei or token counts, +// neither of which loses precision when stringified. +(BigInt.prototype as any).toJSON = function () { + return this.toString(); +}; + +// Prisma 7 ships the query engine as a driver adapter — `@prisma/adapter-pg` +// wraps node-postgres. The schema no longer carries a `datasource.url`; CLI +// tools (migrate, generate, db push) read from prisma.config.ts, while the +// runtime client receives the adapter here. +const adapter = new PrismaPg({ connectionString: env.DATABASE_URL }); +export const prisma = new PrismaClient({ adapter }); + +// Each delegate is wrapped to: persist via Prisma → enqueue `created/updated/deleted`. +// The queue name MUST match the BullMQ worker registered in src/queue/startup.ts. +export const model = { + user: createCudService('user', prisma.user), + account: createCudService('account', prisma.account), + apiKey: createCudService('apiKey', prisma.apiKey), + ledgerEntry: createCudService('ledgerEntry', prisma.ledgerEntry), + gatewayJob: createCudService('gatewayJob', prisma.gatewayJob), + topup: createCudService('topup', prisma.topup), +}; diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..b7f4cd1 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,85 @@ +import { z } from 'zod'; + +const HexAddress = z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, 'must be a 0x-prefixed 20-byte hex address'); + +const HexPrivateKey = z + .string() + .regex(/^0x[a-fA-F0-9]{64}$/, 'must be a 0x-prefixed 32-byte hex key'); + +const HexBatchId = z + .string() + .regex(/^[a-fA-F0-9]{64}$/, 'must be a 32-byte hex postage batch id') + .optional() + .or(z.literal('')); + +const BigIntString = z + .string() + .regex(/^\d+$/, 'must be a non-negative integer (base 10)'); + +const EnvSchema = z.object({ + // Postgres / Redis + DATABASE_URL: z.string().url(), + REDIS_HOST: z.string().default('localhost'), + REDIS_PORT: z.coerce.number().int().positive().default(6379), + + // Auth + JWT_SECRET_KEY: z.string().min(16, 'JWT_SECRET_KEY must be at least 16 chars'), + GOOGLE_OAUTH_CLIENT_ID: z.string().optional(), + + // Operator wallet (the only on-chain identity) + OPERATOR_PRIVATE_KEY: HexPrivateKey, + GNOSIS_RPC_URL: z.string().url(), + + // T4T protocol contract addresses (on Gnosis Chain) + REGISTRY_ADDRESS: HexAddress, + ESCROW_ADDRESS: HexAddress, + XBZZ_ADDRESS: HexAddress, + + // Bee node + postage + BEE_API_URL: z.string().url(), + BEE_POSTAGE_BATCH_ID: HexBatchId, + BEE_STAMP_MANAGE: z + .union([z.string(), z.boolean()]) + .transform((v) => v === true || v === 'true' || v === '1') + .default('false'), + BEE_STAMP_LABEL: z.string().default('hosted-gateway'), + BEE_STAMP_TTL_DAYS: z.coerce.number().int().positive().default(30), + + // Pricing + MARKUP_BPS: z.coerce.number().int().min(0).max(10_000).default(2_000), + WELCOME_XBZZ_WEI: BigIntString.default('50000000000000000000'), // 50 xBZZ + + // Concurrency + MAX_CONCURRENT_JOBS: z.coerce.number().int().positive().default(200), + + // Server + HOSTED_GATEWAY_PORT: z.coerce.number().int().positive().default(8080), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), + + // Phase 2 — Stripe (optional) + STRIPE_SECRET_KEY: z.string().optional(), + STRIPE_WEBHOOK_SECRET: z.string().optional(), +}); + +export type Env = z.infer & { + WELCOME_XBZZ_WEI_BIGINT: bigint; +}; + +function loadEnv(): Env { + const parsed = EnvSchema.safeParse(process.env); + if (!parsed.success) { + console.error('Invalid environment configuration:'); + for (const issue of parsed.error.issues) { + console.error(` ${issue.path.join('.')}: ${issue.message}`); + } + process.exit(1); + } + return { + ...parsed.data, + WELCOME_XBZZ_WEI_BIGINT: BigInt(parsed.data.WELCOME_XBZZ_WEI), + }; +} + +export const env: Env = loadEnv(); diff --git a/src/gateway/context.ts b/src/gateway/context.ts new file mode 100644 index 0000000..afaa9e4 --- /dev/null +++ b/src/gateway/context.ts @@ -0,0 +1,335 @@ +/** + * Boots singleton state for the gateway engine: ChainClient (operator wallet), + * Bee + PssTransport, postage batch, ECIES cipher with operator PSS keypair, + * ModelDiscovery cache, and the in-memory `pending` / `failureTimers` / + * `jobMeta` maps that `handleChat` resolves against. + * + * Called once at startup from `src/api.ts`. The PSS subscription is started + * here so `job_ack` / `job_deliver` envelopes flow into `pending` / DB rows. + */ +import { Bee } from '@ethersphere/bee-js'; +import { keccak256, toBytes, type Hex } from 'viem'; +import { makeChain, type ChainClient } from '../t4t/lib/chain'; +import { discoverUsableBatchId, downloadChunk, PssTransport } from '../t4t/lib/swarm'; +import { ensureManagedStamp, topUpIfBelow, type ManagedStamp } from '../t4t/lib/stamps'; +import { clientTopic } from '../t4t/lib/envelope'; +import { EciesCipher, jsonDecrypt } from '../t4t/lib/crypto'; +import { generatePssKeypair } from '../t4t/lib/keys'; +import { ModelDiscovery } from '../t4t/modes/gateway/models'; +import type { + JobAckBody, + JobDeliverBody, + OpenAIChatResponse, + ResponsePayload, +} from '../t4t/lib/types'; +import { cancelJob, timeoutJob } from '../chain/operatorChain'; +import { env } from '../env'; +import { logger } from '../logger'; +import { prisma } from '../db'; +import { releaseReservation } from '../accounts/ledger'; +import { ACK_WINDOW_SECONDS } from '../t4t/lib/chain'; + +const FAILURE_GRACE_SECONDS = 5; + +export interface PendingSlot { + resolve: (r: OpenAIChatResponse) => void; + reject: (e: unknown) => void; +} + +export interface FailureSlot { + onChainJobId: Hex; + deliveryDeadline: number; + cancelTimer?: ReturnType; + timeoutTimer?: ReturnType; +} + +export interface JobMetaSlot { + userId: string; + gatewayJobId: string; + estimateMaxXbzzWei: bigint; + provider: string; + modelId: string; +} + +export interface GatewayContext { + bee: Bee; + chain: ChainClient; + postageBatchId: string; + cipher: EciesCipher; + pss: PssTransport; + pssPubKeyX: Hex; + selfOverlay: Hex; + discovery: ModelDiscovery; + pending: Map; + failureTimers: Map; + jobMeta: Map; + clearFailureTimers: (routing: Hex) => void; + rejectAndCleanup: (routing: Hex, err: Error) => void; + scheduleAckTimeout: (routing: Hex, slot: FailureSlot) => void; +} + +let bootPromise: Promise | null = null; + +export function getGatewayContext(): Promise { + if (!bootPromise) bootPromise = bootGatewayContext(); + return bootPromise; +} + +async function bootGatewayContext(): Promise { + const bee = new Bee(env.BEE_API_URL); + + // Resolve the operator's postage batch (env-pinned, managed, or discovered). + const postageBatchId = await resolvePostageBatch(bee); + + const chain = makeChain({ + rpcUrl: env.GNOSIS_RPC_URL, + privateKey: env.OPERATOR_PRIVATE_KEY as Hex, + registry: env.REGISTRY_ADDRESS as Hex, + escrow: env.ESCROW_ADDRESS as Hex, + xbzz: env.XBZZ_ADDRESS as Hex, + }); + + // Operator PSS keypair — non-persistent for now; tenants reuse this single + // operator key (providers see all jobs as coming from the same client PSS + // identity, which matches the on-chain wallet model). + const pssKeys = generatePssKeypair(); + + const cipher = new EciesCipher(pssKeys.privateKey); + const selfOverlay = await resolveSelfOverlay(bee); + const pss = new PssTransport({ bee, postageBatchId, logger, selfAddress: chain.address }); + + const discovery = new ModelDiscovery({ + chain, + minProvidersPerModel: 1, + cacheTtlSeconds: 30, + }); + + const pending = new Map(); + const failureTimers = new Map(); + const jobMeta = new Map(); + + function clearFailureTimers(routing: Hex): void { + const slot = failureTimers.get(routing); + if (!slot) return; + if (slot.cancelTimer) clearTimeout(slot.cancelTimer); + if (slot.timeoutTimer) clearTimeout(slot.timeoutTimer); + failureTimers.delete(routing); + } + + function rejectAndCleanup(routing: Hex, err: Error): void { + pending.get(routing)?.reject(err); + pending.delete(routing); + jobMeta.delete(routing); + clearFailureTimers(routing); + } + + async function onAckTimeout(routing: Hex): Promise { + const slot = failureTimers.get(routing); + const meta = jobMeta.get(routing); + if (!slot || !meta) return; + try { + const tx = await cancelJob(chain, slot.onChainJobId); + logger.warn({ tx, routing, onChainJobId: slot.onChainJobId }, 'no ACK — cancelled on-chain'); + await markGatewayJob(meta.gatewayJobId, 'cancelled', 'no PSS ack within ACK_WINDOW'); + await releaseReservation(meta.userId, meta.gatewayJobId, meta.estimateMaxXbzzWei); + rejectAndCleanup(routing, new Error('provider failed to ACK within window')); + } catch (err) { + logger.warn({ err, routing }, 'cancelJob failed — provider likely acked mid-flight'); + } + } + + async function onDeliveryTimeout(routing: Hex): Promise { + const slot = failureTimers.get(routing); + const meta = jobMeta.get(routing); + if (!slot || !meta) return; + try { + const tx = await timeoutJob(chain, slot.onChainJobId); + logger.warn({ tx, routing, onChainJobId: slot.onChainJobId }, 'delivery missed — timeoutJob'); + await markGatewayJob(meta.gatewayJobId, 'timed_out', 'no PSS delivery before deadline'); + await releaseReservation(meta.userId, meta.gatewayJobId, meta.estimateMaxXbzzWei); + rejectAndCleanup(routing, new Error('provider failed to deliver before deadline')); + return; + } catch (err) { + logger.warn({ err, routing }, 'timeoutJob reverted — falling back to cancelJob'); + } + try { + const tx = await cancelJob(chain, slot.onChainJobId); + logger.warn({ tx, routing, onChainJobId: slot.onChainJobId }, 'cancelJob fallback applied'); + await markGatewayJob(meta.gatewayJobId, 'cancelled', 'no PSS delivery; cancelJob fallback'); + await releaseReservation(meta.userId, meta.gatewayJobId, meta.estimateMaxXbzzWei); + rejectAndCleanup(routing, new Error('provider failed to deliver before deadline')); + } catch (err) { + logger.error({ err, routing }, 'both timeoutJob and cancelJob failed — job stuck in escrow'); + rejectAndCleanup(routing, new Error('delivery deadline missed; settlement failed')); + } + } + + function scheduleAckTimeout(routing: Hex, slot: FailureSlot): void { + slot.cancelTimer = setTimeout(() => { + onAckTimeout(routing).catch((err) => + logger.error({ err, routing }, 'onAckTimeout threw'), + ); + }, (ACK_WINDOW_SECONDS + FAILURE_GRACE_SECONDS) * 1000); + } + + // Subscribe to the operator's client topic — ALL tenants' jobs come back + // here (because all jobs go on-chain as the operator wallet). + pss.subscribe({ + topic: clientTopic(chain.address), + onEnvelope: async (envelope) => { + if (envelope.type === 'job_ack') { + const body = envelope.body as JobAckBody; + logger.info({ jobId: body.jobId, eta: body.estimatedCompletion }, 'ack received'); + const slot = failureTimers.get(body.jobId); + const meta = jobMeta.get(body.jobId); + if (slot) { + if (slot.cancelTimer) { + clearTimeout(slot.cancelTimer); + slot.cancelTimer = undefined; + } + if (!slot.timeoutTimer) { + const msUntilTimeout = Math.max( + 0, + (slot.deliveryDeadline + FAILURE_GRACE_SECONDS) * 1000 - Date.now(), + ); + slot.timeoutTimer = setTimeout(() => { + onDeliveryTimeout(body.jobId).catch((err) => + logger.error({ err, routing: body.jobId }, 'onDeliveryTimeout threw'), + ); + }, msUntilTimeout); + } + } + if (meta) { + await markGatewayJob(meta.gatewayJobId, 'acked', null, { ackedAt: new Date() }); + } + } else if (envelope.type === 'job_deliver') { + const body = envelope.body as JobDeliverBody; + const slot = pending.get(body.jobId); + const meta = jobMeta.get(body.jobId); + if (!slot) return; + try { + const bytes = await downloadChunk({ bee, postageBatchId, logger }, body.responseHash); + const payload = await jsonDecrypt(cipher, bytes); + if (meta) { + await markGatewayJob(meta.gatewayJobId, 'delivered', null, { + deliveredAt: new Date(), + responseHash: body.responseHash, + promptTokens: payload.openaiResponse.usage?.prompt_tokens ?? null, + completionTokens: payload.openaiResponse.usage?.completion_tokens ?? null, + }); + } + slot.resolve(payload.openaiResponse); + } catch (err) { + slot.reject(err); + } finally { + pending.delete(body.jobId); + jobMeta.delete(body.jobId); + clearFailureTimers(body.jobId); + } + } + }, + }); + + logger.info( + { + operator: chain.address, + postageBatchId, + pssPubKey: pssKeys.publicKeyX, + selfOverlay, + }, + 'gateway context booted', + ); + + return { + bee, + chain, + postageBatchId, + cipher, + pss, + pssPubKeyX: pssKeys.publicKeyX, + selfOverlay, + discovery, + pending, + failureTimers, + jobMeta, + clearFailureTimers, + rejectAndCleanup, + scheduleAckTimeout, + }; +} + +async function resolvePostageBatch(bee: Bee): Promise { + if (env.BEE_POSTAGE_BATCH_ID) return env.BEE_POSTAGE_BATCH_ID; + if (env.BEE_STAMP_MANAGE) { + const managed: ManagedStamp = await ensureManagedStamp({ + bee, + logger, + opts: { + depth: 22, + ttlDays: env.BEE_STAMP_TTL_DAYS, + minTtlDays: 7, + label: env.BEE_STAMP_LABEL, + dryRun: false, + }, + }); + // Re-check every 5 min (matches t4t-container's gateway tick). + setInterval(() => { + topUpIfBelow({ + bee, + logger, + batchId: managed.batchID, + ttlDays: env.BEE_STAMP_TTL_DAYS, + minTtlDays: 7, + maxUtilization: 0.7, + maxDepth: 28, + dryRun: false, + }).catch((err) => logger.warn({ err }, 'stamp tick failed')); + }, 300_000).unref(); + return managed.batchID; + } + const discovered = await discoverUsableBatchId(bee); + if (!discovered) { + throw new Error( + 'No usable postage batch on the Bee node. Set BEE_POSTAGE_BATCH_ID or BEE_STAMP_MANAGE=true.', + ); + } + return discovered; +} + +async function resolveSelfOverlay(bee: Bee): Promise { + const info = await bee.getNodeAddresses().catch(() => null); + const overlay = info?.overlay?.toString() ?? '00'.repeat(32); + return ('0x' + overlay.replace(/^0x/, '')) as Hex; +} + +/** Update a GatewayJob row. Direct prisma write (not model.gatewayJob.update) + * because we don't need fan-out for status transitions in v1. */ +async function markGatewayJob( + id: string, + status: + | 'posted' + | 'acked' + | 'delivered' + | 'claimed' + | 'cancelled' + | 'timed_out' + | 'failed', + errorMessage: string | null, + extra: Record = {}, +): Promise { + await prisma.gatewayJob + .update({ + where: { id }, + data: { + status, + errorMessage: errorMessage ?? undefined, + ...extra, + }, + }) + .catch((err) => logger.warn({ err: err.message, id, status }, 'gatewayJob update failed')); +} + +/** Util: derive the routing id (mirrors handleChat). Exposed for tests. */ +export function deriveRoutingId(requestHashHex: string): Hex { + return keccak256(toBytes('0x' + requestHashHex.replace(/^0x/, ''))); +} diff --git a/src/gateway/handleChat.ts b/src/gateway/handleChat.ts new file mode 100644 index 0000000..309a735 --- /dev/null +++ b/src/gateway/handleChat.ts @@ -0,0 +1,183 @@ +/** + * Refactor of t4t/container/src/modes/gateway/index.ts `handleChat`. The only + * functional change is that the persisted GatewayJob row carries `userId` and + * `estimatedMaxXbzzWei` so the claim watcher can settle the off-chain ledger + * against the right tenant. Provider selection, on-chain interaction, PSS + * notify, response wait — all unchanged. + */ +import { keccak256, toBytes, toHex, type Hex } from 'viem'; +import { jsonEncrypt } from '../t4t/lib/crypto'; +import { uploadChunk } from '../t4t/lib/swarm'; +import { signEnvelope, providerTopic } from '../t4t/lib/envelope'; +import { selectProvider } from '../t4t/modes/gateway/selector'; +import type { + JobNotifyBody, + OpenAIChatRequest, + OpenAIChatResponse, + RequestPayload, +} from '../t4t/lib/types'; +import { ACK_WINDOW_SECONDS } from '../t4t/lib/chain'; +import { ensureAllowance, postJob } from '../chain/operatorChain'; +import { prisma } from '../db'; +import { env } from '../env'; +import { logger } from '../logger'; +import { estimateMaxXbzz, type ChatRequest } from '../pricing/estimate'; +import { releaseReservation, reserveXbzz } from '../accounts/ledger'; +import type { GatewayContext, FailureSlot } from './context'; + +const PROTOCOL_VERSION = 1 as const; +const DEFAULT_DEADLINE_SECONDS = 300; + +export interface HandleChatArgs { + ctx: GatewayContext; + userId: string; + req: OpenAIChatRequest; +} + +export interface HandleChatResult { + gatewayJobId: string; + response: OpenAIChatResponse; +} + +export async function handleChat(args: HandleChatArgs): Promise { + const { ctx, userId, req } = args; + + // 1) Select provider (stateless registry scan against the operator's chain + // client; selector reads from chain on each call). + const target = await selectProvider(ctx.chain, 'top_rep_cheapest', { + modelId: req.model, + }); + if (!target) throw new Error(`no provider matches model=${req.model}`); + + // 2) Estimate cost — raw (posted on-chain as maxPayment) vs with-markup + // (reserved off-chain; the operator keeps the markup). + const { rawWei, withMarkupWei } = estimateMaxXbzz(req as ChatRequest, target.offering); + + // 3) Reserve off-chain BEFORE doing any chain work. If this throws (402), no + // on-chain side effect happened — fail-closed. + await reserveXbzz(userId, withMarkupWei); + + // 4) Allowance + on-chain postJob. Both serialized by `withLock('chain:operator')`. + let releasedReservation = false; + let gatewayJobId: string | null = null; + try { + await ensureAllowance(ctx.chain, ctx.chain.escrow, rawWei); + + const reqPayload: RequestPayload = { + v: PROTOCOL_VERSION, + jobId: toHex(crypto.getRandomValues(new Uint8Array(32))), + client: ctx.chain.address, + modelId: req.model, + openaiRequest: req, + clientPssPubKey: ctx.pssPubKeyX, + ts: Math.floor(Date.now() / 1000), + }; + const encrypted = await jsonEncrypt(ctx.cipher, target.provider.pssPublicKey, reqPayload); + const requestHash = await uploadChunk( + { bee: ctx.bee, postageBatchId: ctx.postageBatchId, logger }, + encrypted, + ); + + const deliveryDeadline = Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS; + const { txHash, jobId: onChainJobId } = await postJob(ctx.chain, { + provider: target.provider.owner, + requestHash: ('0x' + requestHash) as Hex, + modelId: req.model, + maxPayment: rawWei, + deliveryDeadline, + }); + logger.info({ txHash, onChainJobId, provider: target.provider.owner, userId }, 'job posted on-chain'); + + const jobIdRouting = keccak256(toBytes('0x' + requestHash)); + + // Persist the GatewayJob row (tenant attribution lives here). + const created = await prisma.gatewayJob.create({ + data: { + routingId: jobIdRouting, + onChainJobId, + requestHash, + provider: target.provider.owner, + modelId: req.model, + estimatedMaxXbzzWei: withMarkupWei.toString(), + status: 'posted', + postedAt: new Date(), + deliveryDeadline: new Date(deliveryDeadline * 1000), + userId, + }, + }); + gatewayJobId = created.id; + + ctx.jobMeta.set(jobIdRouting, { + userId, + gatewayJobId: created.id, + estimateMaxXbzzWei: withMarkupWei, + provider: target.provider.owner, + modelId: req.model, + }); + + // 5) PSS-notify the provider out-of-band (chain is the truth; PSS is + // only the wake-up). + const settled = new Promise((resolve, reject) => { + ctx.pending.set(jobIdRouting, { resolve, reject }); + }); + + const slot: FailureSlot = { onChainJobId, deliveryDeadline }; + ctx.failureTimers.set(jobIdRouting, slot); + ctx.scheduleAckTimeout(jobIdRouting, slot); + + const envelope = await signEnvelope( + { + from: ctx.chain.address, + to: target.provider.owner, + type: 'job_notify', + body: { + jobId: jobIdRouting, + requestHash, + modelId: req.model, + maxPayment: rawWei.toString(), + deliveryDeadline, + clientPssPubKey: ctx.pssPubKeyX, + clientSwarmOverlay: ctx.selfOverlay, + }, + }, + (msg) => ctx.chain.wallet.signMessage({ account: ctx.chain.wallet.account!, message: msg }), + ); + try { + await ctx.pss.send({ + topic: providerTopic(target.provider.owner), + recipientOverlay: target.provider.swarmOverlay, + recipientPssKey: target.provider.pssPublicKey, + envelope, + }); + } catch (err) { + // Orphan cleanup: drop the pending state synchronously so the ACK timer + // is a no-op when it fires, and release the reservation. + ctx.pending.delete(jobIdRouting); + ctx.jobMeta.delete(jobIdRouting); + ctx.clearFailureTimers(jobIdRouting); + await releaseReservation(userId, created.id, withMarkupWei); + releasedReservation = true; + throw err; + } + + const response = await settled; + return { gatewayJobId: created.id, response }; + } catch (err) { + // On any failure before / during dispatch, release the reservation. Skip + // if the orphan-cleanup branch above already did so. + if (!releasedReservation && gatewayJobId) { + await releaseReservation(userId, gatewayJobId, withMarkupWei).catch((e) => + logger.warn({ err: e.message, gatewayJobId }, 'release on failure failed'), + ); + } else if (!releasedReservation) { + // No GatewayJob row created yet — manually decrement reserved. + await prisma.account + .update({ + where: { userId }, + data: { reservedXbzzWei: { decrement: withMarkupWei.toString() } }, + }) + .catch((e) => logger.warn({ err: e.message, userId }, 'reserved decrement on failure failed')); + } + throw err; + } +} diff --git a/src/gateway/routes.ts b/src/gateway/routes.ts new file mode 100644 index 0000000..32bb3c3 --- /dev/null +++ b/src/gateway/routes.ts @@ -0,0 +1,110 @@ +import Elysia, { t } from 'elysia'; +import { jwtGuard } from '../auth/jwt'; +import { prisma } from '../db'; +import { handleChat } from './handleChat'; +import { getGatewayContext } from './context'; +import { Semaphore } from '../queue/semaphore'; +import { env } from '../env'; +import { logger } from '../logger'; +import { InsufficientBalanceError } from '../accounts/ledger'; + +const semaphore = new Semaphore(env.MAX_CONCURRENT_JOBS); + +export const gatewayRoutes = new Elysia() + .use(jwtGuard) + .get('/v1/models', async () => { + const ctx = await getGatewayContext(); + const summaries = await ctx.discovery.list(); + const created = Math.floor(Date.now() / 1000); + return { + object: 'list', + data: summaries.map((m) => ({ + id: m.id, + object: 'model' as const, + created, + owned_by: 't4t', + })), + }; + }) + .post( + '/v1/chat/completions', + async ({ body, user, set }) => { + if (!semaphore.tryAcquire()) { + set.status = 503; + set.headers['Retry-After'] = '5'; + throw new Error('Server busy — too many concurrent jobs in flight'); + } + + try { + const ctx = await getGatewayContext(); + const { gatewayJobId, response } = await handleChat({ + ctx, + userId: user.userId, + req: body as any, + }); + return response; + } catch (err: any) { + if (err instanceof InsufficientBalanceError) { + set.status = 402; + throw err; + } + logger.error({ err: err.message, userId: user.userId }, 'chat completion failed'); + set.status = 500; + throw err; + } finally { + semaphore.release(); + } + }, + { + // Pass through the OpenAI-style payload; we don't enforce the schema + // strictly — any unknown fields just ride along to the provider. + body: t.Object( + { + model: t.String(), + messages: t.Array( + t.Object({ + role: t.Union([ + t.Literal('system'), + t.Literal('user'), + t.Literal('assistant'), + t.Literal('tool'), + ]), + content: t.String(), + }), + ), + temperature: t.Optional(t.Number()), + max_tokens: t.Optional(t.Number()), + stream: t.Optional(t.Boolean()), + }, + { additionalProperties: true }, + ), + }, + ) + .get('/v1/jobs', async ({ user, query }) => { + const limit = Math.min(Number(query.limit ?? 50), 200); + const jobs = await prisma.gatewayJob.findMany({ + where: { userId: user.userId }, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + status: true, + modelId: true, + provider: true, + estimatedMaxXbzzWei: true, + actualXbzzWei: true, + promptTokens: true, + completionTokens: true, + createdAt: true, + ackedAt: true, + deliveredAt: true, + claimedAt: true, + errorMessage: true, + }, + }); + return jobs.map((j) => ({ + ...j, + estimatedMaxXbzzWei: j.estimatedMaxXbzzWei.toString(), + actualXbzzWei: j.actualXbzzWei?.toString() ?? null, + })); + }); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..0a95680 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,12 @@ +import pino from 'pino'; +import { env } from './env'; + +export const logger = pino({ + level: env.LOG_LEVEL, + transport: + env.LOG_LEVEL === 'debug' || env.LOG_LEVEL === 'trace' + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, +}); + +export type Logger = typeof logger; diff --git a/src/pricing/estimate.ts b/src/pricing/estimate.ts new file mode 100644 index 0000000..7e5b0a0 --- /dev/null +++ b/src/pricing/estimate.ts @@ -0,0 +1,49 @@ +import type { ModelOffering } from '../t4t/lib/types'; +import { env } from '../env'; + +const BPS_DIVISOR = 10_000n; +const TOKENS_PER_MILLION = 1_000_000n; + +export interface ChatRequest { + model: string; + max_tokens?: number; + messages?: Array<{ content: string }>; +} + +/** + * Rough upper-bound xBZZ cost for a single chat completion. Mirrors the + * `maxPayment` semantics on JobEscrow.postJob. + * + * Returns `{ raw, withMarkup }` so the caller can: + * - post the *raw* amount on-chain as `maxPayment` (markup is operator + * revenue, not paid to providers) + * - reserve the *with-markup* amount off-chain + */ +export function estimateMaxXbzz( + req: ChatRequest, + offering: Pick, +): { rawWei: bigint; withMarkupWei: bigint } { + const maxInputTokens = estimateInputTokens(req); + const maxOutputTokens = BigInt(req.max_tokens ?? Number(offering.maxContextTokens)); + + const inputCost = (BigInt(maxInputTokens) * BigInt(offering.inputPricePerMillionTokens)) / TOKENS_PER_MILLION; + const outputCost = (maxOutputTokens * BigInt(offering.outputPricePerMillionTokens)) / TOKENS_PER_MILLION; + const rawWei = inputCost + outputCost; + const withMarkupWei = (rawWei * (BPS_DIVISOR + BigInt(env.MARKUP_BPS))) / BPS_DIVISOR; + return { rawWei, withMarkupWei }; +} + +/** + * Approximate input-token count from messages. Uses a 4-char-per-token + * heuristic — fine for an upper-bound estimate; the actual cost is recorded + * later from the JobClaimed event. + */ +function estimateInputTokens(req: ChatRequest): number { + const totalChars = (req.messages ?? []).reduce((acc, m) => acc + (m.content?.length ?? 0), 0); + return Math.ceil(totalChars / 4) + 16; // +16 for system / role overhead +} + +/** Apply the same markup function used at reservation time to a settled cost. */ +export function applyMarkup(rawWei: bigint): bigint { + return (rawWei * (BPS_DIVISOR + BigInt(env.MARKUP_BPS))) / BPS_DIVISOR; +} diff --git a/src/pricing/usdDisplay.ts b/src/pricing/usdDisplay.ts new file mode 100644 index 0000000..811ba79 --- /dev/null +++ b/src/pricing/usdDisplay.ts @@ -0,0 +1,21 @@ +// USD conversion helpers — DASHBOARD COSMETICS ONLY. The billing path never +// touches USD; see plan section "Decisions locked in". +import { getBzzUsd } from '../t4t/lib/bzz-price'; +import { logger } from '../logger'; + +const WEI_PER_XBZZ = 10n ** 18n; + +/** + * Returns a USD-cents equivalent of an xBZZ-wei amount, or `null` if the price + * oracle isn't reachable. + */ +export async function getUsdEquivalent(wei: bigint): Promise { + try { + const usdPerXbzz = await getBzzUsd(logger); + if (!usdPerXbzz || usdPerXbzz <= 0) return null; + const xbzz = Number(wei) / Number(WEI_PER_XBZZ); // safe loss for display + return Math.round(xbzz * usdPerXbzz * 100); + } catch { + return null; + } +} diff --git a/src/queue/connection.ts b/src/queue/connection.ts new file mode 100644 index 0000000..1324542 --- /dev/null +++ b/src/queue/connection.ts @@ -0,0 +1,8 @@ +import IORedis from 'ioredis'; +import { env } from '../env'; + +export const redisConnection = new IORedis(env.REDIS_PORT, env.REDIS_HOST, { + maxRetriesPerRequest: null, +}); + +export const queueConnection = { host: env.REDIS_HOST, port: env.REDIS_PORT }; diff --git a/src/queue/lock.ts b/src/queue/lock.ts new file mode 100644 index 0000000..582e99f --- /dev/null +++ b/src/queue/lock.ts @@ -0,0 +1,37 @@ +import { randomUUID } from 'crypto'; +import { redisConnection } from './connection'; + +const DEFAULT_TTL = 30_000; +const RETRY_INTERVAL = 50; + +/** + * Distributed Redis lock using SET NX EX. Serializes critical sections across + * api + worker processes — used for the operator wallet nonce + * (`chain:operator`), claim-watcher leader election (`watcher:claim`), etc. + */ +export async function withLock( + key: string, + fn: () => Promise, + ttl = DEFAULT_TTL, +): Promise { + const lockKey = `lock:${key}`; + const lockValue = randomUUID(); + const ttlSeconds = Math.ceil(ttl / 1000); + + while (true) { + const acquired = await redisConnection.set(lockKey, lockValue, 'EX', ttlSeconds, 'NX'); + if (acquired === 'OK') break; + await new Promise((r) => setTimeout(r, RETRY_INTERVAL)); + } + + try { + return await fn(); + } finally { + await redisConnection.eval( + `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`, + 1, + lockKey, + lockValue, + ); + } +} diff --git a/src/queue/processor.ts b/src/queue/processor.ts new file mode 100644 index 0000000..03d1191 --- /dev/null +++ b/src/queue/processor.ts @@ -0,0 +1,92 @@ +import type { Job } from 'bullmq'; +import { isDeepStrictEqual } from 'node:util'; +import { logger } from '../logger'; + +/** + * Base processor for the CUD-and-enqueue pattern. Subclasses override + * `onCreated` / `getFieldHandlers` / `onDeleted`. + * + * Ported from cipherdolls/core/src/queue/processor.ts. `publishProcessEvent` + * is dropped — MQTT broker arrives in Phase 3. + */ +export abstract class BaseProcessor> { + constructor( + protected readonly entityName: string, + protected readonly scalarFields: readonly string[], + ) {} + + async process(job: Job): Promise { + switch (job.name) { + case 'created': + return this.handleCreated(job); + case 'updated': + return this.handleUpdated(job); + case 'deleted': + return this.handleDeleted(job); + default: + logger.warn({ entity: this.entityName, jobName: job.name }, 'unknown job name'); + } + } + + protected async handleCreated(job: Job): Promise { + const entity = job.data[this.entityName] as T; + logger.debug({ entity: this.entityName, id: entity?.id }, 'created'); + try { + await this.onCreated(job, entity); + } catch (err: any) { + logger.error({ entity: this.entityName, id: entity?.id, err: err.message }, 'onCreated failed'); + throw err; + } + } + + protected async handleUpdated(job: Job): Promise { + const entity = job.data[this.entityName] as T; + const original = job.data.original as T | undefined; + if (!original) { + logger.warn({ entity: this.entityName, id: entity?.id }, 'updated without original snapshot'); + return; + } + const updatedFields = this.scalarFields.filter( + (key) => !isDeepStrictEqual((original as any)[key], (entity as any)[key]), + ); + if (updatedFields.length === 0) return; + + logger.debug( + { entity: this.entityName, id: entity?.id, fields: updatedFields }, + 'updated', + ); + try { + const handlers = this.getFieldHandlers(job, entity); + for (const field of updatedFields) { + const handler = handlers[field]; + if (handler) await handler(); + } + await this.onUpdated(job, entity, original, updatedFields); + } catch (err: any) { + logger.error( + { entity: this.entityName, id: entity?.id, err: err.message }, + 'onUpdated failed', + ); + throw err; + } + } + + protected async handleDeleted(job: Job): Promise { + const entity = job.data[this.entityName] as T; + logger.debug({ entity: this.entityName, id: entity?.id }, 'deleted'); + await this.onDeleted(job, entity); + } + + protected async onCreated(_job: Job, _entity: T): Promise {} + protected async onUpdated( + _job: Job, + _entity: T, + _original: T, + _updatedFields: string[], + ): Promise {} + protected async onDeleted(_job: Job, _entity: T): Promise {} + + protected getFieldHandlers(_job: Job, _entity: T): Record Promise> { + return {}; + } +} diff --git a/src/queue/registry.ts b/src/queue/registry.ts new file mode 100644 index 0000000..30112a8 --- /dev/null +++ b/src/queue/registry.ts @@ -0,0 +1,43 @@ +import { Queue, Worker, type Job, type Processor } from 'bullmq'; +import { queueConnection } from './connection'; +import { logger } from '../logger'; + +const defaultOpts = { concurrency: 1 }; + +const queues = new Map(); +const workers = new Map(); + +export function getQueue(name: string): Queue { + if (!queues.has(name)) { + queues.set(name, new Queue(name, { connection: queueConnection })); + } + return queues.get(name)! as Queue; +} + +export function registerWorker( + name: string, + handler: Processor, + opts?: { concurrency?: number }, +): Worker { + const worker = new Worker(name, handler, { + connection: queueConnection, + concurrency: opts?.concurrency ?? defaultOpts.concurrency, + autorun: true, + }); + + worker.on('failed', (job: Job | undefined, err: Error) => { + logger.error({ queue: name, jobId: job?.id, jobName: job?.name, err: err.message }, 'job failed'); + }); + + worker.on('completed', (job: Job) => { + logger.debug({ queue: name, jobId: job.id, jobName: job.name }, 'job completed'); + }); + + workers.set(name, worker); + return worker; +} + +export async function closeAll() { + for (const w of workers.values()) await w.close(); + for (const q of queues.values()) await q.close(); +} diff --git a/src/queue/semaphore.ts b/src/queue/semaphore.ts new file mode 100644 index 0000000..5da71ff --- /dev/null +++ b/src/queue/semaphore.ts @@ -0,0 +1,47 @@ +/** + * Simple in-process counting semaphore for the chat-completion concurrency cap. + * Bounds Bee / Gnosis RPC / Postgres pressure when many concurrent users hit + * `/v1/chat/completions`. + * + * Not Redis-distributed — each api process keeps its own counter. With Phase 1 + * being a single api process this is sufficient; Phase 3 (horizontal scale) + * will replace with a Redis token bucket. + */ +export class Semaphore { + private available: number; + private waiters: Array<() => void> = []; + + constructor(private readonly capacity: number) { + this.available = capacity; + } + + async acquire(): Promise { + if (this.available > 0) { + this.available -= 1; + return; + } + await new Promise((resolve) => this.waiters.push(resolve)); + } + + /** Non-blocking variant: returns false if capacity is exhausted. */ + tryAcquire(): boolean { + if (this.available > 0) { + this.available -= 1; + return true; + } + return false; + } + + release(): void { + const next = this.waiters.shift(); + if (next) { + next(); + } else { + this.available = Math.min(this.available + 1, this.capacity); + } + } + + get inFlight(): number { + return this.capacity - this.available; + } +} diff --git a/src/queue/service.ts b/src/queue/service.ts new file mode 100644 index 0000000..f1ec634 --- /dev/null +++ b/src/queue/service.ts @@ -0,0 +1,58 @@ +import { getQueue } from './registry'; + +/** Recursively convert BigInt → string so BullMQ's JSON.stringify can serialize. */ +function serializeBigInts(obj: any): any { + if (obj === null || obj === undefined) return obj; + if (typeof obj === 'bigint') return obj.toString(); + if (Array.isArray(obj)) return obj.map(serializeBigInts); + if (typeof obj === 'object' && !(obj instanceof Date)) { + const result: any = {}; + for (const [k, v] of Object.entries(obj)) result[k] = serializeBigInts(v); + return result; + } + return obj; +} + +/** + * Wraps a Prisma model delegate so CUD calls persist AND enqueue a BullMQ job. + * Reads stay on `prisma.x` directly. + * + * Ported from cipherdolls/core/src/queue/service.ts. + */ +export function createCudService( + queueName: string, + prismaDelegate: { + create: (args: any) => Promise; + update: (args: any) => Promise; + delete: (args: any) => Promise; + findUnique: (args: any) => Promise; + }, +) { + const queue = getQueue(queueName); + + return { + async create(args: any): Promise { + const entity = await prismaDelegate.create(args); + await queue.add('created', { [queueName]: serializeBigInts(entity) }); + return entity; + }, + + async update(args: { where: any; data: any; include?: any }, original?: T): Promise { + if (!original) { + original = (await prismaDelegate.findUnique({ where: args.where })) as T; + } + const entity = await prismaDelegate.update(args); + await queue.add('updated', { + [queueName]: serializeBigInts(entity), + original: serializeBigInts(original), + }); + return entity; + }, + + async delete(args: { where: any }): Promise { + const entity = await prismaDelegate.findUnique({ where: args.where }); + await queue.add('deleted', { [queueName]: serializeBigInts(entity) }); + return prismaDelegate.delete(args); + }, + }; +} diff --git a/src/queue/startup.ts b/src/queue/startup.ts new file mode 100644 index 0000000..39bbe2f --- /dev/null +++ b/src/queue/startup.ts @@ -0,0 +1,27 @@ +import { closeAll, registerWorker } from './registry'; +import { logger } from '../logger'; + +// Per-entity processors registered as they're built (Phase 1 onwards). +// Import lazily so worker.ts can call startWorkers() without pulling in api +// surface code. + +export function startWorkers() { + logger.info('starting BullMQ workers…'); + + // Domain processors — added module-by-module as we build them. + // Example wiring (uncomment once the module ships): + // import { usersProcessor } from '../users/processor'; + // registerWorker('user', (job) => usersProcessor.process(job)); + + const { usersProcessor } = require('../users/processor'); + registerWorker('user', (job: any) => usersProcessor.process(job)); + + const { accountsProcessor } = require('../accounts/processor'); + registerWorker('account', (job: any) => accountsProcessor.process(job)); + + logger.info('BullMQ workers registered'); +} + +export async function stopWorkers() { + await closeAll(); +} diff --git a/src/t4t/VENDORED.md b/src/t4t/VENDORED.md new file mode 100644 index 0000000..8d23ad8 --- /dev/null +++ b/src/t4t/VENDORED.md @@ -0,0 +1,64 @@ +# Vendored from t4t-container + +These files are copied verbatim from +[`token-for-token/t4t`](https://github.com/token-for-token/t4t) at commit +**`f525b8bf93349dcaef2e234a6ad9e49871dcf12d`** (path: +`container/src/lib/*.ts`, `container/src/modes/gateway/{models,selector}.ts`). + +The hosted gateway is a hosted multi-tenant frontend that uses the same chain ++ Swarm + PSS protocol. We reuse the engine code as a library; rewriting it +would invite drift, and a separate npm package is premature. + +## Files vendored + +``` +src/t4t/lib/ +├── abi.ts # ProviderRegistry + JobEscrow + ERC20 ABIs +├── bzz-price.ts # CoinGecko USD price (cosmetic display only) +├── chain.ts # viem clients + read/write wrappers (postJob, claimJob, …) +├── crypto.ts # ECIES (payload encryption) +├── envelope.ts # PSS envelope sign/verify, topic derivation, dedup +├── keys.ts # PSS keypair generation (secp256k1) +├── logger.ts # ⚠️ LOCAL EDIT — re-exports project logger (see below) +├── stamps.ts # operator-managed postage batch helpers +├── swarm.ts # bee-js wrapper (chunks + PssTransport) +├── types.ts # shared chain types +└── wallet.ts # BIP39 / private-key helpers +src/t4t/modes/gateway/ +├── models.ts # ModelDiscovery (LRU-cached registry scan) +└── selector.ts # selectProvider() — stateless, filters by model+price+rep +``` + +The `modes/gateway/` depth mirrors the upstream `container/src/modes/gateway/` +layout so the internal `import {…} from '../../lib/chain'` paths keep working +verbatim. **Don't flatten this** — it's the difference between zero edits to +vendored files and a maintenance burden on every sync. + +## Files deliberately NOT vendored + +| Source path | Reason | +|---|---| +| `lib/onboarding.ts`, `modes/gateway/admin.ts`, `modes/gateway/server.ts` | Replaced by Elysia signup + dashboard + routes. | +| `lib/jobs-db.ts` | Replaced by Prisma `GatewayJob` + `userId` tenant attribution. | +| `lib/job-index.ts` | Replaced by Postgres-backed `src/chain/jobIndex.ts`. | +| `lib/config.ts` | Replaced by `src/env.ts` + per-tenant rows. | +| `lib/admin-html.ts`, `lib/admin-stamps.ts` | Single-operator admin UI. | +| `lib/inference.ts` | Provider-side (not used by gateway). | +| `modes/gateway/index.ts` | `handleChat` is refactored into `src/gateway/handleChat.ts`. | + +## Drift policy + +- **No edits** to vendored files. The one explicit exception is `lib/logger.ts` + (re-exports the project logger so vendored code shares the same pino + instance). Any further required change must be filed upstream first. +- Re-sync upstream every two weeks: + + ```bash + diff -ru ../t4t/container/src/lib/ src/t4t/lib/ + diff -ru ../t4t/container/src/modes/gateway/{models,selector}.ts \ + src/t4t/gateway/{models,selector}.ts + ``` + + Bump the SHA at the top of this file when re-syncing. +- If upstream renames or restructures these files, mirror the change here in the + same commit that updates the SHA. diff --git a/src/t4t/lib/abi.ts b/src/t4t/lib/abi.ts new file mode 100644 index 0000000..fb9203a --- /dev/null +++ b/src/t4t/lib/abi.ts @@ -0,0 +1,238 @@ +/** + * Hand-written ABIs for the three T4T contracts so the container builds + * without a Forge artifact pipeline. Regenerate from `contracts/out/` if + * the Solidity sources drift. + */ + +export const providerRegistryAbi = [ + { + type: 'function', + name: 'register', + stateMutability: 'nonpayable', + inputs: [ + {name: 'pssPublicKey', type: 'bytes32'}, + {name: 'swarmOverlay', type: 'bytes32'}, + {name: 'metadataURI', type: 'string'}, + {name: 'initialStake', type: 'uint128'}, + ], + outputs: [], + }, + { + type: 'function', + name: 'updateOfferings', + stateMutability: 'nonpayable', + inputs: [ + { + name: 'offerings', + type: 'tuple[]', + components: [ + {name: 'modelId', type: 'string'}, + {name: 'inputPricePerMillionTokens', type: 'uint128'}, + {name: 'outputPricePerMillionTokens', type: 'uint128'}, + {name: 'maxContextTokens', type: 'uint128'}, + {name: 'maxLatencySeconds', type: 'uint64'}, + ], + }, + ], + outputs: [], + }, + {type: 'function', name: 'heartbeat', stateMutability: 'nonpayable', inputs: [], outputs: []}, + { + type: 'function', + name: 'MIN_STAKE', + stateMutability: 'view', + inputs: [], + outputs: [{type: 'uint128'}], + }, + {type: 'function', name: 'deactivate', stateMutability: 'nonpayable', inputs: [], outputs: []}, + {type: 'function', name: 'withdrawStake', stateMutability: 'nonpayable', inputs: [], outputs: []}, + { + type: 'function', + name: 'isLive', + stateMutability: 'view', + inputs: [{name: 'owner', type: 'address'}], + outputs: [{type: 'bool'}], + }, + { + type: 'function', + name: 'getProvider', + stateMutability: 'view', + inputs: [{name: 'owner', type: 'address'}], + outputs: [ + { + type: 'tuple', + components: [ + {name: 'owner', type: 'address'}, + {name: 'pssPublicKey', type: 'bytes32'}, + {name: 'swarmOverlay', type: 'bytes32'}, + {name: 'metadataURI', type: 'string'}, + {name: 'stake', type: 'uint128'}, + {name: 'lastHeartbeat', type: 'uint64'}, + {name: 'totalJobs', type: 'uint32'}, + {name: 'successfulJobs', type: 'uint32'}, + {name: 'active', type: 'bool'}, + ], + }, + ], + }, + { + type: 'function', + name: 'getOfferings', + stateMutability: 'view', + inputs: [{name: 'owner', type: 'address'}], + outputs: [ + { + type: 'tuple[]', + components: [ + {name: 'modelId', type: 'string'}, + {name: 'inputPricePerMillionTokens', type: 'uint128'}, + {name: 'outputPricePerMillionTokens', type: 'uint128'}, + {name: 'maxContextTokens', type: 'uint128'}, + {name: 'maxLatencySeconds', type: 'uint64'}, + ], + }, + ], + }, + { + type: 'function', + name: 'listProviders', + stateMutability: 'view', + inputs: [ + {name: 'cursor', type: 'uint256'}, + {name: 'limit', type: 'uint256'}, + ], + outputs: [ + { + type: 'tuple[]', + components: [ + {name: 'owner', type: 'address'}, + {name: 'pssPublicKey', type: 'bytes32'}, + {name: 'swarmOverlay', type: 'bytes32'}, + {name: 'metadataURI', type: 'string'}, + {name: 'stake', type: 'uint128'}, + {name: 'lastHeartbeat', type: 'uint64'}, + {name: 'totalJobs', type: 'uint32'}, + {name: 'successfulJobs', type: 'uint32'}, + {name: 'active', type: 'bool'}, + ], + }, + {name: 'nextCursor', type: 'uint256'}, + ], + }, +] as const + +export const jobEscrowAbi = [ + { + type: 'function', + name: 'postJob', + stateMutability: 'nonpayable', + inputs: [ + {name: 'provider', type: 'address'}, + {name: 'requestHash', type: 'bytes32'}, + {name: 'modelId', type: 'string'}, + {name: 'maxPayment', type: 'uint128'}, + {name: 'deliveryDeadline', type: 'uint64'}, + ], + outputs: [{name: 'jobId', type: 'bytes32'}], + }, + { + type: 'function', + name: 'ackJob', + stateMutability: 'nonpayable', + inputs: [{name: 'jobId', type: 'bytes32'}], + outputs: [], + }, + { + type: 'function', + name: 'claimJob', + stateMutability: 'nonpayable', + inputs: [ + {name: 'jobId', type: 'bytes32'}, + {name: 'responseHash', type: 'bytes32'}, + {name: 'actualPayment', type: 'uint128'}, + {name: 'clientSig', type: 'bytes'}, + ], + outputs: [], + }, + { + type: 'function', + name: 'cancelJob', + stateMutability: 'nonpayable', + inputs: [{name: 'jobId', type: 'bytes32'}], + outputs: [], + }, + { + type: 'function', + name: 'timeoutJob', + stateMutability: 'nonpayable', + inputs: [{name: 'jobId', type: 'bytes32'}], + outputs: [], + }, + { + type: 'function', + name: 'jobs', + stateMutability: 'view', + inputs: [{name: 'jobId', type: 'bytes32'}], + outputs: [ + {name: 'client', type: 'address'}, + {name: 'provider', type: 'address'}, + {name: 'requestHash', type: 'bytes32'}, + {name: 'responseHash', type: 'bytes32'}, + {name: 'modelId', type: 'string'}, + {name: 'maxPayment', type: 'uint128'}, + {name: 'postedAt', type: 'uint64'}, + {name: 'ackedAt', type: 'uint64'}, + {name: 'ackDeadline', type: 'uint64'}, + {name: 'deliveryDeadline', type: 'uint64'}, + {name: 'status', type: 'uint8'}, + ], + }, + { + type: 'event', + name: 'JobPosted', + inputs: [ + {name: 'jobId', type: 'bytes32', indexed: true}, + {name: 'client', type: 'address', indexed: true}, + {name: 'provider', type: 'address', indexed: true}, + ], + }, + { + type: 'event', + name: 'JobClaimed', + inputs: [ + {name: 'jobId', type: 'bytes32', indexed: true}, + {name: 'responseHash', type: 'bytes32'}, + {name: 'paid', type: 'uint128'}, + ], + }, +] as const + +export const erc20Abi = [ + { + type: 'function', + name: 'approve', + stateMutability: 'nonpayable', + inputs: [ + {name: 'spender', type: 'address'}, + {name: 'amount', type: 'uint256'}, + ], + outputs: [{type: 'bool'}], + }, + { + type: 'function', + name: 'allowance', + stateMutability: 'view', + inputs: [ + {name: 'owner', type: 'address'}, + {name: 'spender', type: 'address'}, + ], + outputs: [{type: 'uint256'}], + }, + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{name: 'owner', type: 'address'}], + outputs: [{type: 'uint256'}], + }, +] as const diff --git a/src/t4t/lib/bzz-price.ts b/src/t4t/lib/bzz-price.ts new file mode 100644 index 0000000..ac3a242 --- /dev/null +++ b/src/t4t/lib/bzz-price.ts @@ -0,0 +1,47 @@ +/** + * BZZ → USD price lookup via CoinGecko's free tier. Cached 5 min so the + * admin UI can show a USD-equivalent alongside PLUR/BZZ without hammering + * the API. Failures fall back to the last good value (or null if we've + * never had one) — the calling page just hides the USD column. + */ +import type {Logger} from './logger' + +const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price?ids=swarm-bzz&vs_currencies=usd' +const CACHE_TTL_MS = 5 * 60 * 1000 + +interface Cached { + usd: number + fetchedAt: number +} + +let cached: Cached | null = null +let inflight: Promise | null = null + +export async function getBzzUsd(logger?: Logger): Promise { + const now = Date.now() + if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.usd + if (inflight) return inflight + inflight = (async () => { + try { + const r = await fetch(COINGECKO_URL, {headers: {accept: 'application/json'}}) + if (!r.ok) { + logger?.warn({status: r.status}, 'CoinGecko BZZ price fetch returned non-2xx') + return cached?.usd ?? null + } + const data = (await r.json()) as Record + const usd = data['swarm-bzz']?.usd + if (typeof usd !== 'number' || !isFinite(usd) || usd <= 0) { + logger?.warn({data}, 'CoinGecko BZZ price missing or invalid') + return cached?.usd ?? null + } + cached = {usd, fetchedAt: now} + return usd + } catch (err) { + logger?.warn({err}, 'CoinGecko BZZ price fetch failed') + return cached?.usd ?? null + } finally { + inflight = null + } + })() + return inflight +} diff --git a/src/t4t/lib/chain.ts b/src/t4t/lib/chain.ts new file mode 100644 index 0000000..ee239fe --- /dev/null +++ b/src/t4t/lib/chain.ts @@ -0,0 +1,353 @@ +import { + createPublicClient, + createWalletClient, + http, + parseEventLogs, + type Address, + type Hex, + type PublicClient, + type WalletClient, +} from 'viem' +import {privateKeyToAccount} from 'viem/accounts' +import {gnosis, gnosisChiado} from 'viem/chains' +import {erc20Abi, jobEscrowAbi, providerRegistryAbi} from './abi' +import type {ModelOffering, ProviderRow} from './types' + +/** Mirrors `JobEscrow.ACK_WINDOW` (spec §3). Used by the client to schedule + * the no-ack cancel deadline locally. Keep in sync with the contract. */ +export const ACK_WINDOW_SECONDS = 30 + +export interface TxEvent { + kind: string + hash: Hex + toAddress: Address + note?: string | null +} + +export interface ChainClient { + pub: PublicClient + wallet: WalletClient + address: Address + registry: Address + escrow: Address + xbzz: Address + /** Optional sink for every write tx after it's been submitted. Wired in by + * the provider/client startup to persist to `transactions` in jobs.db. */ + onTx?: (e: TxEvent) => void +} + +export interface ChainOpts { + rpcUrl: string + privateKey: Hex + registry: Address + escrow: Address + xbzz: Address + chainId?: number + onTx?: (e: TxEvent) => void +} + +export function makeChain(opts: ChainOpts): ChainClient { + const account = privateKeyToAccount(opts.privateKey) + const chain = opts.chainId === 10200 ? gnosisChiado : gnosis + const transport = http(opts.rpcUrl) + return { + pub: createPublicClient({chain, transport}), + wallet: createWalletClient({chain, transport, account}), + address: account.address, + registry: opts.registry, + escrow: opts.escrow, + xbzz: opts.xbzz, + onTx: opts.onTx, + } +} + +/** Notify the txLog sink. Swallow any error in the callback so a faulty DB + * write never takes down a chain operation. */ +function emit(c: ChainClient, e: TxEvent): void { + try { + c.onTx?.(e) + } catch { + // intentionally ignored + } +} + +// ---------- ProviderRegistry ---------- + +export async function registerProvider( + c: ChainClient, + args: { + pssPublicKey: Hex + swarmOverlay: Hex + metadataURI: string + initialStake: bigint + }, +): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.registry, + abi: providerRegistryAbi, + functionName: 'register', + args: [args.pssPublicKey as `0x${string}`, args.swarmOverlay as `0x${string}`, args.metadataURI, args.initialStake], + }) + emit(c, {kind: 'registerProvider', hash, toAddress: c.registry, note: `stake=${args.initialStake.toString()}`}) + return hash +} + +export async function updateOfferings(c: ChainClient, offerings: ModelOffering[]): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.registry, + abi: providerRegistryAbi, + functionName: 'updateOfferings', + args: [offerings], + }) + emit(c, {kind: 'updateOfferings', hash, toAddress: c.registry, note: `models=${offerings.length}`}) + return hash +} + +export async function sendHeartbeat(c: ChainClient): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.registry, + abi: providerRegistryAbi, + functionName: 'heartbeat', + args: [], + }) + emit(c, {kind: 'heartbeat', hash, toAddress: c.registry}) + return hash +} + +export async function deactivateProvider(c: ChainClient): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.registry, + abi: providerRegistryAbi, + functionName: 'deactivate', + args: [], + }) + emit(c, {kind: 'deactivateProvider', hash, toAddress: c.registry}) + return hash +} + +export async function withdrawStake(c: ChainClient): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.registry, + abi: providerRegistryAbi, + functionName: 'withdrawStake', + args: [], + }) + emit(c, {kind: 'withdrawStake', hash, toAddress: c.registry}) + return hash +} + +export async function readMinStake(c: ChainClient): Promise { + return (await c.pub.readContract({ + address: c.registry, + abi: providerRegistryAbi, + functionName: 'MIN_STAKE', + args: [], + })) as bigint +} + +export async function readXbzzBalance(c: ChainClient, owner: Address): Promise { + return (await c.pub.readContract({ + address: c.xbzz, + abi: [ + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{name: 'o', type: 'address'}], + outputs: [{type: 'uint256'}], + }, + ], + functionName: 'balanceOf', + args: [owner], + })) as bigint +} + +export async function getProvider(c: ChainClient, owner: Address): Promise { + const raw = await c.pub.readContract({ + address: c.registry, + abi: providerRegistryAbi, + functionName: 'getProvider', + args: [owner], + }) + return raw as unknown as ProviderRow +} + +export async function getOfferings(c: ChainClient, owner: Address): Promise { + const raw = await c.pub.readContract({ + address: c.registry, + abi: providerRegistryAbi, + functionName: 'getOfferings', + args: [owner], + }) + return raw as unknown as ModelOffering[] +} + +export async function listProviders( + c: ChainClient, + cursor = 0n, + limit = 50n, +): Promise<{page: ProviderRow[]; nextCursor: bigint}> { + const [page, nextCursor] = (await c.pub.readContract({ + address: c.registry, + abi: providerRegistryAbi, + functionName: 'listProviders', + args: [cursor, limit], + })) as unknown as [ProviderRow[], bigint] + return {page, nextCursor} +} + +// ---------- JobEscrow ---------- + +export async function postJob( + c: ChainClient, + args: { + provider: Address + requestHash: Hex + modelId: string + maxPayment: bigint + deliveryDeadline: number + }, +): Promise<{txHash: Hex; jobId: Hex}> { + const txHash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.escrow, + abi: jobEscrowAbi, + functionName: 'postJob', + args: [args.provider, args.requestHash, args.modelId, args.maxPayment, BigInt(args.deliveryDeadline)], + }) + const receipt = await c.pub.waitForTransactionReceipt({hash: txHash}) + const events = parseEventLogs({abi: jobEscrowAbi, eventName: 'JobPosted', logs: receipt.logs}) + const ev = events[0] + if (!ev) throw new Error('postJob: JobPosted event missing from receipt') + const jobId = ev.args.jobId as Hex + emit(c, {kind: 'postJob', hash: txHash, toAddress: c.escrow, note: `model=${args.modelId} jobId=${jobId}`}) + return {txHash, jobId} +} + +export async function ackJob(c: ChainClient, jobId: Hex): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.escrow, + abi: jobEscrowAbi, + functionName: 'ackJob', + args: [jobId], + }) + emit(c, {kind: 'ackJob', hash, toAddress: c.escrow, note: `jobId=${jobId}`}) + return hash +} + +export async function readJob( + c: ChainClient, + jobId: Hex, +): Promise<{ + client: Address + provider: Address + requestHash: Hex + responseHash: Hex + modelId: string + maxPayment: bigint + postedAt: bigint + ackedAt: bigint + ackDeadline: bigint + deliveryDeadline: bigint + status: number +}> { + const raw = (await c.pub.readContract({ + address: c.escrow, + abi: jobEscrowAbi, + functionName: 'jobs', + args: [jobId], + })) as readonly [Address, Address, Hex, Hex, string, bigint, bigint, bigint, bigint, bigint, number] + return { + client: raw[0], + provider: raw[1], + requestHash: raw[2], + responseHash: raw[3], + modelId: raw[4], + maxPayment: raw[5], + postedAt: raw[6], + ackedAt: raw[7], + ackDeadline: raw[8], + deliveryDeadline: raw[9], + status: raw[10], + } +} + +export async function cancelJob(c: ChainClient, jobId: Hex): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.escrow, + abi: jobEscrowAbi, + functionName: 'cancelJob', + args: [jobId], + }) + emit(c, {kind: 'cancelJob', hash, toAddress: c.escrow, note: `jobId=${jobId}`}) + return hash +} + +export async function timeoutJob(c: ChainClient, jobId: Hex): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.escrow, + abi: jobEscrowAbi, + functionName: 'timeoutJob', + args: [jobId], + }) + emit(c, {kind: 'timeoutJob', hash, toAddress: c.escrow, note: `jobId=${jobId}`}) + return hash +} + +export async function claimJob( + c: ChainClient, + args: {jobId: Hex; responseHash: Hex; actualPayment: bigint; clientSig?: Hex}, +): Promise { + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.escrow, + abi: jobEscrowAbi, + functionName: 'claimJob', + args: [args.jobId, args.responseHash, args.actualPayment, args.clientSig ?? '0x'], + }) + emit(c, {kind: 'claimJob', hash, toAddress: c.escrow, note: `jobId=${args.jobId} amount=${args.actualPayment.toString()}`}) + return hash +} + +// ---------- xBZZ ---------- + +export async function ensureAllowance( + c: ChainClient, + spender: Address, + needed: bigint, +): Promise { + const current = (await c.pub.readContract({ + address: c.xbzz, + abi: erc20Abi, + functionName: 'allowance', + args: [c.address, spender], + })) as bigint + if (current >= needed) return + const hash = await c.wallet.writeContract({ + chain: c.wallet.chain!, + account: c.wallet.account!, + address: c.xbzz, + abi: erc20Abi, + functionName: 'approve', + args: [spender, 2n ** 256n - 1n], + }) + emit(c, {kind: 'approve', hash, toAddress: c.xbzz, note: `spender=${spender} max`}) +} diff --git a/src/t4t/lib/crypto.ts b/src/t4t/lib/crypto.ts new file mode 100644 index 0000000..7fc41be --- /dev/null +++ b/src/t4t/lib/crypto.ts @@ -0,0 +1,89 @@ +/** + * ECIES payload encryption for Swarm chunks (spec §6). + * + * Hybrid scheme: secp256k1 ECDH → HKDF-SHA256 → AES-256-GCM. + * + * ciphertext = ephPubCompressed(33) || iv(12) || aesGcm(plaintext) + * + * The recipient's on-chain `pssPublicKey` is a 32-byte X coordinate (Y-even + * by convention; see lib/keys.ts), so we prepend 0x02 to reconstruct the + * compressed point. The ephemeral pub key is emitted compressed (33 bytes) + * since both Y parities can occur for ephemerals. + */ +import {gcm} from '@noble/ciphers/aes' +import {secp256k1} from '@noble/curves/secp256k1' +import {hkdf} from '@noble/hashes/hkdf' +import {sha256} from '@noble/hashes/sha2' +import {hexToBytes, randomBytes} from '@noble/hashes/utils' +import {pssPubKeyXToCompressed} from './keys' +import type {Hex} from './types' + +const EPH_PUB_LEN = 33 +const IV_LEN = 12 +const AES_KEY_LEN = 32 +const HKDF_INFO = new TextEncoder().encode('t4t-ecies-v1') + +export interface PayloadCipher { + encrypt(plaintext: Uint8Array, recipientPubKeyX: Hex): Promise + decrypt(ciphertext: Uint8Array): Promise +} + +export class EciesCipher implements PayloadCipher { + private readonly selfPriv: Uint8Array + + constructor(selfPssPrivateKey: Hex) { + const priv = hexToBytes(selfPssPrivateKey.replace(/^0x/, '')) + if (priv.length !== 32) throw new Error(`PSS private key must be 32 bytes, got ${priv.length}`) + if (!secp256k1.utils.isValidPrivateKey(priv)) throw new Error('invalid secp256k1 private key') + this.selfPriv = priv + } + + async encrypt(plaintext: Uint8Array, recipientPubKeyX: Hex): Promise { + const recipientPub = pssPubKeyXToCompressed(recipientPubKeyX) + const ephPriv = secp256k1.utils.randomPrivateKey() + const ephPub = secp256k1.getPublicKey(ephPriv, true) // 33 bytes + const shared = secp256k1.getSharedSecret(ephPriv, recipientPub, true) // [parity || X] + const key = hkdf(sha256, shared.subarray(1), undefined, HKDF_INFO, AES_KEY_LEN) + const iv = randomBytes(IV_LEN) + const ct = gcm(key, iv).encrypt(plaintext) + const out = new Uint8Array(EPH_PUB_LEN + IV_LEN + ct.length) + out.set(ephPub, 0) + out.set(iv, EPH_PUB_LEN) + out.set(ct, EPH_PUB_LEN + IV_LEN) + return out + } + + async decrypt(ciphertext: Uint8Array): Promise { + if (ciphertext.length < EPH_PUB_LEN + IV_LEN + 16) { + throw new Error('ciphertext too short for ECIES envelope') + } + const ephPub = ciphertext.subarray(0, EPH_PUB_LEN) + const iv = ciphertext.subarray(EPH_PUB_LEN, EPH_PUB_LEN + IV_LEN) + const ct = ciphertext.subarray(EPH_PUB_LEN + IV_LEN) + const shared = secp256k1.getSharedSecret(this.selfPriv, ephPub, true) + const key = hkdf(sha256, shared.subarray(1), undefined, HKDF_INFO, AES_KEY_LEN) + return gcm(key, iv).decrypt(ct) + } +} + +/** + * No-op cipher kept around for tests where you don't want to deal with key + * material. Production code paths construct EciesCipher. + */ +export class PassthroughCipher implements PayloadCipher { + async encrypt(plaintext: Uint8Array): Promise { + return plaintext + } + async decrypt(ciphertext: Uint8Array): Promise { + return ciphertext + } +} + +export function jsonEncrypt(cipher: PayloadCipher, recipient: Hex, value: unknown): Promise { + return cipher.encrypt(new TextEncoder().encode(JSON.stringify(value)), recipient) +} + +export async function jsonDecrypt(cipher: PayloadCipher, ct: Uint8Array): Promise { + const pt = await cipher.decrypt(ct) + return JSON.parse(new TextDecoder().decode(pt)) as T +} diff --git a/src/t4t/lib/envelope.ts b/src/t4t/lib/envelope.ts new file mode 100644 index 0000000..d5c5bb6 --- /dev/null +++ b/src/t4t/lib/envelope.ts @@ -0,0 +1,120 @@ +import {isAddressEqual, recoverMessageAddress, toHex} from 'viem' +import { + type Envelope, + type EnvelopeType, + type Hex, + PROTOCOL_VERSION, + type SignMessage, + type UnsignedEnvelope, +} from './types' + +/** PSS topics, spec §5.1. */ +export function providerTopic(wallet: Hex): string { + return `t4t:provider:${wallet.toLowerCase()}` +} +export function clientTopic(wallet: Hex): string { + return `t4t:client:${wallet.toLowerCase()}` +} + +export function randomNonce(): Hex { + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + return toHex(bytes) +} + +/** + * Deterministic JSON encoding so signatures are stable across implementations. + * Sorts keys at every level and emits no whitespace. + */ +export function canonicalize(value: unknown): string { + if (value === null) return 'null' + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error('non-finite number') + return JSON.stringify(value) + } + if (typeof value === 'bigint') return JSON.stringify(value.toString()) + if (typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value) + if (Array.isArray(value)) return '[' + value.map(canonicalize).join(',') + ']' + if (typeof value === 'object') { + const obj = value as Record + const keys = Object.keys(obj).sort() + return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalize(obj[k])).join(',') + '}' + } + throw new Error(`unserializable: ${typeof value}`) +} + +export interface BuildEnvelopeArgs { + from: Hex + to: Hex + type: EnvelopeType + body: TBody + ts?: number + nonce?: Hex +} + +export async function signEnvelope( + args: BuildEnvelopeArgs, + signMessage: SignMessage, +): Promise> { + const unsigned: UnsignedEnvelope = { + v: PROTOCOL_VERSION, + type: args.type, + from: args.from, + to: args.to, + ts: args.ts ?? Math.floor(Date.now() / 1000), + nonce: args.nonce ?? randomNonce(), + body: args.body, + } + const sig = await signMessage(canonicalize(unsigned)) + return {...unsigned, sig} +} + +/** True iff the signature recovers to `env.from` and the version matches. */ +export async function verifyEnvelope(env: Envelope): Promise { + if (env.v !== PROTOCOL_VERSION) return false + const {sig, ...unsigned} = env + try { + const recovered = await recoverMessageAddress({ + message: canonicalize(unsigned), + signature: sig, + }) + return isAddressEqual(recovered, env.from) + } catch { + return false + } +} + +export function encodeEnvelope(env: Envelope): Uint8Array { + return new TextEncoder().encode(JSON.stringify(env)) +} + +export function decodeEnvelope(bytes: Uint8Array | string): Envelope { + const text = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes) + return JSON.parse(text) as Envelope +} + +/** + * LRU-ish dedup cache for incoming envelopes. Per spec §9, hold the last 10k + * nonces and drop on repeat. + */ +export class DedupCache { + private readonly seen = new Set() + constructor(private readonly capacity = 10_000) {} + + has(key: string): boolean { + return this.seen.has(key) + } + + mark(key: string): void { + if (this.seen.size >= this.capacity) { + const oldest = this.seen.values().next().value + if (oldest) this.seen.delete(oldest) + } + this.seen.add(key) + } +} + +/** Stable per-envelope dedup key: `from:nonce`. */ +export function envelopeKey(env: Pick): string { + return `${env.from.toLowerCase()}:${env.nonce.toLowerCase()}` +} diff --git a/src/t4t/lib/keys.ts b/src/t4t/lib/keys.ts new file mode 100644 index 0000000..f558655 --- /dev/null +++ b/src/t4t/lib/keys.ts @@ -0,0 +1,113 @@ +import {secp256k1} from '@noble/curves/secp256k1' +import {hkdf} from '@noble/hashes/hkdf' +import {sha256} from '@noble/hashes/sha2' +import {bytesToHex, hexToBytes, randomBytes} from '@noble/hashes/utils' +import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs' +import {dirname} from 'node:path' +import type {Hex} from './types' + +/** + * The on-chain registry stores `pssPublicKey` as `bytes32` (spec §4 / contract). + * A real secp256k1 compressed public key is 33 bytes (1-byte Y-parity prefix + + * 32-byte X). We resolve the mismatch by only minting keys with even-Y parity + * — the X coordinate alone is then enough; senders prepend 0x02 to rebuild + * the compressed form. Rejection-sampling reaches even-Y in ~2 tries on + * average without leaking entropy. + */ +const PSS_INFO = new TextEncoder().encode('t4t-pss-v1') + +export interface PssKeypair { + /** 32-byte secp256k1 private scalar. */ + privateKey: Hex + /** 32-byte X coordinate of the compressed pub key (Y is even by construction). */ + publicKeyX: Hex +} + +/** + * Generate a fresh random PSS keypair with even-Y parity. Used at first + * startup; the result is persisted via {@link loadOrCreatePssKey}. + */ +export function generatePssKeypair(): PssKeypair { + for (let i = 0; i < 256; i++) { + const candidate = randomBytes(32) + if (!secp256k1.utils.isValidPrivateKey(candidate)) continue + const pub = secp256k1.getPublicKey(candidate, true) + if (pub[0] === 0x02) return pubFromPriv(candidate, pub) + } + throw new Error('failed to generate an even-Y PSS keypair') +} + +/** + * Deterministically derive a PSS keypair from the wallet's private key. + * + * Retained for tests and as a one-shot migration helper for operators who + * registered with the wallet-derived key in the previous container version. + * **Not used in production startup** — see `loadOrCreatePssKey`. + */ +export function derivePssKeypair(walletKey: Hex): PssKeypair { + const ikm = hexToBytes(walletKey.replace(/^0x/, '')) + for (let counter = 0; counter < 256; counter++) { + const salt = new Uint8Array([counter]) + const candidate = hkdf(sha256, ikm, salt, PSS_INFO, 32) + if (!secp256k1.utils.isValidPrivateKey(candidate)) continue + const pub = secp256k1.getPublicKey(candidate, true) + if (pub[0] === 0x02) return pubFromPriv(candidate, pub) + } + throw new Error('failed to derive an even-Y PSS keypair') +} + +/** Public-key-only accessor for code paths that don't need the secret. */ +export function pssPubKeyFromWallet(walletKey: Hex): Hex { + return derivePssKeypair(walletKey).publicKeyX +} + +/** Reconstruct the 33-byte compressed pubkey from the on-chain 32-byte X. */ +export function pssPubKeyXToCompressed(pubKeyX: Hex): Uint8Array { + const x = hexToBytes(pubKeyX.replace(/^0x/, '')) + if (x.length !== 32) throw new Error(`expected 32-byte X coord, got ${x.length}`) + const out = new Uint8Array(33) + out[0] = 0x02 // even-Y by convention + out.set(x, 1) + return out +} + +/** + * Load the PSS keypair from `path`, or generate + persist one if absent. + * + * The on-disk format is a single line of 64 hex chars (the 32-byte private + * scalar). The file is created mode 0600 and its parent directory is created + * recursively if missing. The PSS pubkey is derived on load and validated to + * have even-Y parity — files written by older versions that picked a key + * with odd Y will be rejected so the operator can re-issue rather than + * silently producing keys incompatible with the bytes32 registry slot. + */ +export function loadOrCreatePssKey(path: string): PssKeypair { + if (existsSync(path)) return parsePssKeyFile(readFileSync(path, 'utf8')) + const fresh = generatePssKeypair() + mkdirSync(dirname(path), {recursive: true}) + const hex = fresh.privateKey.replace(/^0x/, '') + writeFileSync(path, hex + '\n', {mode: 0o600}) + try { chmodSync(path, 0o600) } catch { /* best-effort on platforms without chmod */ } + return fresh +} + +function parsePssKeyFile(contents: string): PssKeypair { + const hex = contents.trim().replace(/^0x/, '') + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error('PSS key file must contain a 64-hex-char (32-byte) private scalar') + } + const priv = hexToBytes(hex) + if (!secp256k1.utils.isValidPrivateKey(priv)) throw new Error('invalid secp256k1 private key') + const pub = secp256k1.getPublicKey(priv, true) + if (pub[0] !== 0x02) { + throw new Error('PSS key has odd-Y public point; registry expects even-Y. Regenerate the key.') + } + return pubFromPriv(priv, pub) +} + +function pubFromPriv(priv: Uint8Array, pub: Uint8Array): PssKeypair { + return { + privateKey: ('0x' + bytesToHex(priv)) as Hex, + publicKeyX: ('0x' + bytesToHex(pub.subarray(1))) as Hex, + } +} diff --git a/src/t4t/lib/logger.ts b/src/t4t/lib/logger.ts new file mode 100644 index 0000000..6d23933 --- /dev/null +++ b/src/t4t/lib/logger.ts @@ -0,0 +1,4 @@ +// VENDORED — local edit (allowed by drift policy): re-export the project logger +// so vendored t4t code shares the same pino instance as the rest of the service. +export { logger } from '../../logger'; +export type { Logger } from '../../logger'; diff --git a/src/t4t/lib/stamps.ts b/src/t4t/lib/stamps.ts new file mode 100644 index 0000000..0e2fb6d --- /dev/null +++ b/src/t4t/lib/stamps.ts @@ -0,0 +1,292 @@ +/** + * Container-managed Swarm postage stamps. + * + * Pure, mode-agnostic helpers wrapping the Bee `/stamps` and `/chainstate` + * endpoints. The provider and gateway modes both call `ensureManagedStamp()` + * on boot and `topUpIfBelow()` on a background tick. + * + * Operator intent (depth + TTL) is configured via the `T4T_STAMP_*` env vars + * in `config.ts`; the raw `amount` (per-chunk wei budget) is derived from + * `getChainState().currentPrice × blocksPerDay × ttlDays`. + * + * If `POSTAGE_BATCH_ID` is set, that escape hatch wins and this module is + * never consulted — the operator owns the batch lifecycle. + */ + +import type {Bee} from '@ethersphere/bee-js' +import type {Logger} from './logger' + +// Gnosis Chain block time is ~5s; 86400/5 = 17280. +export const BLOCKS_PER_DAY_GNOSIS = 17280n + +export interface StampManageOpts { + depth: number + ttlDays: number + minTtlDays: number + label: string + dryRun: boolean +} + +export interface ManagedStamp { + batchID: string + /** Source of the resolved id, for log/UI display. */ + source: 'env' | 'reused' | 'bought' | 'discovered' + depth: number + remainingDays: number + usable: boolean + utilization: number + usage: number + usageText: string + label: string +} + +/** xBZZ cost to buy/top-up a stamp = amount × 2^depth (Bee charges the node + * wallet, not the container's wallet). Used for log/UI cost preview only. */ +export function stampWalletCostWei(amount: bigint, depth: number): bigint { + return amount * (1n << BigInt(depth)) +} + +/** Wei budget per chunk per block, multiplied across the TTL window — the + * value Bee expects as `amount` in POST /stamps/{amount}/{depth}. */ +export async function amountForTtl(bee: Bee, ttlDays: number): Promise { + const state = await bee.getChainState() + const price = BigInt(state.currentPrice) + if (price <= 0n) throw new Error(`Bee returned non-positive chainstate.currentPrice (${state.currentPrice})`) + return price * BLOCKS_PER_DAY_GNOSIS * BigInt(Math.max(1, Math.trunc(ttlDays))) +} + +interface BatchLike { + batchID: {toString(): string} + label: string + usable: boolean + depth: number + utilization: number + usage: number + usageText: string + duration: {toDays(): number} +} + +export function summariseBatch(b: BatchLike, source: ManagedStamp['source']): ManagedStamp { + return { + batchID: b.batchID.toString(), + source, + depth: b.depth, + remainingDays: b.duration.toDays(), + usable: b.usable, + utilization: b.utilization, + usage: b.usage, + usageText: b.usageText, + label: b.label, + } +} + +/** Pick the best `t4t-managed` batch the node already owns: must be usable + * and have remaining TTL ≥ minTtlDays. Tiebreak by longest remaining TTL + * so we converge on the youngest batch when an old one is about to expire. */ +export function pickReusable( + all: T[], + label: string, + minTtlDays: number, +): T | null { + const candidates = all + .filter(b => b.label === label && b.usable && b.duration.toDays() >= minTtlDays) + .sort((a, b) => b.duration.toDays() - a.duration.toDays()) + return candidates[0] ?? null +} + +/** Same label but TTL too short — caller can top up rather than buy fresh. */ +export function pickToppable( + all: T[], + label: string, +): T | null { + const candidates = all + .filter(b => b.label === label && b.usable) + .sort((a, b) => b.duration.toDays() - a.duration.toDays()) + return candidates[0] ?? null +} + +/** List all batches via Bee — typed loosely so it adapts to bee-js shape + * changes (the module only cares about a handful of fields). */ +async function listAllBatches(bee: Bee): Promise { + return (await bee.getAllPostageBatch()) as unknown as BatchLike[] +} + +export interface EnsureStampDeps { + bee: Bee + logger: Logger + opts: StampManageOpts +} + +/** + * Resolve a managed batch — list, reuse, top-up-then-reuse, or buy. + * Idempotent: safe to call from many container instances against the same Bee + * node; they converge on the longest-lived `label` batch. + */ +export async function ensureManagedStamp(deps: EnsureStampDeps): Promise { + const {bee, logger, opts} = deps + const log = logger.child({stamps: true}) + + const all = await listAllBatches(bee) + + const reusable = pickReusable(all, opts.label, opts.minTtlDays) + if (reusable) { + log.info( + {batchID: reusable.batchID.toString(), remainingDays: reusable.duration.toDays(), depth: reusable.depth}, + 'reusing existing t4t-managed batch', + ) + return summariseBatch(reusable, 'reused') + } + + const toppable = pickToppable(all, opts.label) + if (toppable) { + if (opts.dryRun) { + log.warn({batchID: toppable.batchID.toString()}, 'dry-run: would top up existing batch instead of buying') + return summariseBatch(toppable, 'reused') + } + const amount = await amountForTtl(bee, opts.ttlDays) + log.info( + { + batchID: toppable.batchID.toString(), + amount: amount.toString(), + walletCost: stampWalletCostWei(amount, toppable.depth).toString(), + }, + 'topping up under-TTL t4t-managed batch instead of buying new', + ) + await bee.topUpBatch(toppable.batchID.toString(), amount) + const refreshed = (await listAllBatches(bee)).find( + b => b.batchID.toString() === toppable.batchID.toString(), + ) + return summariseBatch(refreshed ?? toppable, 'reused') + } + + // No reusable batch — buy fresh. + const amount = await amountForTtl(bee, opts.ttlDays) + const walletCost = stampWalletCostWei(amount, opts.depth) + log.info( + { + depth: opts.depth, + ttlDays: opts.ttlDays, + amount: amount.toString(), + walletCost: walletCost.toString(), + label: opts.label, + dryRun: opts.dryRun, + }, + opts.dryRun ? 'dry-run: would buy postage batch' : 'buying new postage batch', + ) + if (opts.dryRun) { + throw new Error( + 'T4T_STAMP_DRY_RUN=true and no reusable batch exists — refusing to buy. ' + + 'Disable dry-run or pre-purchase a batch labelled "' + opts.label + '".', + ) + } + const batchId = await bee.createPostageBatch(amount, opts.depth, { + label: opts.label, + waitForUsable: true, + }) + const idStr = batchId.toString() + log.info({batchID: idStr}, 'postage batch bought and usable') + const refreshed = (await listAllBatches(bee)).find(b => b.batchID.toString() === idStr) + if (!refreshed) { + // Bee returned the id but list missed it — return a minimal stub. + return { + batchID: idStr, + source: 'bought', + depth: opts.depth, + remainingDays: opts.ttlDays, + usable: true, + utilization: 0, + usage: 0, + usageText: '0%', + label: opts.label, + } + } + return summariseBatch(refreshed, 'bought') +} + +export interface TopUpIfBelowDeps { + bee: Bee + logger: Logger + batchId: string + ttlDays: number + minTtlDays: number + /** Auto-dilute (raise depth by +1) when utilization crosses this fraction. + * Doubles bucket capacity but halves remaining TTL, so we top up first when + * TTL is also under threshold. Set to 1.0 to disable. */ + maxUtilization: number + /** Hard cap so an unexpected utilization spike can't run depth to infinity. */ + maxDepth: number + dryRun: boolean +} + +/** Background tick: top up if remaining TTL falls below `minTtlDays`, and + * dilute if utilization crosses `maxUtilization`. Returns the post-tick + * remaining-days for the admin UI to surface. */ +export async function topUpIfBelow(deps: TopUpIfBelowDeps): Promise<{toppedUp: boolean; diluted: boolean; remainingDays: number}> { + const all = await listAllBatches(deps.bee) + let batch = all.find(b => b.batchID.toString() === deps.batchId) + if (!batch) { + deps.logger.warn({batchId: deps.batchId}, 'managed batch not found on Bee — skipping top-up tick') + return {toppedUp: false, diluted: false, remainingDays: 0} + } + let toppedUp = false + let diluted = false + + // Top-up first — diluting halves remaining TTL, so if we're already short + // we'd push the batch below the floor. + const remaining = batch.duration.toDays() + if (remaining < deps.minTtlDays) { + if (deps.dryRun) { + deps.logger.warn({batchId: deps.batchId, remaining, threshold: deps.minTtlDays}, 'dry-run: would auto-top-up') + } else { + const amount = await amountForTtl(deps.bee, deps.ttlDays) + deps.logger.info( + { + batchId: deps.batchId, + remaining, + threshold: deps.minTtlDays, + amount: amount.toString(), + walletCost: stampWalletCostWei(amount, batch.depth).toString(), + }, + 'auto-topping up managed batch', + ) + await deps.bee.topUpBatch(deps.batchId, amount) + toppedUp = true + batch = (await listAllBatches(deps.bee)).find(b => b.batchID.toString() === deps.batchId) ?? batch + } + } + + // Auto-dilute on high utilization. Bee tracks the most-loaded bucket; one + // hot bucket can spike utilization long before "total bytes" is anywhere + // near full, so this kicks in earlier than the operator expects. + if (batch.usage >= deps.maxUtilization && batch.depth < deps.maxDepth) { + const newDepth = batch.depth + 1 + if (deps.dryRun) { + deps.logger.warn({batchId: deps.batchId, usage: batch.usage, from: batch.depth, to: newDepth}, 'dry-run: would auto-dilute') + } else { + deps.logger.info({batchId: deps.batchId, usage: batch.usage, from: batch.depth, to: newDepth}, 'auto-diluting managed batch') + await deps.bee.diluteBatch(deps.batchId, newDepth) + diluted = true + batch = (await listAllBatches(deps.bee)).find(b => b.batchID.toString() === deps.batchId) ?? batch + } + } + + return {toppedUp, diluted, remainingDays: batch.duration.toDays()} +} + +/** Manual top-up triggered from the admin UI. Amount is derived from the + * same TTL math so the UI just asks for "days" and the math stays one place. */ +export async function manualTopUp( + bee: Bee, + batchId: string, + ttlDays: number, +): Promise { + const amount = await amountForTtl(bee, ttlDays) + await bee.topUpBatch(batchId, amount) + return amount +} + +/** Manual dilute (grow capacity) triggered from the admin UI. Halves + * remaining TTL per +1 depth — caller should confirm in UI. */ +export async function manualDilute(bee: Bee, batchId: string, newDepth: number): Promise { + await bee.diluteBatch(batchId, newDepth) +} diff --git a/src/t4t/lib/swarm.ts b/src/t4t/lib/swarm.ts new file mode 100644 index 0000000..dca436b --- /dev/null +++ b/src/t4t/lib/swarm.ts @@ -0,0 +1,178 @@ +import {Bee, Topic, type PssSubscription} from '@ethersphere/bee-js' +import type {Envelope, Hex} from './types' +import {decodeEnvelope, encodeEnvelope, envelopeKey, DedupCache, verifyEnvelope} from './envelope' +import type {Logger} from './logger' + +export interface SwarmClientOpts { + bee: Bee + postageBatchId: string + logger: Logger +} + +/** Returns true if the Bee error means "the postage batch ran out of bucket + * capacity" (HTTP 402 with `code:402, message:"batch is overissued"`). */ +function isBatchOverissued(err: unknown): boolean { + const e = err as {status?: number; responseBody?: {message?: string; code?: number}} | null + if (!e || e.status !== 402) return false + const msg = e.responseBody?.message ?? '' + return /overissued|insufficient/i.test(msg) || e.responseBody?.code === 402 +} + +/** Emergency self-heal: when a Bee call fails because the batch is full, + * dilute it by +1 depth (doubles bucket capacity, halves remaining TTL) and + * retry the call exactly once. Logged so the operator sees the recovery. + * Bee handles the dilute tx from its own wallet — we don't pay xBZZ here. */ +const EMERGENCY_DILUTE_MAX_DEPTH = 28 +async function withBatchRecovery(opts: SwarmClientOpts, fn: () => Promise): Promise { + try { + return await fn() + } catch (err) { + if (!isBatchOverissued(err)) throw err + const batches = await opts.bee.getAllPostageBatch().catch(() => []) + const batch = batches.find(b => b.batchID.toString() === opts.postageBatchId) + if (!batch) throw err + if (batch.depth >= EMERGENCY_DILUTE_MAX_DEPTH) { + opts.logger.error({batchId: opts.postageBatchId, depth: batch.depth}, 'batch overissued and at depth cap — cannot self-heal') + throw err + } + const newDepth = batch.depth + 1 + opts.logger.warn( + {batchId: opts.postageBatchId, from: batch.depth, to: newDepth}, + 'batch overissued — emergency dilute then retry', + ) + await opts.bee.diluteBatch(opts.postageBatchId, newDepth) + // Bee's diluteBatch returns when the tx is SUBMITTED, not mined. The local + // node won't accept writes at the new depth until the tx confirms on + // Gnosis (~5-15s). Poll until the batch's depth actually reflects the + // dilute, with a 30s ceiling. Without this, the retry races the chain and + // hits the same 402. + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + const refreshed = await opts.bee.getAllPostageBatch().catch(() => []) + const b = refreshed.find(x => x.batchID.toString() === opts.postageBatchId) + if (b && b.depth >= newDepth) { + opts.logger.info({batchId: opts.postageBatchId, depth: b.depth}, 'dilute confirmed') + break + } + await new Promise(r => setTimeout(r, 1500)) + } + return await fn() + } +} + +/** Upload a single chunk and return its Swarm reference (hex). */ +export async function uploadChunk( + opts: SwarmClientOpts, + bytes: Uint8Array, +): Promise { + return withBatchRecovery(opts, async () => { + const {reference} = await opts.bee.uploadData(opts.postageBatchId, bytes) + return reference.toString() + }) +} + +export async function downloadChunk(opts: SwarmClientOpts, reference: string): Promise { + const data = await opts.bee.downloadData(reference) + return data.toUint8Array() +} + +/** Pick the most usable postage batch the Bee node already owns. We prefer + * batches that Bee marks as `usable: true` (i.e. enough block confirmations + * and not expired), with the largest remaining capacity as a tiebreaker. + * Returns null when the node has no usable batch — the caller should surface + * this so the operator can buy or top-up one. */ +export async function discoverUsableBatchId(bee: Bee): Promise { + const all = await bee.getAllPostageBatch().catch(() => []) + const usable = all.filter(b => b.usable) + if (usable.length === 0) return null + // Pick the longest-lived as a proxy for "still valid for the next while". + usable.sort((a, b) => b.duration.toSeconds() - a.duration.toSeconds()) + return usable[0]!.batchID.toString() +} + +export interface PssTransportOpts extends SwarmClientOpts { + selfAddress: Hex + dedupCapacity?: number +} + +export interface PssSendArgs { + topic: string + /** Target's Swarm overlay address (hex, 0x-prefixed). */ + recipientOverlay: Hex + /** Recipient's PSS public key (hex, 0x-prefixed compressed pubkey). */ + recipientPssKey: Hex + envelope: Envelope +} + +export interface PssSubscribeArgs { + topic: string + onEnvelope: (env: Envelope) => void | Promise + onError?: (err: unknown) => void +} + +/** + * Thin wrapper over bee-js PSS that signs nothing and verifies everything. + * Envelope signing happens upstream in `envelope.ts`; this layer only routes. + */ +export class PssTransport { + private readonly dedup: DedupCache + + constructor(private readonly opts: PssTransportOpts) { + this.dedup = new DedupCache(opts.dedupCapacity ?? 10_000) + } + + async send(args: PssSendArgs): Promise { + const topic = Topic.fromString(args.topic) + // First 2 bytes of the overlay narrow PSS forwarding without doxxing + // the full target address (Bee convention). + const target = args.recipientOverlay.slice(2, 6) + // The on-chain registry stores `pssPublicKey` as bytes32 (X coord only) + // and our keygen guarantees even-Y parity (see lib/keys.ts), so prepend + // 0x02 to rebuild the 33-byte compressed pubkey form bee-js' PublicKey + // class accepts. Passing bare 32 bytes throws + // "Bytes#checkByteLength: bytes length is 32 but expected 64". + const x = args.recipientPssKey.replace(/^0x/, '') + if (x.length !== 64) { + throw new Error(`recipient PSS pubkey must be 32-byte X coord (got ${x.length / 2} bytes)`) + } + const pubKey = '02' + x + await withBatchRecovery(this.opts, () => + this.opts.bee.pssSend( + this.opts.postageBatchId, + topic, + target, + encodeEnvelope(args.envelope), + pubKey, + ), + ) + } + + subscribe(args: PssSubscribeArgs): PssSubscription { + const topic = Topic.fromString(args.topic) + return this.opts.bee.pssSubscribe(topic, { + onMessage: async msg => { + try { + const env = decodeEnvelope(msg.toUtf8()) + const key = envelopeKey(env) + if (this.dedup.has(key)) return + if (!(await verifyEnvelope(env))) { + this.opts.logger.warn({from: env.from}, 'envelope signature verification failed') + return + } + this.dedup.mark(key) + await args.onEnvelope(env) + } catch (err) { + args.onError?.(err) + this.opts.logger.error({err}, 'pss decode failure') + } + }, + onError: err => { + args.onError?.(err) + this.opts.logger.error({err}, 'pss subscription error') + }, + onClose: () => { + this.opts.logger.warn({topic: args.topic}, 'pss subscription closed') + }, + }) + } +} diff --git a/src/t4t/lib/types.ts b/src/t4t/lib/types.ts new file mode 100644 index 0000000..9457135 --- /dev/null +++ b/src/t4t/lib/types.ts @@ -0,0 +1,115 @@ +import type {Hex} from 'viem' + +export type {Hex} + +export const PROTOCOL_VERSION = 1 as const + +export type EnvelopeType = 'job_notify' | 'job_ack' | 'job_deliver' + +export interface UnsignedEnvelope { + v: typeof PROTOCOL_VERSION + type: EnvelopeType + from: Hex + to: Hex + ts: number + nonce: Hex + body: TBody +} + +export interface Envelope extends UnsignedEnvelope { + sig: Hex +} + +// ---------- Body payloads (spec §5.3) ---------- + +export interface JobNotifyBody { + jobId: Hex + requestHash: string + modelId: string + /** xBZZ wei as a base-10 string to survive JSON. */ + maxPayment: string + deliveryDeadline: number + /** Gateway's PSS pubkey (32-byte X coord, even-Y by convention). + * Required so the provider can encrypt the ACK/deliver back without + * looking up the gateway's address in the on-chain registry (gateways + * aren't registered there). */ + clientPssPubKey: Hex + /** Gateway's Bee overlay for PSS routing. */ + clientSwarmOverlay: Hex +} + +export interface JobAckBody { + jobId: Hex + estimatedCompletion: number +} + +export interface JobDeliverBody { + jobId: Hex + responseHash: string +} + +// ---------- On-chain mirror types ---------- + +export interface ProviderRow { + owner: Hex + pssPublicKey: Hex + swarmOverlay: Hex + metadataURI: string + stake: bigint + lastHeartbeat: number + totalJobs: number + successfulJobs: number + active: boolean +} + +export interface ModelOffering { + modelId: string + inputPricePerMillionTokens: bigint + outputPricePerMillionTokens: bigint + maxContextTokens: bigint + maxLatencySeconds: bigint +} + +// ---------- Swarm payload (spec §6) ---------- + +export interface OpenAIChatRequest { + model: string + messages: Array<{role: 'system' | 'user' | 'assistant' | 'tool'; content: string}> + temperature?: number + max_tokens?: number + stream?: boolean + [k: string]: unknown +} + +export interface OpenAIChatResponse { + id: string + object: 'chat.completion' + created: number + model: string + choices: Array<{ + index: number + message: {role: 'assistant'; content: string} + finish_reason: string + }> + usage?: {prompt_tokens: number; completion_tokens: number; total_tokens: number} +} + +export interface RequestPayload { + v: typeof PROTOCOL_VERSION + jobId: Hex + client: Hex + modelId: string + openaiRequest: OpenAIChatRequest + clientPssPubKey: Hex + ts: number +} + +export interface ResponsePayload { + v: typeof PROTOCOL_VERSION + jobId: Hex + provider: Hex + openaiResponse: OpenAIChatResponse + ts: number +} + +export type SignMessage = (message: string) => Promise diff --git a/src/t4t/lib/wallet.ts b/src/t4t/lib/wallet.ts new file mode 100644 index 0000000..6169c04 --- /dev/null +++ b/src/t4t/lib/wallet.ts @@ -0,0 +1,54 @@ +import {existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync} from 'node:fs' +import {dirname} from 'node:path' +import {validateMnemonic, mnemonicToSeedSync} from '@scure/bip39' +import {wordlist} from '@scure/bip39/wordlists/english' +import {HDKey} from '@scure/bip32' +import {generateMnemonic, english} from 'viem/accounts' +import {toHex} from 'viem' +import type {Hex} from './types' + +/** Standard Ethereum derivation path — first account, first address. */ +const ETH_PATH = "m/44'/60'/0'/0/0" + +export function newMnemonic(): string { + return generateMnemonic(english) +} + +export function isMnemonic(input: string): boolean { + const words = input.trim().split(/\s+/) + if (![12, 15, 18, 21, 24].includes(words.length)) return false + return validateMnemonic(words.join(' '), wordlist) +} + +export function mnemonicToPrivateKey(mnemonic: string): Hex { + if (!isMnemonic(mnemonic)) throw new Error('invalid BIP39 mnemonic') + const seed = mnemonicToSeedSync(mnemonic.trim().replace(/\s+/g, ' ')) + const derived = HDKey.fromMasterSeed(seed).derive(ETH_PATH) + if (!derived.privateKey) throw new Error('mnemonic derived no private key') + return toHex(derived.privateKey) as Hex +} + +/** Accepts a BIP39 mnemonic OR a 0x-prefixed 32-byte private key. */ +export function parseWalletInput(input: string): Hex { + const trimmed = input.trim() + if (/^0x[a-fA-F0-9]{64}$/.test(trimmed)) return trimmed as Hex + return mnemonicToPrivateKey(trimmed) +} + +export function walletFileExists(path: string): boolean { + return existsSync(path) +} + +export function loadWalletKey(path: string): Hex { + const raw = readFileSync(path, 'utf8').trim() + if (!/^0x[a-fA-F0-9]{64}$/.test(raw)) throw new Error(`wallet file at ${path} is not a valid private key`) + return raw as Hex +} + +export function saveWalletKey(path: string, key: Hex): void { + if (!/^0x[a-fA-F0-9]{64}$/.test(key)) throw new Error('refusing to save: not a 0x-prefixed 32-byte private key') + mkdirSync(dirname(path), {recursive: true}) + writeFileSync(path, key + '\n', {mode: 0o600}) + // writeFileSync's mode is only respected on file creation; tighten existing files too. + chmodSync(path, 0o600) +} diff --git a/src/t4t/modes/gateway/models.ts b/src/t4t/modes/gateway/models.ts new file mode 100644 index 0000000..e6d35ea --- /dev/null +++ b/src/t4t/modes/gateway/models.ts @@ -0,0 +1,130 @@ +import type {ChainClient} from '../../lib/chain' +import {getOfferings, listProviders} from '../../lib/chain' +import type {ModelOffering, ProviderRow} from '../../lib/types' +import {isHeartbeatFresh} from './selector' + +export interface ModelSummary { + /** OpenAI-style model id (e.g. "llama3:8b-instruct"). */ + id: string + /** Active, heartbeat-fresh providers advertising this model. */ + providerCount: number + /** Min input price (xBZZ wei per 1M tokens) across providers. */ + minInputPrice: bigint + /** Median input price. */ + medianInputPrice: bigint + /** Min output price (xBZZ wei per 1M tokens) across providers. */ + minOutputPrice: bigint + /** Median output price. */ + medianOutputPrice: bigint + /** Worst declared SLA across the offering set, in seconds. */ + slowestSlaSeconds: number +} + +export interface DiscoverDeps { + chain: ChainClient + allowedModels?: string[] + minProvidersPerModel: number + cacheTtlSeconds: number + now?: () => number +} + +/** A provider plus the model offerings it currently advertises. */ +export interface ProviderListing { + provider: ProviderRow + offerings: ModelOffering[] +} + +interface CacheEntry { + expiresAt: number + data: T +} + +export class ModelDiscovery { + private modelsCache: CacheEntry | null = null + private providersCache: CacheEntry | null = null + + constructor(private readonly deps: DiscoverDeps) {} + + async list(): Promise { + const now = (this.deps.now ?? Date.now)() + if (this.modelsCache && this.modelsCache.expiresAt > now) return this.modelsCache.data + const {summaries} = await this.scan() + this.modelsCache = {data: summaries, expiresAt: now + this.deps.cacheTtlSeconds * 1000} + return summaries + } + + async listProviders(): Promise { + const now = (this.deps.now ?? Date.now)() + if (this.providersCache && this.providersCache.expiresAt > now) return this.providersCache.data + const {providers} = await this.scan() + this.providersCache = {data: providers, expiresAt: now + this.deps.cacheTtlSeconds * 1000} + return providers + } + + invalidate(): void { + this.modelsCache = null + this.providersCache = null + } + + private async scan(): Promise<{summaries: ModelSummary[]; providers: ProviderListing[]}> { + const allow = this.deps.allowedModels + const nowSec = Math.floor(((this.deps.now ?? Date.now)()) / 1000) + const byModel = new Map() + const providers: ProviderListing[] = [] + + let cursor = 0n + for (let i = 0; i < 20; i++) { + const {page, nextCursor} = await listProviders(this.deps.chain, cursor, 50n) + for (const p of page) { + if (!isUsable(p, nowSec)) continue + const offerings = await getOfferings(this.deps.chain, p.owner) + const visible = allow ? offerings.filter(o => allow.includes(o.modelId)) : offerings + if (visible.length > 0) providers.push({provider: p, offerings: visible}) + for (const o of visible) mergeOffering(byModel, o) + } + if (nextCursor === cursor || page.length === 0) break + cursor = nextCursor + } + + const summaries: ModelSummary[] = [] + for (const [id, agg] of byModel) { + if (agg.count < this.deps.minProvidersPerModel) continue + summaries.push({ + id, + providerCount: agg.count, + minInputPrice: agg.inputPrices.reduce((a, b) => (a < b ? a : b)), + medianInputPrice: median(agg.inputPrices), + minOutputPrice: agg.outputPrices.reduce((a, b) => (a < b ? a : b)), + medianOutputPrice: median(agg.outputPrices), + slowestSlaSeconds: agg.slowest, + }) + } + summaries.sort((a, b) => a.id.localeCompare(b.id)) + providers.sort((a, b) => a.provider.owner.localeCompare(b.provider.owner)) + return {summaries, providers} + } +} + +function isUsable(p: ProviderRow, nowSec: number): boolean { + if (!p.active) return false + return isHeartbeatFresh(p.lastHeartbeat, nowSec) +} + +function mergeOffering( + byModel: Map, + o: ModelOffering, +): void { + const slot = byModel.get(o.modelId) ?? {inputPrices: [], outputPrices: [], slowest: 0, count: 0} + slot.inputPrices.push(o.inputPricePerMillionTokens) + slot.outputPrices.push(o.outputPricePerMillionTokens) + slot.slowest = Math.max(slot.slowest, Number(o.maxLatencySeconds)) + slot.count += 1 + byModel.set(o.modelId, slot) +} + +function median(values: bigint[]): bigint { + const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + const mid = Math.floor(sorted.length / 2) + if (sorted.length % 2 === 1) return sorted[mid]! + return (sorted[mid - 1]! + sorted[mid]!) / 2n +} diff --git a/src/t4t/modes/gateway/selector.ts b/src/t4t/modes/gateway/selector.ts new file mode 100644 index 0000000..832a778 --- /dev/null +++ b/src/t4t/modes/gateway/selector.ts @@ -0,0 +1,114 @@ +import type {Address} from 'viem' +import type {ChainClient} from '../../lib/chain' +import {getOfferings, listProviders} from '../../lib/chain' +import type {ModelOffering, ProviderRow} from '../../lib/types' + +export type SelectionStrategy = 'cheapest' | 'top_rep_cheapest' | 'manual' + +export interface SelectionContext { + modelId: string + /** Optional cap on (input + output) xBZZ wei per 1M tokens combined. */ + maxPrice?: bigint + /** Required when strategy = 'manual'. */ + manualProvider?: Address +} + +function combinedPrice(o: ModelOffering): bigint { + return o.inputPricePerMillionTokens + o.outputPricePerMillionTokens +} + +export interface CandidateProvider { + provider: ProviderRow + offering: ModelOffering +} + +const MIN_TOTAL_JOBS = 20 +const MIN_SUCCESS_RATE = 0.95 +/** Share of routes given to new providers so they can build reputation. */ +const EXPLORATION_SHARE = 0.05 +/** Matches `ProviderRegistry.HEARTBEAT_TTL`. Providers stale past this drop + * from the selectable set, even if `active` is still true on-chain. */ +const HEARTBEAT_TTL_SECONDS = 600 + +/** + * Pull the active provider set, filter to those that offer the requested model + * within budget, then rank per the selected strategy. See spec §8. + */ +export async function selectProvider( + chain: ChainClient, + strategy: SelectionStrategy, + ctx: SelectionContext, +): Promise { + if (strategy === 'manual') { + if (!ctx.manualProvider) throw new Error('manual strategy requires T4T_MANUAL_PROVIDER') + return matchOffering(chain, ctx.manualProvider, ctx) + } + + const candidates: CandidateProvider[] = [] + const now = Math.floor(Date.now() / 1000) + let cursor = 0n + for (let i = 0; i < 20; i++) { + const {page, nextCursor} = await listProviders(chain, cursor, 50n) + for (const provider of page) { + if (!provider.active) continue + if (!isHeartbeatFresh(provider.lastHeartbeat, now)) continue + const offerings = await getOfferings(chain, provider.owner) + const offering = offerings.find( + o => + o.modelId === ctx.modelId && + (ctx.maxPrice === undefined || combinedPrice(o) <= ctx.maxPrice), + ) + if (offering) candidates.push({provider, offering}) + } + if (nextCursor === cursor || page.length === 0) break + cursor = nextCursor + } + return rank(candidates, strategy) +} + +export function isHeartbeatFresh(lastHeartbeat: number | bigint, nowSeconds: number): boolean { + const last = typeof lastHeartbeat === 'bigint' ? Number(lastHeartbeat) : lastHeartbeat + if (last === 0) return false + return last + HEARTBEAT_TTL_SECONDS >= nowSeconds +} + +async function matchOffering( + chain: ChainClient, + owner: Address, + ctx: SelectionContext, +): Promise { + const offerings = await getOfferings(chain, owner) + const offering = offerings.find( + o => o.modelId === ctx.modelId && (ctx.maxPrice === undefined || combinedPrice(o) <= ctx.maxPrice), + ) + if (!offering) return null + const {page} = await listProviders(chain, 0n, 1n) + // We don't strictly need the row for manual mode, so synthesize the parts + // the caller cares about from the offerings call. + const provider = (page.find(p => p.owner === owner) ?? {owner}) as ProviderRow + return {provider, offering} +} + +function rank(candidates: CandidateProvider[], strategy: SelectionStrategy): CandidateProvider | null { + if (candidates.length === 0) return null + + if (strategy === 'cheapest') { + return [...candidates].sort((a, b) => Number(combinedPrice(a.offering) - combinedPrice(b.offering)))[0]! + } + + // top_rep_cheapest + const newcomers = candidates.filter(c => c.provider.totalJobs < MIN_TOTAL_JOBS) + const seasoned = candidates + .filter(c => c.provider.totalJobs >= MIN_TOTAL_JOBS) + .filter(c => successRate(c.provider) >= MIN_SUCCESS_RATE) + + if (seasoned.length > 0 && newcomers.length > 0 && Math.random() < EXPLORATION_SHARE) { + return newcomers[Math.floor(Math.random() * newcomers.length)]! + } + const pool = seasoned.length > 0 ? seasoned : candidates + return [...pool].sort((a, b) => Number(combinedPrice(a.offering) - combinedPrice(b.offering)))[0]! +} + +function successRate(p: ProviderRow): number { + return p.totalJobs === 0 ? 0 : p.successfulJobs / p.totalJobs +} diff --git a/src/users/processor.ts b/src/users/processor.ts new file mode 100644 index 0000000..a182cc5 --- /dev/null +++ b/src/users/processor.ts @@ -0,0 +1,51 @@ +import type { Job } from 'bullmq'; +import type { User } from '@prisma/client'; +import { BaseProcessor } from '../queue/processor'; +import { model, prisma } from '../db'; +import { creditWelcomeGrant } from '../accounts/ledger'; +import { logger } from '../logger'; + +const USER_FIELDS = ['email', 'emailVerified', 'role', 'stripeCustomerId', 'country'] as const; + +class UsersProcessor extends BaseProcessor { + constructor() { + super('user', USER_FIELDS); + } + + /** New user → create their Account (which in turn triggers the welcome grant + * if the user is already email-verified, e.g. via Google OAuth). */ + protected async onCreated(_job: Job, user: User): Promise { + await model.account.create({ + data: { + userId: user.id, + balanceXbzzWei: '0', + reservedXbzzWei: '0', + }, + }); + } + + /** + * Field-level handlers fire when a scalar column changes (computed by + * BaseProcessor's diff). We watch `emailVerified` so email-signup users who + * verify *after* their account row exists still get the welcome grant. + * Idempotent — `creditWelcomeGrant` keys on the account id. + */ + protected getFieldHandlers(_job: Job, entity: User): Record Promise> { + return { + emailVerified: async () => { + if (!entity.emailVerified) return; // only fire on false → true + const account = await prisma.account.findUnique({ + where: { userId: entity.id }, + select: { id: true }, + }); + if (!account) { + logger.warn({ userId: entity.id }, 'verified user has no account row yet — skipping grant'); + return; + } + await creditWelcomeGrant(account.id); + }, + }; + } +} + +export const usersProcessor = new UsersProcessor(); diff --git a/src/users/routes.ts b/src/users/routes.ts new file mode 100644 index 0000000..a3ce1b0 --- /dev/null +++ b/src/users/routes.ts @@ -0,0 +1,21 @@ +import Elysia from 'elysia'; +import { prisma } from '../db'; +import { jwtGuard } from '../auth/jwt'; + +export const usersRoutes = new Elysia({ prefix: '/users' }) + .use(jwtGuard) + .get('/me', async ({ user }) => { + const row = await prisma.user.findUniqueOrThrow({ + where: { id: user.userId }, + select: { + id: true, + email: true, + role: true, + emailVerified: true, + authProvider: true, + country: true, + createdAt: true, + }, + }); + return row; + }); diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..2f6bc91 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,18 @@ +import { startWorkers, stopWorkers } from './queue/startup'; +import { startClaimWatcher } from './chain/claimWatcher'; +import { startBalanceMonitor } from './chain/balanceMonitor'; +import { logger } from './logger'; + +startWorkers(); +startClaimWatcher(); +startBalanceMonitor(); + +logger.info('worker process up'); + +const shutdown = async (sig: string) => { + logger.info({ sig }, 'shutting down'); + await stopWorkers(); + process.exit(0); +}; +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts new file mode 100644 index 0000000..8f38b60 --- /dev/null +++ b/test/auth.e2e-spec.ts @@ -0,0 +1,6 @@ +import { describeAuth } from './auth'; +import './setup'; + +// `describeAuth()` registers its own `describe(...)` block. setup.ts's +// top-level beforeAll/afterAll fire once for the file. +describeAuth(); diff --git a/test/auth.ts b/test/auth.ts new file mode 100644 index 0000000..ed7943e --- /dev/null +++ b/test/auth.ts @@ -0,0 +1,204 @@ +/** + * Sequential auth + profile + API key flow against the running hosted-gateway + * stack. Mirrors cipherdolls/core/test/auth.ts shape: one `describeAuth()` that + * exercises each persona end-to-end, with state shared across `it()` blocks + * via module-level `let` bindings. + * + * What this covers: + * - Email + password signup (with verification token echoed back in dev/test). + * - Email verification → account activation → 50 xBZZ welcome grant credited. + * - JWT-authed /users/me and /accounts/me reads. + * - API key creation, listing, JWT-or-key auth interchange, deletion. + * - /api-keys returns the persona's keys only. + * - Login round-trip returns a fresh JWT for the same user. + */ +import { describe, it, expect } from 'bun:test'; +import { + api, + get, + post, + del, + auth, + signupEmail, + verifyEmail, + loginEmail, + createApiKey, + waitForQueuesEmpty, +} from './helpers'; + +const WELCOME_XBZZ_WEI = (50n * 10n ** 18n).toString(); + +export function describeAuth(): void { + describe('auth + profile + api-keys (e2e)', () => { + + /* ── Alice: full signup → verify → welcome grant ────────────── */ + + it('signs up Alice via email/password', async () => { + const email = `alice-${Date.now()}@hosted.test`; + const password = 'correct-horse-battery-staple'; + const { jwt, userId, verificationToken } = await signupEmail(email, password); + expect(jwt).toBeTruthy(); + expect(userId).toBeTruthy(); + expect(verificationToken).toBeTruthy(); + auth.alice = { email, password, jwt, userId }; + (auth.alice as any).verificationToken = verificationToken; + }); + + it('Alice can read /users/me with the issued JWT (email unverified)', async () => { + const { status, body } = await get('/users/me', auth.alice.jwt!); + expect(status).toBe(200); + expect(body).toMatchObject({ + id: auth.alice.userId, + email: auth.alice.email, + role: 'USER', + emailVerified: false, + authProvider: 'email', + }); + }); + + it('Alice has no welcome grant yet (account pending email verify)', async () => { + await waitForQueuesEmpty(); + const { status, body } = await get('/accounts/me', auth.alice.jwt!); + expect(status).toBe(200); + expect(body.balanceXbzzWei).toBe('0'); + expect(body.reservedXbzzWei).toBe('0'); + }); + + it('Alice verifies her email → balance credited with 50 xBZZ', async () => { + const token = (auth.alice as any).verificationToken as string; + await verifyEmail(token); + await waitForQueuesEmpty(); + + const { status, body } = await get('/accounts/me', auth.alice.jwt!); + expect(status).toBe(200); + expect(body.balanceXbzzWei).toBe(WELCOME_XBZZ_WEI); + expect(body.reservedXbzzWei).toBe('0'); + expect(body.availableXbzzWei).toBe(WELCOME_XBZZ_WEI); + + const me = await get('/users/me', auth.alice.jwt!); + expect(me.body.emailVerified).toBe(true); + }); + + it('Alice can list her ledger and see the welcomeGrant row', async () => { + const { status, body } = await get('/accounts/me/ledger', auth.alice.jwt!); + expect(status).toBe(200); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(1); + expect(body[0].kind).toBe('welcomeGrant'); + expect(body[0].deltaXbzzWei).toBe(WELCOME_XBZZ_WEI); + }); + + /* ── API keys ─────────────────────────────────────────────── */ + + it('Alice creates an API key', async () => { + const { id, key } = await createApiKey(auth.alice.jwt!, 'alice-key'); + expect(id).toBeTruthy(); + expect(key.startsWith('t4t-live-')).toBe(true); + auth.alice.apiKey = key; + auth.alice.apiKeyId = id; + }); + + it('Alice can read /users/me with her API key (no JWT)', async () => { + const { status, body } = await get('/users/me', auth.alice.apiKey); + expect(status).toBe(200); + expect(body.id).toBe(auth.alice.userId); + }); + + it('Alice lists /api-keys and sees only her key', async () => { + const { status, body } = await get('/api-keys', auth.alice.jwt!); + expect(status).toBe(200); + expect(body.length).toBe(1); + expect(body[0].id).toBe(auth.alice.apiKeyId); + expect(body[0].name).toBe('alice-key'); + // Secret value is never echoed after creation. + expect(body[0].key).toBeUndefined(); + }); + + /* ── Bob: isolation ───────────────────────────────────────── */ + + it('Bob signs up, verifies, and gets his own welcome grant', async () => { + const email = `bob-${Date.now()}@hosted.test`; + const password = 'tr0ub4dor&3'; + const signup = await signupEmail(email, password); + await verifyEmail(signup.verificationToken); + await waitForQueuesEmpty(); + const { id: apiKeyId, key: apiKey } = await createApiKey(signup.jwt, 'bob-key'); + auth.bob = { + email, + password, + jwt: signup.jwt, + userId: signup.userId, + apiKey, + apiKeyId, + }; + + const accountRes = await get('/accounts/me', signup.jwt); + expect(accountRes.body.balanceXbzzWei).toBe(WELCOME_XBZZ_WEI); + }); + + it("Bob cannot see Alice's API keys", async () => { + const { status, body } = await get('/api-keys', auth.bob.jwt!); + expect(status).toBe(200); + expect(body.length).toBe(1); + expect(body[0].id).toBe(auth.bob.apiKeyId); + }); + + it("Bob cannot delete Alice's API key", async () => { + const { status } = await del(`/api-keys/${auth.alice.apiKeyId}`, auth.bob.jwt!); + expect(status).toBe(404); + }); + + /* ── Login round-trip ─────────────────────────────────────── */ + + it('Alice logs in with email + password and gets a fresh JWT', async () => { + const { jwt, userId } = await loginEmail(auth.alice.email!, auth.alice.password!); + expect(userId).toBe(auth.alice.userId); + // The new JWT still resolves to the same user. + const { status, body } = await get('/users/me', jwt); + expect(status).toBe(200); + expect(body.id).toBe(auth.alice.userId); + }); + + it('login with wrong password is rejected', async () => { + const { status } = await post('/auth/email/login', { + email: auth.alice.email, + password: 'WRONG', + }); + expect(status).toBe(401); + }); + + it('signup with an existing email is rejected (409)', async () => { + const { status } = await post('/auth/email/signup', { + email: auth.alice.email, + password: 'another-password-123', + }); + expect(status).toBe(409); + }); + + /* ── Unauthed access ──────────────────────────────────────── */ + + it('rejects /users/me with no token (401)', async () => { + const { status } = await api('GET', '/users/me'); + expect(status).toBe(401); + }); + + it('rejects /accounts/me with garbage token (401)', async () => { + const { status } = await get('/accounts/me', 'not-a-real-token'); + expect(status).toBe(401); + }); + + /* ── Key deletion ─────────────────────────────────────────── */ + + it('Alice can delete her API key, and it stops working', async () => { + const del1 = await del(`/api-keys/${auth.alice.apiKeyId}`, auth.alice.jwt!); + expect(del1.status).toBe(200); + + const meAfter = await get('/users/me', auth.alice.apiKey); + expect(meAfter.status).toBe(401); + + // JWT still works — we only revoked the key. + const meJwt = await get('/users/me', auth.alice.jwt!); + expect(meJwt.status).toBe(200); + }); + }); +} diff --git a/test/fixtures/fundOperator.ts b/test/fixtures/fundOperator.ts new file mode 100644 index 0000000..2dd8f4d --- /dev/null +++ b/test/fixtures/fundOperator.ts @@ -0,0 +1,122 @@ +/** + * Test bootstrap: gives the operator wallet (and optionally the test provider) + * xDAI + xBZZ on the anvil fork. The contracts live on Gnosis mainnet at the + * addresses configured via env (managed by the t4t repo), so we don't deploy + * anything — we just fund the test wallets against the existing tokens. + * + * Two cheatcodes: + * - `anvil_setBalance(addr, wei)` seeds native xDAI + * - `anvil_impersonateAccount(whale)` lets us send `xBZZ.transfer` as the whale + * + * The whale address is configurable via `XBZZ_WHALE` env (default below is a + * known high-balance holder on Gnosis Chain). Refresh from Gnosisscan if the + * default ever runs dry. + */ +import { + createPublicClient, + createTestClient, + createWalletClient, + http, + parseEther, + type Address, + type Hex, +} from 'viem'; +import { gnosis } from 'viem/chains'; +import { privateKeyToAccount } from 'viem/accounts'; + +const ERC20_TRANSFER_ABI = [ + { + type: 'function', + name: 'transfer', + stateMutability: 'nonpayable', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ type: 'bool' }], + }, + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{ name: 'a', type: 'address' }], + outputs: [{ type: 'uint256' }], + }, +] as const; + +export interface FundConfig { + rpcUrl: string; + xbzzAddress: Address; + whale: Address; + recipients: Array<{ address: Address; xdai: bigint; xbzz: bigint }>; +} + +export async function fundOperator(cfg: FundConfig): Promise { + const transport = http(cfg.rpcUrl); + const pub = createPublicClient({ chain: gnosis, transport }); + const test = createTestClient({ chain: gnosis, transport, mode: 'anvil' }); + + // xDAI via setBalance — instant, no impersonation needed. + for (const r of cfg.recipients) { + await test.setBalance({ address: r.address, value: r.xdai }); + } + + // xBZZ via impersonation + transfer. The whale must have ≥ Σ xbzz on chain. + await test.impersonateAccount({ address: cfg.whale }); + // Make sure the whale itself has xDAI to pay gas on the transfer. + await test.setBalance({ address: cfg.whale, value: parseEther('1') }); + + const whaleClient = createWalletClient({ + chain: gnosis, + transport, + account: cfg.whale, + }); + for (const r of cfg.recipients) { + const hash = await whaleClient.writeContract({ + address: cfg.xbzzAddress, + abi: ERC20_TRANSFER_ABI, + functionName: 'transfer', + args: [r.address, r.xbzz], + }); + await pub.waitForTransactionReceipt({ hash }); + } + await test.stopImpersonatingAccount({ address: cfg.whale }); + + // Sanity-check the funding landed. + for (const r of cfg.recipients) { + const bal = (await pub.readContract({ + address: cfg.xbzzAddress, + abi: ERC20_TRANSFER_ABI, + functionName: 'balanceOf', + args: [r.address], + })) as bigint; + if (bal < r.xbzz) { + throw new Error(`funding failed: ${r.address} expected ${r.xbzz}, got ${bal}`); + } + } +} + +/** + * Convenience: derive the operator address from `OPERATOR_PRIVATE_KEY` and + * apply a standard funding amount (1k xBZZ + 1 xDAI). + */ +export async function fundOperatorFromEnv(extra: Address[] = []): Promise { + const operator = privateKeyToAccount(process.env.OPERATOR_PRIVATE_KEY as Hex).address; + const recipients = [ + { address: operator, xdai: parseEther('1'), xbzz: 1000n * 10n ** 18n }, + ...extra.map((address) => ({ + address, + xdai: parseEther('1'), + xbzz: 1000n * 10n ** 18n, + })), + ]; + await fundOperator({ + rpcUrl: process.env.GNOSIS_RPC_URL ?? 'http://localhost:8545', + xbzzAddress: (process.env.XBZZ_ADDRESS ?? '0xdBF3Ea6F5beE45c02255B2c26a16F300502F68da') as Address, + // The "t4t wallet" on Gnosis — a known funded address that's used as the + // impersonation source on the fork. Holds xBZZ + xDAI on mainnet, so on + // the fork we anvil_impersonateAccount it and transfer to the operator. + whale: (process.env.XBZZ_WHALE ?? '0xaCfE00BeA3f8acC8e500c7Ea6421902014778a85') as Address, + recipients, + }); +} diff --git a/test/gateway.e2e-spec.ts b/test/gateway.e2e-spec.ts new file mode 100644 index 0000000..5617f4f --- /dev/null +++ b/test/gateway.e2e-spec.ts @@ -0,0 +1,4 @@ +import { describeGateway } from './gateway'; +import './setup'; + +describeGateway(); diff --git a/test/gateway.ts b/test/gateway.ts new file mode 100644 index 0000000..e2a1b8b --- /dev/null +++ b/test/gateway.ts @@ -0,0 +1,96 @@ +/** + * Chat-completion end-to-end. This is the heaviest spec — requires the full + * gateway profile (anvil fork + Bee + a registered t4t-provider + mock-ollama) + * AND a funded operator wallet on the fork. + * + * If the upstream prerequisites (provider registration, xBZZ funding) haven't + * been set up the test surfaces the actual failure mode — `no provider matches + * model`, 503 on gateway-context boot, etc. — rather than green-washing. + */ +import { describe, it, expect } from 'bun:test'; +import { + api, + get, + post, + auth, + signupEmail, + verifyEmail, + createApiKey, + waitForQueuesEmpty, +} from './helpers'; + +export function describeGateway(): void { + describe('chat completions (e2e)', () => { + + it('signs Carol up + verifies + mints an API key', async () => { + const email = `carol-${Date.now()}@hosted.test`; + const signup = await signupEmail(email, 'a-strong-password-1234'); + await verifyEmail(signup.verificationToken); + await waitForQueuesEmpty(); + const { id, key } = await createApiKey(signup.jwt, 'carol-key'); + auth.carol = { + email, + password: 'a-strong-password-1234', + jwt: signup.jwt, + userId: signup.userId, + apiKey: key, + apiKeyId: id, + }; + }); + + it('GET /v1/models lists the catalogue (may be empty until a provider registers)', async () => { + const { status, body } = await get('/v1/models', auth.carol.apiKey); + // Auth alone gets us a 200; whether the catalogue is empty depends on + // whether the t4t-provider container had time to register before this + // request fired. + expect(status).toBe(200); + expect(body).toHaveProperty('object', 'list'); + expect(Array.isArray(body.data)).toBe(true); + }); + + it('POST /v1/chat/completions returns a 200 with assistant content (mock-ollama)', async () => { + // The mock-ollama always returns "pong (mock-ollama)" regardless of + // input, so this assertion only verifies the route + on-chain settle + // path, not LLM quality. + const { status, body } = await post( + '/v1/chat/completions', + { + model: 'qwen2.5:0.5b', + messages: [{ role: 'user', content: 'ping' }], + max_tokens: 32, + }, + auth.carol.apiKey, + ); + + if (status !== 200) { + // Surface the upstream error so we know what to fix: provider not + // registered, gateway context boot failed, insufficient balance, etc. + console.error(`chat completion failed: status=${status} body=${JSON.stringify(body)}`); + } + + expect(status).toBe(200); + expect(body).toHaveProperty('choices'); + expect(body.choices[0]?.message?.content).toMatch(/pong/); + }); + + it("debits Carol's balance after the JobClaimed watcher settles", async () => { + // The on-chain claimJob fires from the provider after delivering. Our + // worker's claimWatcher tails JobClaimed events and runs settleXbzz. + // Poll for up to 30s. + const deadline = Date.now() + 30_000; + let balance = 50n * 10n ** 18n; + while (Date.now() < deadline) { + const { body } = await get('/accounts/me', auth.carol.jwt!); + balance = BigInt(body.balanceXbzzWei); + if (balance < 50n * 10n ** 18n) break; + await new Promise((r) => setTimeout(r, 1000)); + } + expect(balance).toBeLessThan(50n * 10n ** 18n); + + const ledger = await get('/accounts/me/ledger', auth.carol.jwt!); + const settle = ledger.body.find((e: any) => e.kind === 'settle'); + expect(settle).toBeDefined(); + expect(BigInt(settle.deltaXbzzWei)).toBeLessThan(0n); + }); + }); +} diff --git a/test/health.e2e-spec.ts b/test/health.e2e-spec.ts new file mode 100644 index 0000000..f50dc81 --- /dev/null +++ b/test/health.e2e-spec.ts @@ -0,0 +1,11 @@ +import { describe, test, expect } from 'bun:test'; +import { api } from './helpers'; + +describe('health', () => { + test('GET /health returns ok', async () => { + const res = await api('/health'); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..55fbba5 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,144 @@ +/** + * Shared E2E helpers. Tests run inside the dockerized stack — the api/worker + * are reachable as service names on the compose network. + * + * Pattern lifted from cipherdolls/core/test/helpers.ts: a single `api(...)` + * wrapper around fetch, a `waitForQueuesEmpty()` poller for the + * model.x.create → BullMQ → processor pipeline, and persona-style auth + * records keyed in a shared `auth` table. + */ +import IORedis from 'ioredis'; + +export const BASE_URL = process.env.HOSTED_GATEWAY_URL ?? 'http://localhost:8080'; +const REDIS_HOST = process.env.REDIS_HOST ?? 'localhost'; +const REDIS_PORT = parseInt(process.env.REDIS_PORT ?? '6379'); + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +export interface ApiResult { + status: number; + body: T; +} + +/** + * Issue an HTTP request to the hosted-gateway api. Accepts either a JWT or an + * API key (bare uuid OR the `t4t-live-` form) as `auth`. + */ +export async function api( + method: HttpMethod, + path: string, + auth?: string, + body?: any, +): Promise> { + const headers: Record = { 'Content-Type': 'application/json' }; + if (auth) headers.Authorization = `Bearer ${auth}`; + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let parsed: any = text; + try { parsed = JSON.parse(text); } catch { /* keep raw */ } + return { status: res.status, body: parsed as T }; +} + +export const get = (path: string, auth?: string) => api('GET', path, auth); +export const post = (path: string, body: any, auth?: string) => api('POST', path, auth, body); +export const del = (path: string, auth?: string) => api('DELETE', path, auth); + +/** + * Wait until all BullMQ queues are empty AND stay empty for `settleMs` — + * defends against cascades where one processor enqueues the next (eg + * user.created → account.created → ledger welcomeGrant). + */ +export async function waitForQueuesEmpty( + timeoutMs = 15_000, + settleMs = 500, +): Promise { + const redis = new IORedis(REDIS_PORT, REDIS_HOST, { maxRetriesPerRequest: null }); + const interval = 100; + let elapsed = 0; + let emptyFor = 0; + try { + while (elapsed < timeoutMs) { + const queueKeys = [ + ...(await redis.keys('bull:*:waiting')), + ...(await redis.keys('bull:*:active')), + ...(await redis.keys('bull:*:delayed')), + ]; + let total = 0; + for (const key of queueKeys) { + const type = await redis.type(key); + if (type === 'list') total += await redis.llen(key); + else if (type === 'zset') total += await redis.zcard(key); + } + if (total === 0) { + emptyFor += interval; + if (emptyFor >= settleMs) return; + } else { + emptyFor = 0; + } + await new Promise((r) => setTimeout(r, interval)); + elapsed += interval; + } + throw new Error(`queues not empty after ${timeoutMs}ms`); + } finally { + redis.disconnect(); + } +} + +/* ──────────────────────────────────────────────────────────────── + Auth flow helpers — mirror cipherdolls' signIn / createApiKey shape, + but for email/password (Phase 1 doesn't wire real Google OAuth in tests). + ──────────────────────────────────────────────────────────────── */ + +export interface AuthRecord { + email: string; + password: string; + jwt: string; + userId: string; + apiKey: string; + apiKeyId: string; +} + +/** Persona registry — populated by `describe*()` factories and read by + * downstream specs. New personas added as the test suite grows. */ +export const auth: Record> = { + alice: {}, + bob: {}, + carol: {}, + dave: {}, +}; + +export async function signupEmail( + email: string, + password: string, +): Promise<{ jwt: string; userId: string; verificationToken: string }> { + const { status, body } = await post('/auth/email/signup', { email, password }); + if (status !== 200) throw new Error(`signup ${email} failed: ${status} ${JSON.stringify(body)}`); + return { jwt: body.token, userId: body.user.id, verificationToken: body.verificationToken }; +} + +export async function verifyEmail(token: string): Promise { + const { status, body } = await post('/auth/email/verify', { token }); + if (status !== 200) throw new Error(`verify failed: ${status} ${JSON.stringify(body)}`); +} + +export async function loginEmail( + email: string, + password: string, +): Promise<{ jwt: string; userId: string }> { + const { status, body } = await post('/auth/email/login', { email, password }); + if (status !== 200) throw new Error(`login ${email} failed: ${status} ${JSON.stringify(body)}`); + return { jwt: body.token, userId: body.user.id }; +} + +export async function createApiKey( + jwt: string, + name: string, +): Promise<{ id: string; key: string }> { + const { status, body } = await post('/api-keys', { name }, jwt); + if (status !== 200) throw new Error(`createApiKey failed: ${status} ${JSON.stringify(body)}`); + return { id: body.id, key: body.key }; +} diff --git a/test/models.e2e-spec.ts b/test/models.e2e-spec.ts new file mode 100644 index 0000000..d0826c2 --- /dev/null +++ b/test/models.e2e-spec.ts @@ -0,0 +1,4 @@ +import { describeModels } from './models'; +import './setup'; + +describeModels(); diff --git a/test/models.ts b/test/models.ts new file mode 100644 index 0000000..ede1428 --- /dev/null +++ b/test/models.ts @@ -0,0 +1,62 @@ +/** + * Lightweight /v1/models smoke test against the anvil-forked Gnosis chain. + * + * Validates: + * - api boots and `getGatewayContext()` succeeds (operator wallet, Bee, PSS) + * - `ModelDiscovery.list()` runs against the live ProviderRegistry on the fork + * - the route returns the OpenAI `list` shape + * + * No chat completion, no on-chain writes, no t4t-provider container required. + * If the fork has any mainnet provider that heartbeated within the last 10 + * min before the fork, the catalogue should be non-empty. + */ +import { describe, it, expect } from 'bun:test'; +import { + auth, + signupEmail, + verifyEmail, + createApiKey, + get, + waitForQueuesEmpty, +} from './helpers'; + +export function describeModels(): void { + describe('/v1/models (e2e against forked Gnosis)', () => { + it('signs up Dave + mints an API key', async () => { + const email = `dave-${Date.now()}@hosted.test`; + const signup = await signupEmail(email, 'a-strong-password-1234'); + await verifyEmail(signup.verificationToken); + await waitForQueuesEmpty(); + const { id, key } = await createApiKey(signup.jwt, 'dave-key'); + auth.dave = { + email, + password: 'a-strong-password-1234', + jwt: signup.jwt, + userId: signup.userId, + apiKey: key, + apiKeyId: id, + }; + }); + + it('GET /v1/models returns the OpenAI list shape', async () => { + const { status, body } = await get('/v1/models', auth.dave.apiKey); + if (status !== 200) { + console.error('models endpoint failed:', JSON.stringify(body)); + } + expect(status).toBe(200); + expect(body).toHaveProperty('object', 'list'); + expect(Array.isArray(body.data)).toBe(true); + }); + + it('catalogue contains at least one model (mainnet has live providers)', async () => { + const { body } = await get('/v1/models', auth.dave.apiKey); + // Phase 1: tolerate an empty catalogue (no live mainnet providers + // recently heartbeated). Log the count so the run surfaces it. + console.log(`/v1/models returned ${body.data.length} models`); + if (body.data.length > 0) { + expect(body.data[0]).toHaveProperty('id'); + expect(body.data[0]).toHaveProperty('object', 'model'); + } + }); + }); +} diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..d715b64 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,66 @@ +/** + * E2E test bootstrap. Runs inside the `bun` test container (see + * devops/local/docker-compose-test.yaml) against the dockerized stack: + * + * - `prisma migrate reset` so the schema matches HEAD. + * - `redis-cli FLUSHALL` clears queues + locks + nonce. + * - Fund the operator wallet on the anvil fork with xBZZ + xDAI via + * `fundOperatorFromEnv()` (anvil_setBalance + xBZZ whale impersonation). + * The contracts themselves already exist on the fork — they're managed + * by the t4t repo and live on Gnosis mainnet; the fork inherits them. + * - `evm_snapshot` so each spec can revert to a clean fork. + */ +import { $ } from 'bun'; +import { redisConnection } from '../src/queue/connection'; +import { fundOperatorFromEnv } from './fixtures/fundOperator'; + +let snapshotId: string | null = null; + +export async function setBeforeAll(): Promise { + // The compose `prisma` service applied migrations on stack-up. Postgres uses + // tmpfs in the test stack so each compose run starts fresh — within one + // run we just need to clear Redis (BullMQ jobs + the operator nonce). + await redisConnection.flushall(); + + // Fund the operator wallet on the fork. Only needed for specs that settle a + // chat completion on-chain (gateway / concurrency / cancellation). Auth / + // accounts specs don't touch chain, so a funding failure is non-fatal here — + // the chain specs will surface a clearer error later if they actually need it. + try { + await fundOperatorFromEnv(); + } catch (err: any) { + console.warn(`[setup] fundOperatorFromEnv failed (non-fatal for non-chain specs): ${err.message}`); + } + + snapshotId = await anvilSnapshot().catch(() => null); +} + +export async function setAfterAll(): Promise { + if (snapshotId) await anvilRevert(snapshotId).catch(() => undefined); + await redisConnection.quit(); +} + +async function anvilSnapshot(): Promise { + const res = await fetch(process.env.GNOSIS_RPC_URL ?? 'http://localhost:8545', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'evm_snapshot', params: [], id: 1 }), + }); + const json = (await res.json()) as { result?: string }; + if (!json.result) throw new Error('evm_snapshot returned no id'); + return json.result; +} + +async function anvilRevert(id: string): Promise { + await fetch(process.env.GNOSIS_RPC_URL ?? 'http://localhost:8545', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'evm_revert', params: [id], id: 1 }), + }); +} + +// Bun auto-invokes the top-level `beforeAll` / `afterAll` from the preloaded +// setup file when called as `--preload`. +import { afterAll, beforeAll } from 'bun:test'; +beforeAll(setBeforeAll); +afterAll(setAfterAll); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4bcb8da --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "types": ["bun-types"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}