diff --git a/.env.example b/.env.example index cb72e60..de4bd80 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ BACKEND_QUEUE_ID=1 ENGINE_TIMEOUT_MS=30000 # Engine stream batching -STREAM_RETENTION_MS=1200000 +STREAM_RETENTION_MS=21600000 SNAPSHOT_INTERVAL=60000 STREAM_READ_BATCH_SIZE=200 RESPONSE_BATCH_SIZE=50 @@ -33,9 +33,15 @@ RATE_LIMIT_ORDER_WINDOW_MS=60000 LOG_LEVEL=debug +# Production-only external services. They may be omitted in development and test. +# RESEND_API_KEY=re_... +# EMAIL_FROM=PaperDrill +# SENTRY_DSN=https://... +# SENTRY_ENVIRONMENT=production + # Bot credentials BASE_URL=http://localhost:8000 SERVICE_TOKEN=your-service-token-here SERVICE_EMAIL=your-service-email-here MARKET=SOL_USD -MAX_ORDER_QTY=10 \ No newline at end of file +MAX_ORDER_QTY=10 diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..83e4dd8 --- /dev/null +++ b/.env.test @@ -0,0 +1,25 @@ +NODE_ENV=test +PORT=8000 +CORS_ORIGIN=http://localhost:3000 +WS_URL=ws://127.0.0.1:8001 + +DATABASE_URL=postgresql://postgres:password@postgres:5432/paperdrill_test +REDIS_URL=redis://redis:6379 + +JWT_SECRET=paperdrill-integration-tests-only +SERVICE_TOKEN=YzW0PrwwnzD30jyadkz475WiuPAM+3W/LF8umuM/AOc= +SERVICE_TOKEN_HASH=83b9b13ea2ca0050245a4f8070d884968e71cb4e477436f36f305939d39768a4 +SERVICE_EMAIL=sol.service@paperdrill.dev + +INCOMING_STREAM=backend-to-engine-broker +BACKEND_QUEUE_ID=integration-tests +ENGINE_TIMEOUT_MS=5000 + +RATE_LIMIT_ENABLED=false +LOG_LEVEL=info + +STREAM_RETENTION_MS=1200000 +SNAPSHOT_INTERVAL=60000 +STREAM_READ_BATCH_SIZE=200 +RESPONSE_BATCH_SIZE=50 +FLUSH_INTERVAL_MS=250 diff --git a/.github/workflows/ci.yml b/.github/workflows/test.yml similarity index 52% rename from .github/workflows/ci.yml rename to .github/workflows/test.yml index bc4e252..419288e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/test.yml @@ -1,25 +1,18 @@ -name: CI +name: Tests on: push: pull_request: + workflow_dispatch: permissions: contents: read jobs: test: - name: Tests + name: Unit tests runs-on: ubuntu-latest timeout-minutes: 10 - env: - NODE_ENV: test - CORS_ORIGIN: http://localhost:3000 - REDIS_URL: redis://localhost:6379 - DATABASE_URL: postgresql://paperdrill:paperdrill@localhost:5432/paperdrill - JWT_SECRET: ci-jwt-secret - LOG_LEVEL: info - RATE_LIMIT_ENABLED: "false" steps: - name: Checkout repository @@ -42,5 +35,26 @@ jobs: working-directory: engine run: bun install --frozen-lockfile - - name: Run tests - run: bun test + - name: Run unit tests + run: make test-unit + + integration: + name: Integration tests + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.3 + + - name: Install integration test dependencies + working-directory: tests + run: bun install --frozen-lockfile + + - name: Run integration tests + run: make test-integration diff --git a/.gitignore b/.gitignore index 5e7d2ce..42e3834 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .env .env.* !.env.example +!.env.test # caches .eslintcache diff --git a/Makefile b/Makefile index 67b5fb4..5d1672b 100644 --- a/Makefile +++ b/Makefile @@ -1,61 +1,96 @@ -ENV ?= dev +SERVICE ?= +TAG ?= -DEV_FILE := compose.dev.yml -PROD_FILE := compose.prod.yml -BOT_FILE := compose.bot.yml +DEV_COMPOSE := ./scripts/compose.sh dev +PROD_COMPOSE := ./scripts/compose.sh prod -ifeq ($(ENV),dev) -COMPOSE := docker compose -f $(DEV_FILE) -endif - -ifeq ($(ENV),prod) -COMPOSE := docker compose -f $(PROD_FILE) -endif - -ifeq ($(ENV),bot) -COMPOSE := docker compose -f $(BOT_FILE) -endif - -.PHONY: up build down deploy start restart stop status logs migrate-dev migrate-reset migrate-deploy seed clean +.PHONY: up build down restart status logs shell bots-up bots-stop \ + db-prepare db-seed migrate-dev migrate-reset \ + test-unit test-integration bench \ + images-build images-push prod-config deploy prod-status prod-logs \ + prod-bots-up prod-bots-stop clean +# Development up: - $(COMPOSE) up -d + $(DEV_COMPOSE) up -d --wait postgres redis + ./scripts/database.sh dev prepare + $(DEV_COMPOSE) up -d --wait backend engine worker build: - $(COMPOSE) build + $(DEV_COMPOSE) build backend engine worker down: - $(COMPOSE) down - -deploy: - $(COMPOSE) up -d --build - -start: - $(COMPOSE) start + $(DEV_COMPOSE) down --remove-orphans restart: - $(COMPOSE) restart - -stop: - $(COMPOSE) stop + $(DEV_COMPOSE) restart backend engine worker status: - $(COMPOSE) ps + $(DEV_COMPOSE) ps logs: - $(COMPOSE) logs -f $(SERVICE) + $(DEV_COMPOSE) logs -f $(SERVICE) + +shell: + @if [ -z "$(SERVICE)" ]; then echo "Set SERVICE, for example: make shell SERVICE=backend" >&2; exit 1; fi + $(DEV_COMPOSE) exec $(SERVICE) sh + +bots-up: + $(DEV_COMPOSE) --profile bots up -d --build btc-bot sol-bot eth-bot + +bots-stop: + $(DEV_COMPOSE) --profile bots stop btc-bot sol-bot eth-bot + +# Database +db-prepare: + ./scripts/database.sh dev prepare + +db-seed: + ./scripts/database.sh dev seed migrate-dev: - $(COMPOSE) exec backend bun prisma migrate dev + $(DEV_COMPOSE) exec backend bun prisma migrate dev migrate-reset: - $(COMPOSE) exec backend bun prisma migrate reset + $(DEV_COMPOSE) exec backend bun prisma migrate reset -migrate-deploy: - $(COMPOSE) exec backend bun prisma migrate deploy +# Test +test-unit: + cd backend && set -a && . ../.env.test && set +a && bun test + cd engine && set -a && . ../.env.test && set +a && bun test -db-seed: - $(COMPOSE) exec backend bun prisma db seed +test-integration: + sh tests/run-integration.sh + +bench: + sh benchmarks/run.sh + +# Release images +images-build: + ./scripts/images.sh build "$(TAG)" + +images-push: + ./scripts/images.sh push "$(TAG)" + +# Production +prod-config: + $(PROD_COMPOSE) config --quiet + +deploy: + ./scripts/deploy.sh + +prod-status: + $(PROD_COMPOSE) ps + +prod-logs: + $(PROD_COMPOSE) logs -f $(SERVICE) + +prod-bots-up: + $(PROD_COMPOSE) --profile bots pull btc-bot sol-bot eth-bot + $(PROD_COMPOSE) --profile bots up -d btc-bot sol-bot eth-bot + +prod-bots-stop: + $(PROD_COMPOSE) --profile bots stop btc-bot sol-bot eth-bot clean: - docker system prune -f + $(DEV_COMPOSE) down --volumes --remove-orphans diff --git a/README.md b/README.md index 9dd93fc..48d68f5 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,56 @@ -

-

PaperDrill

-

The exchange built for developers, not spectators.

-

- Features • - Architecture • - Tech Stack +

+

PaperDrill

+

The exchange built for developers, not spectators.

+ +

+ Release v1.0.0 + Bun + TypeScript + Docker +

+ +

+ Platform · + API documentation · + API

-

+
-A live, always-on exchange simulator with a real matching engine and order book. Trade on the UI or connect a bot via API - no KYC, no real money, no risk. +PaperDrill is a live paper-trading exchange simulator with a real matching engine and order book. Trade through the web interface or connect a bot through the API—no KYC and no real funds at risk. ## Features -- **Live matching engine** - Price-time priority order matching, not a mock. -- **Real-time order book** - Watch depth and trades move over a live WebSocket feed. -- **Persistent accounts** - Simulated balances, full order history, nothing resets. -- **Public leaderboard** _(Coming Soon)_ - See how your strategy stacks up against others. -- **Bot support** _(Coming Soon)_ - Connect trading bots via REST API and WebSocket. -- **API-first access** _(Coming Soon)_ - Full REST API and API keys for programmatic trading. +- **Live matching engine** — price-time priority matching rather than mocked fills. +- **Real-time market data** — order book, trades, tickers, and updating candles over WebSocket. +- **Persistent accounts** — simulated balances, open orders, order history, and trade history. +- **Public leaderboard** — compare simulated portfolio performance with other traders. +- **Scoped API keys** — programmable account access and order management. +- **Bot-friendly API** — REST trading endpoints and public WebSocket feeds. +- **Daily simulated credit** — idempotent recurring credit with an adjustable PnL baseline. ## Architecture -![PaperDrill-Architecture](/assets/paperdrill-architecture.png) +![PaperDrill architecture](/assets/architecture.png) + +| Service | Responsibility | +| ---------- | --------------------------------------------------------------------------- | +| `frontend` | React SPA for markets, trading, account management, docs, and leaderboard | +| `backend` | Express REST/WebSocket API, authentication, validation, and market metadata | +| `engine` | In-memory balances, order books, matching, and periodic snapshots | +| `worker` | Persists orders/fills and derives candles and tickers | +| `bot` | Optional market makers for the supported markets | +| PostgreSQL | Accounts, markets, orders, fills, candles, credits, and leaderboard data | +| Redis | Commands, responses, event streams, pub/sub, and runtime checkpoints | + +Backend, engine, and worker communicate through Redis. Production uses an external PostgreSQL provider; Redis and engine snapshots use persistent Docker volumes, with nginx running on the application host. ## Tech Stack -| Layer | Technology | -| ----------- | ----------------------------------- | -| Frontend | React + TypeScript + Tailwind CSS | -| Backend | Express + Prisma ORM | -| Engine | Custom matching engine (TypeScript) | -| Database | PostgreSQL (via TimescaleDB) | -| Cache/Queue | Redis | -| Infra | Docker, Nginx, Vercel (frontend) | +| Layer | Technology | +| -------------- | ----------------------------------------- | +| Runtime | Bun + TypeScript | +| Frontend | React 19, Tailwind CSS 4, TanStack Query | +| API | Express 5, WebSocket (`ws`), Zod | +| Data | PostgreSQL/TimescaleDB, Prisma 7, Redis 7 | +| Observability | Structured service logs, Sentry, PostHog | +| Infrastructure | Docker Compose, nginx, GHCR, Cloudflare | diff --git a/assets/architecture.png b/assets/architecture.png new file mode 100644 index 0000000..54e32aa Binary files /dev/null and b/assets/architecture.png differ diff --git a/assets/paperdrill-architecture.png b/assets/paperdrill-architecture.png deleted file mode 100644 index 1034717..0000000 Binary files a/assets/paperdrill-architecture.png and /dev/null differ diff --git a/backend/bun.lock b/backend/bun.lock index 6abdb9b..ae4145c 100644 --- a/backend/bun.lock +++ b/backend/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "@sentry/bun": "^10.74.0", "bcrypt": "^6.0.0", "cookie": "^1.1.1", "cors": "^2.8.6", @@ -37,6 +38,10 @@ }, }, "packages": { + "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.18.1", "", { "dependencies": { "@types/estree": "^1.0.8", "astring": "^1.9.0", "esquery": "^1.7.0", "meriyah": "^6.1.4", "semifies": "^1.0.0", "source-map": "^0.6.0" }, "bin": { "code-transformer": "cli.js" } }, "sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ=="], + + "@apm-js-collab/code-transformer-bundler-plugins": ["@apm-js-collab/code-transformer-bundler-plugins@0.7.4", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.18.1", "es-module-lexer": "^2.1.0", "magic-string": "^0.30.21", "module-details-from-path": "^1.0.4" } }, "sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg=="], + "@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=="], @@ -45,10 +50,26 @@ "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA=="], + + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/sdk-trace": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@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=="], @@ -103,6 +124,20 @@ "@redis/time-series": ["@redis/time-series@6.0.0", "", { "peerDependencies": { "@redis/client": "^6.0.0" } }, "sha512-QWhkYsg+3lhBrBf+cbzybtV8LQcSrk7iXIgTaGU+pHNFTkql7TpVRE24ROS6M2ybVIV6O/zxTqfxgxxYiqyw0Q=="], + "@sentry/bun": ["@sentry/bun@10.74.0", "", { "dependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.74.0", "@sentry/node": "10.74.0", "@sentry/server-utils": "10.74.0" } }, "sha512-KDX+sBp0ISatJ31r+GN4Uk1FUogDM+e3xqTTD1sTATpmYH0PEmggqZC/RaG+VM2DjDxf7jxjdeNuLuC1xKhV3A=="], + + "@sentry/conventions": ["@sentry/conventions@0.16.0", "", {}, "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ=="], + + "@sentry/core": ["@sentry/core@10.74.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-u9rY8vcZfktccwm6LznfCZlqP5C9A+p76r4/pFS1grqpuTO0m21Cl8rosnlESrDGP/Xd9tfr91rWYk0jPH8jeQ=="], + + "@sentry/node": ["@sentry/node@10.74.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.74.0", "@sentry/node-core": "10.74.0", "@sentry/opentelemetry": "10.74.0", "@sentry/server-utils": "10.74.0", "import-in-the-middle": "^3.0.0" } }, "sha512-u1wsarTOWHn9CCev81Da5T4IQHZgdcosXRfX2+4DMII/lVJMBYesKixTQuMwXexjVG2+pkf16zTmgjZDa+75jA=="], + + "@sentry/node-core": ["@sentry/node-core@10.74.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.74.0", "@sentry/opentelemetry": "10.74.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-btgZXcGmkOGgojbxHo/gfGiyqYpzC9E8zVR78Q3MtM6Xnemk9gwJiQlhNmEX/FM+C36WBRPZrdcZcnMaJhbfJw=="], + + "@sentry/opentelemetry": ["@sentry/opentelemetry@10.74.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.74.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-ua5mt0NDBfye+/ACKjAw9Ad2i+y42lOLvhxXZepZXjszblMz80MEsIZflMB7uLZUCTNH7MbZN8tlzCy8KsJKmQ=="], + + "@sentry/server-utils": ["@sentry/server-utils@10.74.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.74.0" } }, "sha512-AHmPIGE8yVRyywpZRhAkm/H0cHTgsQSPhFZFbaeQLcYHFE/eOP9bAMh8Nj/Apr7WMagFXYHd04tR/63BxtLgKw=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -117,6 +152,8 @@ "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="], @@ -147,6 +184,8 @@ "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=="], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], "bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="], @@ -171,6 +210,8 @@ "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], @@ -221,10 +262,16 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -281,6 +328,8 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "import-in-the-middle": ["import-in-the-middle@3.5.1", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^3.0.2", "module-details-from-path": "^1.0.4" } }, "sha512-mPKuL8bPQzecui2KK6Gb+M8JvJoHnhS1FeYGa22QopBmlevF5F0FE6ued/B5EgHDeIoMTONIpDWWFKUPOG0DBQ=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ip-address": ["ip-address@10.4.0", "", {}, "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ=="], @@ -321,16 +370,22 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "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=="], @@ -423,6 +478,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "resend": ["resend@6.22.1", "", { "dependencies": { "postal-mime": "2.7.5", "standardwebhooks": "1.0.0" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-VqYhB6zqsifujJfZsiTKBVcZaKih0XitdXHCte4x4d7KH4D0QvPx5GvqcypCn2y64tK0C/6OskpQvsaSJaHHBw=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], @@ -435,6 +492,8 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "semifies": ["semifies@1.0.0", "", {}, "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw=="], + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -459,6 +518,8 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], @@ -503,6 +564,8 @@ "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "import-in-the-middle/es-module-lexer": ["es-module-lexer@3.0.2", "", {}, "sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg=="], + "pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], diff --git a/backend/package.json b/backend/package.json index 1cfff9f..9e42918 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "@sentry/bun": "^10.74.0", "bcrypt": "^6.0.0", "cookie": "^1.1.1", "cors": "^2.8.6", diff --git a/backend/prisma/migrations/20260917130000_add_daily_credit/migration.sql b/backend/prisma/migrations/20260917130000_add_daily_credit/migration.sql new file mode 100644 index 0000000..106da68 --- /dev/null +++ b/backend/prisma/migrations/20260917130000_add_daily_credit/migration.sql @@ -0,0 +1,25 @@ +-- CreateEnum +CREATE TYPE "DailyCreditStatus" AS ENUM ('PENDING', 'APPLIED'); + +-- CreateTable +CREATE TABLE "DailyCredit" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "creditDate" DATE NOT NULL, + "asset" TEXT NOT NULL, + "amount" BIGINT NOT NULL, + "status" "DailyCreditStatus" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "appliedAt" TIMESTAMP(3), + + CONSTRAINT "DailyCredit_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "DailyCredit_status_createdAt_idx" ON "DailyCredit"("status", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "DailyCredit_userId_creditDate_key" ON "DailyCredit"("userId", "creditDate"); + +-- AddForeignKey +ALTER TABLE "DailyCredit" ADD CONSTRAINT "DailyCredit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 89ebcaa..bf4a8c2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -36,6 +36,11 @@ enum UserType { SERVICE } +enum DailyCreditStatus { + PENDING + APPLIED +} + model User { id String @id @default(uuid()) email String @unique @@ -53,6 +58,23 @@ model User { buyFills Fill[] @relation("FillBuyer") sellFills Fill[] @relation("FillSeller") leaderboardEntry LeaderboardEntry? + dailyCredits DailyCredit[] +} + +model DailyCredit { + id String @id @default(uuid()) + userId String + creditDate DateTime @db.Date + asset String + amount BigInt + status DailyCreditStatus @default(PENDING) + createdAt DateTime @default(now()) + appliedAt DateTime? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, creditDate]) + @@index([status, createdAt]) } model LeaderboardEntry { diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index c7327d3..918d78e 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -37,6 +37,20 @@ async function seed() { qtyPrecision: 3, }, ]; + const serviceEmails = [ + "btc.service@paperdrill.dev", + "sol.service@paperdrill.dev", + "eth.service@paperdrill.dev", + ]; + const [marketCount, serviceCount] = await Promise.all([ + prisma.market.count({ where: { symbol: { in: markets.map((market) => market.symbol) } } }), + prisma.user.count({ where: { email: { in: serviceEmails }, type: UserType.SERVICE } }), + ]); + + if (marketCount === markets.length && serviceCount === serviceEmails.length) { + console.log("Seed data already exists, skipping"); + return; + } await prisma.market.createMany({ data: markets, @@ -74,7 +88,6 @@ async function seed() { }); console.log("Seeded: 3 markets, 3 services"); - process.exit(0); } seed() diff --git a/backend/src/app.ts b/backend/src/app.ts index 9213d13..55e7246 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,4 +1,4 @@ -import express, { type NextFunction, type Request, type Response } from "express"; +import express, { type Request, type Response } from "express"; import cors from "cors"; import { pingRedis } from "./redis"; import { prisma } from "./db"; @@ -7,18 +7,18 @@ import { config } from "./config"; import { logger } from "./utils/logger"; import { apiLimiter } from "./middleware/rateLimit"; import { authenticate } from "./middleware/auth"; +import { errorHandler, notFoundHandler, requestContext } from "./middleware/errors"; +import { sendApiError } from "./utils/apiError"; export const app = express(); app.set("trust proxy", 1); +app.use(requestContext); app.use(cors(config.cors)); app.use(express.json()); app.use(express.urlencoded({ extended: true })); -app.use(authenticate); -app.use(apiLimiter); - app.get("/", (_req: Request, res: Response) => { res.status(200).json({ message: "Welcome to PaperDrill", @@ -35,19 +35,13 @@ app.get("/health", async (_req: Request, res: Response) => { res.status(200).json({ success: true, status: "ok" }); } catch (err) { logger.error("Health check failed", err); - res.status(503).json({ success: false, status: "error" }); + sendApiError(res, 503, "SERVICE_UNAVAILABLE", "A required service is unavailable"); } }); -app.use("/v1", appRouter); +app.use(authenticate); +app.use(apiLimiter); -app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { - logger.error("Unhandled error", err); - const message = - config.app.env === "production" - ? "Internal server error" - : err instanceof Error - ? err.message - : "Internal server error"; - res.status(500).json({ error: message }); -}); +app.use("/v1", appRouter); +app.use(notFoundHandler); +app.use(errorHandler); diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 1e18d81..51116df 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -34,6 +34,9 @@ const envSchema = z.object({ STREAM_RETENTION_MS: z.coerce.number().default(30 * 60 * 1000), LOG_LEVEL: z.enum(["debug", "info"]), + + SENTRY_DSN: z.string().trim().optional(), + SENTRY_ENVIRONMENT: z.string().trim().optional(), }); const env = envSchema.parse(process.env); @@ -102,4 +105,10 @@ export const config = { sameSite: isProduction ? "none" : "lax", path: "/", } satisfies CookieOptions, + + sentry: { + enabled: Boolean(env.SENTRY_DSN) && env.NODE_ENV !== "test", + dsn: env.SENTRY_DSN, + environment: env.SENTRY_ENVIRONMENT ?? env.NODE_ENV, + }, }; diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts index 8125b09..26257e8 100644 --- a/backend/src/controllers/auth.ts +++ b/backend/src/controllers/auth.ts @@ -16,6 +16,7 @@ import { isEmailDeliveryEnabled, sendVerificationEmail } from "../utils/emailCli import { sendToEngine } from "../utils/engineClient"; import { assetPrecision } from "../store/market"; import { toBigInt } from "../utils/convert"; +import { ApiError, sendApiError } from "../utils/apiError"; const INITIAL_BALANCE_ASSET = "USD"; const INITIAL_BALANCE_AMOUNT = "10000"; @@ -55,7 +56,7 @@ export async function signup(req: Request, res: Response) { const bypassEmailVerification = config.app.env !== "production" && !emailDeliveryEnabled; if (config.app.env === "production" && !emailDeliveryEnabled) { - res.status(503).json({ error: "Account registration is currently unavailable" }); + sendApiError(res, 503, "SERVICE_UNAVAILABLE", "Account registration is currently unavailable"); return; } @@ -63,7 +64,7 @@ export async function signup(req: Request, res: Response) { const existingUser = await prisma.user.findUnique({ where: { email } }); if (existingUser) { - res.status(400).json({ error: "User already exists" }); + sendApiError(res, 409, "CONFLICT", "An account with this email already exists"); return; } @@ -116,7 +117,11 @@ export async function signup(req: Request, res: Response) { }); } catch (e) { logger.error("Signup failed", e); - res.status(500).json({ error: "Internal server error" }); + if (e instanceof ApiError) { + sendApiError(res, e.status, e.code, e.message, e.details); + return; + } + sendApiError(res, 500, "INTERNAL_ERROR", "Account creation failed unexpectedly"); } } @@ -136,13 +141,13 @@ export async function signin(req: Request, res: Response) { }); if (!user) { - res.status(400).json({ error: "Invalid email or password" }); + sendApiError(res, 401, "INVALID_CREDENTIALS", "Invalid email or password"); return; } let match = await bcrypt.compare(password, user.password); if (!match) { - res.status(400).json({ error: "Invalid email or password" }); + sendApiError(res, 401, "INVALID_CREDENTIALS", "Invalid email or password"); return; } @@ -157,7 +162,7 @@ export async function signin(req: Request, res: Response) { }); } catch (e) { logger.error("Signin failed", e); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "Sign in failed unexpectedly"); } } @@ -186,17 +191,17 @@ export async function verifyEmail(req: Request, res: Response) { }); if (!verification) { - res.status(404).json({ error: "Invalid verification token" }); + sendApiError(res, 404, "INVALID_VERIFICATION_TOKEN", "Verification token was not found"); return; } if (verification.expiresAt < new Date()) { - res.status(400).json({ error: "Verification token has expired" }); + sendApiError(res, 410, "VERIFICATION_TOKEN_EXPIRED", "Verification token has expired"); return; } if (verification.usedAt) { - res.status(400).json({ error: "Verification token has already been used" }); + sendApiError(res, 409, "VERIFICATION_TOKEN_USED", "Verification token has already been used"); return; } @@ -213,7 +218,7 @@ export async function verifyEmail(req: Request, res: Response) { res.status(200).json({ message: "Email verified successfully" }); } catch (e) { logger.error("Email verification failed", e); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "Email verification failed unexpectedly"); } } @@ -227,7 +232,7 @@ export async function resendVerificationEmail(req: Request, res: Response) { const { email } = parsedBody.data; if (!isEmailDeliveryEnabled()) { - res.status(503).json({ error: "Email delivery is currently unavailable" }); + sendApiError(res, 503, "SERVICE_UNAVAILABLE", "Email delivery is currently unavailable"); return; } @@ -235,12 +240,12 @@ export async function resendVerificationEmail(req: Request, res: Response) { const user = await prisma.user.findUnique({ where: { email } }); if (!user) { - res.status(404).json({ error: "User not found" }); + sendApiError(res, 404, "USER_NOT_FOUND", "User not found"); return; } if (user.emailVerified) { - res.status(400).json({ error: "Email is already verified" }); + sendApiError(res, 409, "CONFLICT", "Email is already verified"); return; } @@ -262,6 +267,6 @@ export async function resendVerificationEmail(req: Request, res: Response) { }); } catch (e) { logger.error("Resend verification email failed", e); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "Verification email could not be sent"); } } diff --git a/backend/src/controllers/key.ts b/backend/src/controllers/key.ts index 0df6178..eea7ad7 100644 --- a/backend/src/controllers/key.ts +++ b/backend/src/controllers/key.ts @@ -7,6 +7,7 @@ import { hashSecret } from "../middleware/auth"; import { API_KEY_PREFIX } from "../types/principal"; import { getUserId } from "./user"; import { logger } from "../utils/logger"; +import { sendApiError } from "../utils/apiError"; const MAX_ACTIVE_KEYS_PER_USER = 10; @@ -27,9 +28,12 @@ export async function createKey(req: Request, res: Response) { }); if (activeKeys >= MAX_ACTIVE_KEYS_PER_USER) { - res - .status(400) - .json({ error: `Maximum of ${MAX_ACTIVE_KEYS_PER_USER} active API keys reached` }); + sendApiError( + res, + 409, + "API_KEY_LIMIT_REACHED", + `Maximum of ${MAX_ACTIVE_KEYS_PER_USER} active API keys reached`, + ); return; } @@ -51,7 +55,7 @@ export async function createKey(req: Request, res: Response) { }); } catch (e) { logger.error("createKey failed", e); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "API key could not be created"); } } @@ -75,7 +79,7 @@ export async function listKeys(req: Request, res: Response) { res.status(200).json({ keys }); } catch (e) { logger.error("listKeys failed", e); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "API keys could not be loaded"); } } @@ -90,13 +94,13 @@ export async function revokeKey(req: Request, res: Response) { }); if (result.count === 0) { - res.status(404).json({ error: "API key not found or already revoked" }); + sendApiError(res, 404, "API_KEY_NOT_FOUND", "API key not found or already revoked"); return; } res.status(200).json({ success: true, message: "API key revoked" }); } catch (e) { logger.error("revokeKey failed", e); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "API key could not be revoked"); } } diff --git a/backend/src/controllers/leaderboard.ts b/backend/src/controllers/leaderboard.ts index 24f941f..527a8d3 100644 --- a/backend/src/controllers/leaderboard.ts +++ b/backend/src/controllers/leaderboard.ts @@ -9,6 +9,7 @@ import { logger } from "../utils/logger"; import { sendValidationError } from "../utils/validation"; import { config } from "../config"; import { isLeaderboardStale } from "../utils/leaderboard"; +import { sendApiError } from "../utils/apiError"; export async function getLeaderboard(req: Request, res: Response) { const parsedQuery = leaderboardQuerySchema.safeParse(req.query); @@ -42,7 +43,7 @@ export async function getLeaderboard(req: Request, res: Response) { ); if (!snapshot) { - res.status(503).json({ error: "Leaderboard is being calculated" }); + sendApiError(res, 503, "LEADERBOARD_UNAVAILABLE", "Leaderboard is being calculated"); return; } @@ -62,7 +63,7 @@ export async function getLeaderboard(req: Request, res: Response) { }); } catch (error) { logger.error("Failed to fetch leaderboard", error); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "Leaderboard could not be loaded"); } } @@ -75,10 +76,8 @@ export async function getMyLeaderboardEntry(req: Request, res: Response) { select: { emailVerified: true }, }); if (!user) { - res - .clearCookie("token", config.cookie) - .status(401) - .json({ error: "Authentication required" }); + res.clearCookie("token", config.cookie); + sendApiError(res, 401, "AUTHENTICATION_REQUIRED", "Authentication required"); return; } if (!user.emailVerified) { @@ -120,6 +119,6 @@ export async function getMyLeaderboardEntry(req: Request, res: Response) { }); } catch (error) { logger.error("Failed to fetch personal leaderboard entry", error); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "Leaderboard entry could not be loaded"); } } diff --git a/backend/src/controllers/market.ts b/backend/src/controllers/market.ts index d5bf418..701b495 100644 --- a/backend/src/controllers/market.ts +++ b/backend/src/controllers/market.ts @@ -13,9 +13,13 @@ import { } from "../utils/formatter"; import { logger } from "../utils/logger"; import { marketStore } from "../store/market"; +import { sendApiError, sendEngineError } from "../utils/apiError"; const intervalMap = { + "1M": "1 minute", + "5M": "5 minutes", "15M": "15 minutes", + "30M": "30 minutes", "1H": "1 hour", "4H": "4 hours", "1D": "1 day", @@ -28,7 +32,7 @@ export async function getMarkets(_req: Request, res: Response) { res.status(200).json({ data: markets }); } catch (e) { logger.error("Failed to fetch markets", e); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "Markets could not be loaded"); } } @@ -48,11 +52,15 @@ export async function getTrades(req: Request, res: Response) { const { symbol } = parsedParams.data; const { limit = 50 } = parsedQueries.data; + if (!marketStore.has(symbol)) { + sendApiError(res, 404, "MARKET_NOT_FOUND", `Market ${symbol} was not found`); + return; + } const engineResponse = await sendToEngine("get_trades", { symbol, limit }); if (!engineResponse.success) { - res.status(400).json({ error: engineResponse.error }); + sendEngineError(res, engineResponse.error); return; } @@ -68,11 +76,15 @@ export async function getDepth(req: Request, res: Response) { } const { symbol } = parsedParams.data; + if (!marketStore.has(symbol)) { + sendApiError(res, 404, "MARKET_NOT_FOUND", `Market ${symbol} was not found`); + return; + } const engineResponse = await sendToEngine("get_depth", { symbol }); if (!engineResponse.success) { - res.status(400).json({ error: engineResponse.error }); + sendEngineError(res, engineResponse.error); return; } @@ -97,6 +109,10 @@ export async function getCandles(req: Request, res: Response) { const { symbol } = parsedParams.data; const { interval = "15M" } = parsedQueries.data; const bucket = intervalMap[interval]; + if (!marketStore.has(symbol)) { + sendApiError(res, 404, "MARKET_NOT_FOUND", `Market ${symbol} was not found`); + return; + } try { const candles = await prisma.$queryRaw` @@ -124,7 +140,7 @@ export async function getCandles(req: Request, res: Response) { }); } catch (err) { logger.error("Failed to fetch candles", err); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "Candles could not be loaded"); } } @@ -142,7 +158,7 @@ export async function getTicker(req: Request, res: Response) { const market = marketStore.get(symbol); if (!market) { - res.status(404).json({ error: "Market not found" }); + sendApiError(res, 404, "MARKET_NOT_FOUND", `Market ${symbol} was not found`); return; } @@ -156,7 +172,7 @@ export async function getTicker(req: Request, res: Response) { res.status(200).json(formatTicker(JSON.parse(raw) as Record)); } catch (err) { logger.error("Failed to fetch ticker", err); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "Ticker could not be loaded"); } } @@ -178,6 +194,6 @@ export async function getTickers(_req: Request, res: Response) { res.status(200).json(formatTickers(tickers)); } catch (err) { logger.error("Failed to fetch tickers", err); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "Tickers could not be loaded"); } } diff --git a/backend/src/controllers/order.ts b/backend/src/controllers/order.ts index f31f57d..a5dd43a 100644 --- a/backend/src/controllers/order.ts +++ b/backend/src/controllers/order.ts @@ -8,6 +8,7 @@ import { formatOrder, formatOrders, formatCancel } from "../utils/formatter"; import { getUserId } from "./user"; import { prisma } from "../db"; import { logger } from "../utils/logger"; +import { sendApiError, sendEngineError } from "../utils/apiError"; export async function createOrder(req: Request, res: Response) { const userId = getUserId(req); @@ -23,13 +24,26 @@ export async function createOrder(req: Request, res: Response) { const market = marketStore.get(symbol); if (!market) { - res.status(400).json({ error: `Unknown market: ${symbol}` }); + sendApiError(res, 404, "MARKET_NOT_FOUND", `Market ${symbol} was not found`); return; } - const scaledQty = toBigInt(qty, market.qtyPrecision); - const scaledPrice = - type === "MARKET" ? null : toBigInt(parsedBody.data.price, market.pricePrecision); + let scaledQty: bigint; + let scaledPrice: bigint | null; + try { + scaledQty = toBigInt(qty, market.qtyPrecision); + scaledPrice = + type === "MARKET" ? null : toBigInt(parsedBody.data.price, market.pricePrecision); + } catch (error) { + sendApiError( + res, + 400, + "VALIDATION_ERROR", + "Order precision is invalid for this market", + [{ message: error instanceof Error ? error.message : "Invalid order precision" }], + ); + return; + } const id = crypto.randomUUID(); const engineResponse = await sendToEngine("create_order", { @@ -43,11 +57,11 @@ export async function createOrder(req: Request, res: Response) { }); if (!engineResponse.success) { - res.status(400).json({ error: engineResponse.error }); + sendEngineError(res, engineResponse.error); return; } - res.status(200).json(formatOrder(engineResponse.data as Record)); + res.status(201).json(formatOrder(engineResponse.data as Record)); } export async function getOpenOrders(req: Request, res: Response) { @@ -56,7 +70,7 @@ export async function getOpenOrders(req: Request, res: Response) { const engineResponse = await sendToEngine("get_open_orders", { userId }); if (!engineResponse.success) { - res.status(400).json({ error: engineResponse.error }); + sendEngineError(res, engineResponse.error); return; } @@ -86,15 +100,10 @@ export async function getOrders(req: Request, res: Response) { orderBy: { createdAt: "desc" }, }); - if (!orders) { - res.status(404).json({ error: "Order not found" }); - return; - } - res.status(200).json(formatOrders(orders)); } catch (err) { logger.error("Failed to fetch order", err); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "Order history could not be loaded"); } } @@ -118,14 +127,14 @@ export async function getOrderById(req: Request, res: Response) { }); if (!order) { - res.status(404).json({ error: "Order not found" }); + sendApiError(res, 404, "ORDER_NOT_FOUND", "Order not found"); return; } res.status(200).json(formatOrder(order as Record)); } catch (err) { logger.error("Failed to fetch order", err); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "Order could not be loaded"); } } @@ -142,9 +151,15 @@ export async function cancelOrder(req: Request, res: Response) { const engineResponse = await sendToEngine("cancel_order", { userId, id: orderId }); if (!engineResponse.success) { - res.status(400).json({ error: engineResponse.error }); + sendEngineError(res, engineResponse.error); + return; + } + + const result = engineResponse.data as Record; + if (typeof result.message === "string") { + res.status(200).json({ message: result.message }); return; } - res.status(200).json(formatCancel(engineResponse.data as Record)); + res.status(200).json(formatCancel(result)); } diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index 947f366..e21e4d6 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -9,12 +9,14 @@ import { marketStore } from "../store/market"; import { calculatePortfolio, type Balance } from "../utils/portfolio"; import { getReferencePrices } from "../utils/referencePrice"; import { config } from "../config"; +import { ApiError, sendApiError, sendEngineError } from "../utils/apiError"; +import { claimDailyCredit } from "../services/dailyCredit"; export function getUserId(req: Request): string { const userId = req.principal?.userId; if (!userId) { - throw new Error("Missing authenticated user"); + throw new ApiError(401, "AUTHENTICATION_REQUIRED", "Authentication required"); } return userId; @@ -31,10 +33,8 @@ export async function getUserData(req: Request, res: Response) { }); if (!user) { - res - .clearCookie("token", config.cookie) - .status(401) - .json({ error: "Authentication required" }); + res.clearCookie("token", config.cookie); + sendApiError(res, 401, "AUTHENTICATION_REQUIRED", "Authentication required"); return; } @@ -46,7 +46,7 @@ export async function getUserData(req: Request, res: Response) { }); } catch (e) { logger.error("getUserData failed", e); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "User profile could not be loaded"); } } @@ -75,7 +75,7 @@ export async function getTradeHistory(req: Request, res: Response) { ); } catch (err) { logger.error("Failed to fetch trade history", err); - res.status(500).json({ error: "Internal server error" }); + sendApiError(res, 500, "INTERNAL_ERROR", "Trade history could not be loaded"); } } @@ -94,7 +94,7 @@ export async function getBalance(req: Request, res: Response) { const engineResponse = await sendToEngine("get_user_balance", { userId, asset }); if (!engineResponse.success) { - res.status(400).json({ error: engineResponse.error }); + sendEngineError(res, engineResponse.error); return; } @@ -108,7 +108,7 @@ export async function getPortfolio(req: Request, res: Response) { const engineResponse = await sendToEngine("get_user_balance", { userId }); if (!engineResponse.success) { - res.status(400).json({ error: engineResponse.error }); + sendEngineError(res, engineResponse.error); return; } @@ -131,20 +131,25 @@ export async function getPortfolio(req: Request, res: Response) { }); if (!user) { - res.status(404).json({ error: "User not found" }); + sendApiError(res, 401, "AUTHENTICATION_REQUIRED", "Authentication required"); return; } if (user.pnlBaseline == null || user.pnlBaselineAt == null) { logger.warn("Portfolio requested for user without a baseline", { userId }); - res.status(503).json({ error: "Portfolio baseline is not configured" }); + sendApiError(res, 503, "PORTFOLIO_UNAVAILABLE", "Portfolio baseline is not configured"); return; } res.status(200).json(formatPortfolio(portfolio, user.pnlBaseline, user.pnlBaselineAt)); } catch (error) { logger.error("Failed to calculate portfolio", error); - res.status(503).json({ error: "Portfolio valuation is temporarily unavailable" }); + sendApiError( + res, + 503, + "PORTFOLIO_UNAVAILABLE", + "Portfolio valuation is temporarily unavailable", + ); } } @@ -162,7 +167,7 @@ export async function createDeposit(req: Request, res: Response) { const engineResponse = await sendToEngine("create_deposit", { userId, amount, asset }); if (!engineResponse.success) { - res.status(400).json({ error: engineResponse.error }); + sendEngineError(res, engineResponse.error); return; } @@ -170,3 +175,8 @@ export async function createDeposit(req: Request, res: Response) { .status(200) .json(formatBalance(engineResponse.data as Record>)); } + +export async function claimDailyReward(req: Request, res: Response) { + const reward = await claimDailyCredit(getUserId(req)); + res.status(200).json(reward); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index d8a896c..d07b2e1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,3 +1,4 @@ +import "./instrument"; import "dotenv"; import { engineAbortController, @@ -11,6 +12,7 @@ import { logger } from "./utils/logger"; import { prisma } from "./db"; import { loadMarkets } from "./store/market"; import { startLeaderboardRefresh, stopLeaderboardRefresh } from "./services/leaderboard"; +import { flushSentry } from "./instrument"; const { httpServer, wss } = createAppServer(); @@ -67,7 +69,10 @@ async function gracefulShutdown(signal: string) { logger.error("Error disconnecting Prisma", err); } + await flushSentry(); + clearTimeout(forceExit); + logger.info("Backend shutdown complete"); process.exit(0); } @@ -83,7 +88,8 @@ process.on("uncaughtException", (err) => { gracefulShutdown("uncaughtException").then(() => process.exit(1)); }); -main().catch((err) => { +main().catch(async (err) => { logger.error("Fatal startup error", err); + await flushSentry(); process.exit(1); }); diff --git a/backend/src/instrument.ts b/backend/src/instrument.ts new file mode 100644 index 0000000..171a46a --- /dev/null +++ b/backend/src/instrument.ts @@ -0,0 +1,30 @@ +import "dotenv/config"; +import * as Sentry from "@sentry/bun"; +import { config } from "./config"; + +Sentry.init({ + dsn: config.sentry.dsn, + enabled: config.sentry.enabled, + environment: config.sentry.environment, + sendDefaultPii: false, + maxBreadcrumbs: 50, + initialScope: { tags: { service: "backend" } }, +}); + +export function captureBackendException( + error: unknown, + context?: { tags?: Record; extra?: Record }, +) { + if (!config.sentry.enabled) return; + + Sentry.withScope((scope) => { + if (context?.tags) scope.setTags(context.tags); + if (context?.extra) scope.setExtras(context.extra); + Sentry.captureException(error); + }); +} + +export async function flushSentry(timeoutMs = 2_000) { + if (!config.sentry.enabled) return true; + return Sentry.flush(timeoutMs); +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index c4c2791..5eb54a5 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -7,6 +7,7 @@ import { prisma } from "../db"; import { logger } from "../utils/logger"; import { API_KEY_PREFIX, type PrincipalType } from "../types/principal"; import type { Scope } from "../../generated/prisma/enums"; +import { sendApiError } from "../utils/apiError"; interface TokenPayload { id: string; @@ -54,28 +55,28 @@ export async function authenticate(req: Request, res: Response, next: NextFuncti }); if (!service) { - res.status(401).json({ error: "Invalid service email" }); + sendApiError(res, 401, "INVALID_API_KEY", "Invalid service credentials"); return; } req.principal = { type: "service", userId: service.id, - scopes: ["ORDER_READ", "ORDER_CREATE", "ORDER_CANCEL"], + scopes: ["ACCOUNT_READ", "ORDER_READ", "ORDER_CREATE", "ORDER_CANCEL"], }; next(); return; } if (!credential.startsWith(API_KEY_PREFIX)) { - res.status(401).json({ error: "Malformed API key" }); + sendApiError(res, 401, "INVALID_API_KEY", "Malformed API key"); return; } const token = credential.slice(API_KEY_PREFIX.length); const separator = token.indexOf("."); if (separator <= 0) { - res.status(401).json({ error: "Malformed API key" }); + sendApiError(res, 401, "INVALID_API_KEY", "Malformed API key"); return; } @@ -84,7 +85,7 @@ export async function authenticate(req: Request, res: Response, next: NextFuncti const key = await prisma.apiKey.findUnique({ where: { id: keyId } }); if (!key || key.revokedAt || !secretsMatch(secret, key.hashedSecret)) { - res.status(401).json({ error: "Invalid API key" }); + sendApiError(res, 401, "INVALID_API_KEY", "Invalid API key"); return; } @@ -133,12 +134,12 @@ export function requireAccess({ types, scopes, allowUnverified = false }: Access const principal = req.principal; if (!principal) { - res.status(401).json({ error: "Authentication required" }); + sendApiError(res, 401, "AUTHENTICATION_REQUIRED", "Authentication required"); return; } if (types?.length && !types.includes(principal.type)) { - res.status(403).json({ error: "You do not have permission to perform this action" }); + sendApiError(res, 403, "FORBIDDEN", "You do not have permission to perform this action"); return; } @@ -153,7 +154,7 @@ export function requireAccess({ types, scopes, allowUnverified = false }: Access if (!user) { if (principal.type === "session") res.clearCookie("token", config.cookie); - res.status(401).json({ error: "Authentication required" }); + sendApiError(res, 401, "AUTHENTICATION_REQUIRED", "Authentication required"); return; } @@ -162,10 +163,7 @@ export function requireAccess({ types, scopes, allowUnverified = false }: Access } if (!emailVerified) { - res.status(403).json({ - error: "Verify your email to continue", - code: "EMAIL_NOT_VERIFIED", - }); + sendApiError(res, 403, "EMAIL_NOT_VERIFIED", "Verify your email to continue"); return; } } @@ -174,7 +172,7 @@ export function requireAccess({ types, scopes, allowUnverified = false }: Access const authorized = scopes.every((scope) => principal.scopes?.includes(scope)); if (!authorized) { - res.status(403).json({ error: "You do not have permission to perform this action" }); + sendApiError(res, 403, "FORBIDDEN", "You do not have permission to perform this action"); return; } } diff --git a/backend/src/middleware/errors.ts b/backend/src/middleware/errors.ts new file mode 100644 index 0000000..8271636 --- /dev/null +++ b/backend/src/middleware/errors.ts @@ -0,0 +1,62 @@ +import type { ErrorRequestHandler, NextFunction, Request, Response } from "express"; +import { config } from "../config"; +import { ApiError, sendApiError } from "../utils/apiError"; +import { logger } from "../utils/logger"; +import { captureBackendException } from "../instrument"; + +export function requestContext(req: Request, res: Response, next: NextFunction) { + req.requestId = crypto.randomUUID(); + res.setHeader("X-Request-Id", req.requestId); + next(); +} + +export function notFoundHandler(req: Request, res: Response) { + sendApiError(res, 404, "RESOURCE_NOT_FOUND", `Route ${req.method} ${req.path} was not found`); +} + +function isBodyParserError(error: unknown): error is Error & { status: number; type?: string } { + return error instanceof Error && "status" in error && typeof error.status === "number"; +} + +export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => { + if (res.headersSent) { + _next(error); + return; + } + + if (error instanceof ApiError) { + sendApiError(res, error.status, error.code, error.message, error.details); + return; + } + + if (isBodyParserError(error) && error.type === "entity.parse.failed") { + sendApiError(res, 400, "MALFORMED_JSON", "Request body contains invalid JSON"); + return; + } + + if (isBodyParserError(error) && error.status === 413) { + sendApiError(res, 413, "PAYLOAD_TOO_LARGE", "Request body is too large"); + return; + } + + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Engine response timed out") { + sendApiError(res, 504, "ENGINE_TIMEOUT", "The matching engine did not respond in time"); + return; + } + + logger.error("Unhandled request error", { + requestId: req.requestId, + method: req.method, + path: req.originalUrl, + error: message, + }); + captureBackendException(error, { + tags: { source: "express", request_id: req.requestId }, + extra: { method: req.method, path: req.path }, + }); + + const publicMessage = + config.app.env === "production" ? "An unexpected error occurred" : message; + sendApiError(res, 500, "INTERNAL_ERROR", publicMessage); +}; diff --git a/backend/src/middleware/rateLimit.ts b/backend/src/middleware/rateLimit.ts index e03d949..b46e128 100644 --- a/backend/src/middleware/rateLimit.ts +++ b/backend/src/middleware/rateLimit.ts @@ -4,6 +4,7 @@ import { RedisStore } from "rate-limit-redis"; import { config } from "../config"; import { rateLimiterClient } from "../redis"; import { logger } from "../utils/logger"; +import { sendApiError } from "../utils/apiError"; async function sendCommand(...args: string[]): Promise { if (!rateLimiterClient.isOpen) { @@ -26,13 +27,15 @@ function createLimiter(prefix: string, windowMs: number, limit: number) { windowMs, limit, keyGenerator: bucketKey, - store: new RedisStore({ prefix, sendCommand }), + ...(config.rateLimit.enabled && { + store: new RedisStore({ prefix, sendCommand }), + }), standardHeaders: true, legacyHeaders: false, skip: (req) => !config.rateLimit.enabled || req.principal?.type === "service", handler: (req, res) => { logger.warn("Rate limit exceeded", { key: bucketKey(req), path: req.originalUrl }); - res.status(429).json({ error: "Too many requests, please try again later" }); + sendApiError(res, 429, "RATE_LIMITED", "Too many requests; try again later"); }, }); } diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index e59081a..6feab5e 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -1,7 +1,13 @@ import { Router } from "express"; import { requireAccess } from "../middleware/auth"; import { asyncHandler } from "../utils/asyncHandler"; -import { createDeposit, getBalance, getPortfolio, getUserData } from "../controllers/user"; +import { + claimDailyReward, + createDeposit, + getBalance, + getPortfolio, + getUserData, +} from "../controllers/user"; import { getTradeHistory } from "../controllers/user"; export const userRouter = Router(); @@ -27,3 +33,8 @@ userRouter.get( asyncHandler(getPortfolio), ); userRouter.post("/deposits", requireAccess({ types: ["service"] }), asyncHandler(createDeposit)); +userRouter.post( + "/users/me/daily-credit", + requireAccess({ types: ["session"] }), + asyncHandler(claimDailyReward), +); diff --git a/backend/src/schema/exchange.ts b/backend/src/schema/exchange.ts index d44e8de..051ad21 100644 --- a/backend/src/schema/exchange.ts +++ b/backend/src/schema/exchange.ts @@ -34,7 +34,7 @@ export const orderBodySchema = z.discriminatedUnion("type", [ ]); export const candleQuerySchema = z.object({ - interval: z.enum(["15M", "1H", "4H", "1D"]).optional(), + interval: z.enum(["1M", "5M", "15M", "30M", "1H", "4H", "1D"]).optional(), }); export const orderQuerySchema = z.object({ diff --git a/backend/src/services/dailyCredit.ts b/backend/src/services/dailyCredit.ts new file mode 100644 index 0000000..2631845 --- /dev/null +++ b/backend/src/services/dailyCredit.ts @@ -0,0 +1,106 @@ +import { Prisma } from "../../generated/prisma/client"; +import { DailyCreditStatus, UserType } from "../../generated/prisma/enums"; +import { prisma } from "../db"; +import { assetPrecision } from "../store/market"; +import { ApiError } from "../utils/apiError"; +import { fromBigInt, toBigInt } from "../utils/convert"; +import { sendToEngine } from "../utils/engineClient"; + +export const DAILY_CREDIT_ASSET = "USD"; +export const DAILY_CREDIT_AMOUNT = "100"; + +export function utcCreditDate(now = new Date()) { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); +} + +export function nextUtcCreditDate(creditDate: Date) { + return new Date(creditDate.getTime() + 24 * 60 * 60 * 1000); +} + +async function findOrCreateCredit(userId: string, creditDate: Date, amount: bigint) { + try { + return await prisma.dailyCredit.create({ + data: { userId, creditDate, asset: DAILY_CREDIT_ASSET, amount }, + }); + } catch (error) { + if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== "P2002") { + throw error; + } + + return prisma.dailyCredit.findUniqueOrThrow({ + where: { userId_creditDate: { userId, creditDate } }, + }); + } +} + +export async function claimDailyCredit(userId: string, now = new Date()) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { type: true, emailVerified: true, pnlBaseline: true }, + }); + + if (!user) throw new ApiError(401, "AUTHENTICATION_REQUIRED", "Authentication required"); + if (user.type !== UserType.USER) { + throw new ApiError(403, "DAILY_CREDIT_NOT_ELIGIBLE", "Service accounts are not eligible"); + } + if (!user.emailVerified) { + throw new ApiError(403, "EMAIL_NOT_VERIFIED", "Verify your email to claim daily credit"); + } + if (user.pnlBaseline == null) { + throw new ApiError(503, "DAILY_CREDIT_UNAVAILABLE", "PnL baseline is not configured"); + } + + const precision = assetPrecision.get(DAILY_CREDIT_ASSET); + if (precision == null) { + throw new ApiError(503, "DAILY_CREDIT_UNAVAILABLE", "Daily credit is temporarily unavailable"); + } + + const creditDate = utcCreditDate(now); + const configuredAmount = toBigInt(DAILY_CREDIT_AMOUNT, precision); + const credit = await findOrCreateCredit(userId, creditDate, configuredAmount); + + if (credit.status === DailyCreditStatus.APPLIED) { + return { + credited: false, + asset: credit.asset, + amount: fromBigInt(credit.amount, precision), + creditDate: creditDate.toISOString().slice(0, 10), + nextEligibleAt: nextUtcCreditDate(creditDate).toISOString(), + }; + } + + const engineResponse = await sendToEngine("apply_credit", { + creditId: credit.id, + userId, + asset: credit.asset, + amount: credit.amount.toString(), + }); + + if (!engineResponse.success) { + throw new ApiError(503, "DAILY_CREDIT_UNAVAILABLE", "Daily credit is temporarily unavailable"); + } + + const credited = await prisma.$transaction(async (transaction) => { + const applied = await transaction.dailyCredit.updateMany({ + where: { id: credit.id, status: DailyCreditStatus.PENDING }, + data: { status: DailyCreditStatus.APPLIED, appliedAt: new Date() }, + }); + + if (applied.count === 0) return false; + + await transaction.user.update({ + where: { id: userId }, + data: { pnlBaseline: { increment: credit.amount } }, + }); + + return true; + }); + + return { + credited, + asset: credit.asset, + amount: fromBigInt(credit.amount, precision), + creditDate: creditDate.toISOString().slice(0, 10), + nextEligibleAt: nextUtcCreditDate(creditDate).toISOString(), + }; +} diff --git a/backend/src/store/pendingResponses.ts b/backend/src/store/pendingResponses.ts index 07598f1..5f5dbf2 100644 --- a/backend/src/store/pendingResponses.ts +++ b/backend/src/store/pendingResponses.ts @@ -34,3 +34,12 @@ export function resolveEngineResponse(response: EngineResponse): void { pendingResponses.delete(response.correlationId); pending.resolve(response); } + +export function rejectEngineResponse(correlationId: string, error: Error): void { + const pending = pendingResponses.get(correlationId); + if (!pending) return; + + clearTimeout(pending.timeout); + pendingResponses.delete(correlationId); + pending.reject(error); +} diff --git a/backend/src/types/engine.ts b/backend/src/types/engine.ts index b7e46c8..1788dab 100644 --- a/backend/src/types/engine.ts +++ b/backend/src/types/engine.ts @@ -6,6 +6,7 @@ export type EngineCommandType = | "get_open_orders" | "get_trades" | "cancel_order" + | "apply_credit" | "create_deposit" | "initialize_balance"; diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts index 8c4196c..2987099 100644 --- a/backend/src/types/express.d.ts +++ b/backend/src/types/express.d.ts @@ -4,6 +4,7 @@ declare global { namespace Express { interface Request { principal?: Principal; + requestId: string; } } } diff --git a/backend/src/utils/apiError.ts b/backend/src/utils/apiError.ts new file mode 100644 index 0000000..08d27e3 --- /dev/null +++ b/backend/src/utils/apiError.ts @@ -0,0 +1,64 @@ +import type { Response } from "express"; + +export interface ApiErrorDetail { + field?: string; + message: string; +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string, + public readonly details?: ApiErrorDetail[], + ) { + super(message); + this.name = "ApiError"; + } +} + +export function sendApiError( + res: Response, + status: number, + code: string, + message: string, + details?: ApiErrorDetail[], +) { + const requestId = res.req?.requestId ?? crypto.randomUUID(); + return res.status(status).json({ + error: { + code, + message, + ...(details?.length ? { details } : {}), + }, + requestId, + }); +} + +export function engineApiError(message = "The matching engine rejected the request") { + if (message.startsWith("Unknown market:")) { + return new ApiError(404, "MARKET_NOT_FOUND", message); + } + if (message.startsWith("Unknown asset:")) { + return new ApiError(404, "ASSET_NOT_FOUND", message); + } + if (message === "Order not found") { + return new ApiError(404, "ORDER_NOT_FOUND", message); + } + if (message === "Filled orders cannot be cancelled") { + return new ApiError(409, "ORDER_NOT_CANCELLABLE", message); + } + if (message === "Insufficient balance" || message.startsWith("Missing balance for asset:")) { + return new ApiError(422, "INSUFFICIENT_BALANCE", message); + } + if (message === "No liquidity") { + return new ApiError(422, "NO_LIQUIDITY", message); + } + + return new ApiError(422, "ORDER_REJECTED", message); +} + +export function sendEngineError(res: Response, message?: string) { + const error = engineApiError(message); + return sendApiError(res, error.status, error.code, error.message, error.details); +} diff --git a/backend/src/utils/engineClient.ts b/backend/src/utils/engineClient.ts index 3a94a10..5bf0836 100644 --- a/backend/src/utils/engineClient.ts +++ b/backend/src/utils/engineClient.ts @@ -1,10 +1,15 @@ import type { EngineCommandType, EngineResponse } from "../types/engine"; -import { resolveEngineResponse, waitForEngineResponse } from "../store/pendingResponses"; +import { + rejectEngineResponse, + resolveEngineResponse, + waitForEngineResponse, +} from "../store/pendingResponses"; import { activeSubscriptions } from "./websocket"; import { config } from "../config"; import { logger } from "./logger"; import { marketSubscriber, publisher, responsesubscriber } from "../redis"; import { formatCandle, formatDepth, formatTrade, formatTicker } from "./formatter"; +import { ApiError } from "./apiError"; export const engineAbortController = new AbortController(); @@ -15,25 +20,36 @@ export async function sendToEngine( const correlationId = crypto.randomUUID(); const responsePromise = waitForEngineResponse(correlationId, config.engine.timeout); - await publisher.xAdd( - config.engine.incomingStream, - "*", - { - correlationId, - responseQueue: config.engine.responseQueue, - type, - payload: JSON.stringify(payload), - }, - { - TRIM: { - strategy: "MINID", - strategyModifier: "=", - threshold: Date.now() - config.redis.retentionMs, + try { + await publisher.xAdd( + config.engine.incomingStream, + "*", + { + correlationId, + responseQueue: config.engine.responseQueue, + type, + payload: JSON.stringify(payload), }, - }, - ); + { + TRIM: { + strategy: "MINID", + strategyModifier: "=", + threshold: Date.now() - config.redis.retentionMs, + }, + }, + ); - return responsePromise; + return await responsePromise; + } catch (error) { + const cause = error instanceof Error ? error : new Error(String(error)); + if (cause.message === "Engine response timed out") { + throw new ApiError(504, "ENGINE_TIMEOUT", "The matching engine did not respond in time"); + } + + rejectEngineResponse(correlationId, cause); + void responsePromise.catch(() => undefined); + throw new ApiError(503, "ENGINE_UNAVAILABLE", "The matching engine is unavailable"); + } } export async function listenForEngineresponses(): Promise { diff --git a/backend/src/utils/formatter.ts b/backend/src/utils/formatter.ts index e0709e0..50792d2 100644 --- a/backend/src/utils/formatter.ts +++ b/backend/src/utils/formatter.ts @@ -120,7 +120,9 @@ export function formatTicker(ticker: Record) { quoteVolume: fmt(ticker.quoteVolume, m.pricePrecision + m.qtyPrecision), priceChange, priceChangePercent, - timestamp: new Date(Number(ticker.timestamp)).toISOString(), + timestamp: new Date( + ticker.timestamp == null ? Date.now() : Number(ticker.timestamp), + ).toISOString(), }; } @@ -221,6 +223,7 @@ export function formatCancel(result: Record) { return { id: result.id as string, symbol: result.symbol, + status: result.status, side: result.side, qty: fmt(result.qty, m.qtyPrecision), filledQty: fmt(result.filledQty, m.qtyPrecision), diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts index a55e179..fe9339a 100644 --- a/backend/src/utils/logger.ts +++ b/backend/src/utils/logger.ts @@ -1,18 +1,34 @@ import { config } from "../config"; +import { captureBackendException } from "../instrument"; type LogLevel = "debug" | "info" | "warn" | "error"; const LEVEL = { debug: 0, info: 1, warn: 2, error: 3 }; +function serialize(meta: unknown) { + return JSON.stringify(meta, (_key, value) => + value instanceof Error + ? { name: value.name, message: value.message, stack: value.stack } + : value, + ); +} + function log(level: LogLevel, message: string, meta?: unknown) { if (LEVEL[level] < LEVEL[config.app.logLevel]) return; const ts = new Date().toISOString(); const prefix = `[${ts}] [${level.toUpperCase()}]`; const line = - meta !== undefined ? `${prefix} ${message} ${JSON.stringify(meta)}` : `${prefix} ${message}`; + meta !== undefined ? `${prefix} ${message} ${serialize(meta)}` : `${prefix} ${message}`; if (level === "error") console.error(line); else console.log(line); + + if (level === "error" && meta instanceof Error) { + captureBackendException(meta, { + tags: { source: "logger" }, + extra: { logMessage: message }, + }); + } } export const logger = { diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 72407c6..28f19d1 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -1,12 +1,16 @@ import type { Response } from "express"; import type { ZodError } from "zod"; +import { sendApiError } from "./apiError"; -export async function sendValidationError(res: Response, error: ZodError) { - res.status(400).json({ - error: "validation_error", - issues: error.issues.map((issue) => ({ - path: issue.path.join("."), +export function sendValidationError(res: Response, error: ZodError) { + return sendApiError( + res, + 400, + "VALIDATION_ERROR", + "Request validation failed", + error.issues.map((issue) => ({ + field: issue.path.join(".") || undefined, message: issue.message, })), - }); + ); } diff --git a/backend/tests/access.test.ts b/backend/tests/access.test.ts index 0299700..a31dd46 100644 --- a/backend/tests/access.test.ts +++ b/backend/tests/access.test.ts @@ -45,8 +45,8 @@ test("requireAccess rejects users with unverified email", () => { expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith({ - error: "Verify your email to continue", - code: "EMAIL_NOT_VERIFIED", + error: { code: "EMAIL_NOT_VERIFIED", message: "Verify your email to continue" }, + requestId: expect.any(String), }); expect(next).not.toHaveBeenCalled(); }); @@ -89,7 +89,11 @@ test("requireAccess rejects api keys missing a required scope", () => { expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith({ - error: "You do not have permission to perform this action", + error: { + code: "FORBIDDEN", + message: "You do not have permission to perform this action", + }, + requestId: expect.any(String), }); expect(next).not.toHaveBeenCalled(); }); @@ -100,3 +104,13 @@ test("requireAccess rejects service principals on scoped user routes", () => { expect(res.status).toHaveBeenCalledWith(403); expect(next).not.toHaveBeenCalled(); }); + +test("requireAccess allows service principals with the required scope", () => { + const { res, next } = run( + { type: "service", userId: "bot-1", scopes: ["ACCOUNT_READ"] }, + { scopes: ["ACCOUNT_READ"] }, + ); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); +}); diff --git a/backend/tests/auth.test.ts b/backend/tests/auth.test.ts index 2a2c775..ded00d1 100644 --- a/backend/tests/auth.test.ts +++ b/backend/tests/auth.test.ts @@ -83,6 +83,9 @@ test("authenticate returns 401 for malformed api key", async () => { await authenticate(req, res, next); expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: "Malformed API key" }); + expect(res.json).toHaveBeenCalledWith({ + error: { code: "INVALID_API_KEY", message: "Malformed API key" }, + requestId: expect.any(String), + }); expect(next).not.toHaveBeenCalled(); }); diff --git a/benchmarks/api.bench.ts b/benchmarks/api.bench.ts deleted file mode 100644 index 70330ea..0000000 --- a/benchmarks/api.bench.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { performance } from "perf_hooks"; - -const API_URL = process.env.API_URL ?? "http://localhost:8000"; -const CONCURRENCY = parseInt(process.env.CONCURRENCY ?? "50", 10); -const DURATION_SEC = parseInt(process.env.DURATION_SEC ?? "10", 10); -const SERVICE_TOKEN = process.env.SERVICE_TOKEN; -const SERVICE_EMAIL = process.env.SERVICE_EMAIL; - -// --- Helpers --- - -function stats(times: number[]) { - if (times.length === 0) return { avg: 0, p50: 0, p95: 0, p99: 0, total: 0, count: 0 }; - times.sort((a, b) => a - b); - const sum = times.reduce((a, b) => a + b, 0); - const avg = sum / times.length; - const p50 = times[Math.floor(times.length * 0.5)]; - const p95 = times[Math.floor(times.length * 0.95)]; - const p99 = times[Math.floor(times.length * 0.99)]; - return { avg, p50, p95, p99, total: sum, count: times.length }; -} - -function report(name: string, times: number[], errors: number) { - const s = stats(times); - const opsPerSec = s.total === 0 ? 0 : (s.count / s.total) * 1000; - const errRate = ((errors / (s.count + errors)) * 100).toFixed(1); - console.log(` ${name}`); - console.log( - ` ${s.count} ok, ${errors} err (${errRate}% err) | ${(s.total / 1000).toFixed(1)}s`, - ); - console.log( - ` ${opsPerSec.toFixed(0)} req/sec | avg ${s.avg.toFixed(1)}ms | p50 ${s.p50?.toFixed(1)}ms | p95 ${s.p95?.toFixed(1)}ms | p99 ${s.p99?.toFixed(1)}ms`, - ); -} - -type RequestFn = () => Promise; - -async function runLoadTest(name: string, fn: RequestFn, durationMs: number) { - const times: number[] = []; - let errors = 0; - let running = true; - - const worker = async () => { - while (running) { - const start = performance.now(); - const ok = await fn(); - const elapsed = performance.now() - start; - - if (ok) { - times.push(elapsed); - } else { - errors++; - } - } - }; - - const workers = Array.from({ length: CONCURRENCY }, () => worker()); - - const deadline = setTimeout(() => { - running = false; - }, durationMs); - - await Promise.all(workers); - clearTimeout(deadline); - - report(name, times, errors); -} - -// --- Load test scenarios --- - -async function benchMarkets() { - await runLoadTest( - "GET /v1/markets (public)", - async () => { - try { - const res = await fetch(`${API_URL}/v1/markets`); - return res.ok; - } catch { - return false; - } - }, - DURATION_SEC * 1000, - ); -} - -async function benchOrderbook() { - await runLoadTest( - "GET /v1/markets/SOL_USD/orderbook (public)", - async () => { - try { - const res = await fetch(`${API_URL}/v1/markets/SOL_USD/orderbook`); - return res.ok; - } catch { - return false; - } - }, - DURATION_SEC * 1000, - ); -} - -async function benchCreateOrder() { - await runLoadTest( - "POST /v1/orders (LIMIT, no match)", - async () => { - try { - const price = 50 + Math.floor(Math.random() * 10); - const res = await fetch(`${API_URL}/v1/orders`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Api-Key": SERVICE_TOKEN ?? "", - "X-Service-Email": SERVICE_EMAIL ?? "", - }, - body: JSON.stringify({ - type: "LIMIT", - side: "BUY", - symbol: "SOL_USD", - price: String(price), - qty: "0.1", - }), - }); - return res.ok; - } catch { - return false; - } - }, - DURATION_SEC * 1000, - ); -} - -async function benchTrades() { - await runLoadTest( - "GET /v1/markets/SOL_USD/trades (public)", - async () => { - try { - const res = await fetch(`${API_URL}/v1/markets/SOL_USD/trades?limit=50`); - return res.ok; - } catch { - return false; - } - }, - DURATION_SEC * 1000, - ); -} - -// --- Main --- - -console.log("=== API Load Tests ==="); -console.log(` URL: ${API_URL}`); -console.log(` Concurrency: ${CONCURRENCY}`); -console.log(` Duration: ${DURATION_SEC}s\n`); - -// Warm up — make sure backend is alive -try { - const res = await fetch(`${API_URL}/health`); - if (!res.ok) { - console.error(`Backend health check failed (${res.status}). Is it running?`); - process.exit(1); - } - console.log(" Backend is healthy\n"); -} catch (err) { - console.error(`Cannot reach backend at ${API_URL}. Is it running?`); - process.exit(1); -} - -await benchMarkets(); -console.log(); -await benchOrderbook(); -console.log(); -if (SERVICE_TOKEN && SERVICE_EMAIL) { - await benchCreateOrder(); - console.log(); -} else { - console.log(" POST /v1/orders skipped (no service token or email)\n"); -} -await benchTrades(); -console.log(); diff --git a/benchmarks/engine.bench.ts b/benchmarks/engine.bench.ts deleted file mode 100644 index 099c76a..0000000 --- a/benchmarks/engine.bench.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { performance } from "perf_hooks"; -import { resetState, placeOrder, cancelOrder } from "../engine/tests/utils"; -import { getDepth } from "../engine/src/modules/orderbook"; - -const N = 1000; - -function stats(times: number[]) { - times.sort((a, b) => a - b); - const sum = times.reduce((a, b) => a + b, 0); - const avg = sum / times.length; - const p50 = times[Math.floor(times.length * 0.5)]; - const p95 = times[Math.floor(times.length * 0.95)]; - const p99 = times[Math.floor(times.length * 0.99)]; - return { avg, p50, p95, p99, total: sum }; -} - -function report(name: string, times: number[]) { - const s = stats(times); - const opsPerSec = (times.length / s.total) * 1000; - console.log(` ${name}`); - console.log(` ${times.length} ops in ${s.total.toFixed(0)}ms`); - console.log( - ` ${opsPerSec.toFixed(0)} ops/sec | avg ${s.avg.toFixed(2)}ms | p50 ${s.p50?.toFixed(2)}ms | p95 ${s.p95?.toFixed(2)}ms | p99 ${s.p99?.toFixed(2)}ms`, - ); -} - -async function benchLimitNoMatch() { - resetState(); - const times: number[] = []; - - for (let i = 0; i < N; i++) { - const start = performance.now(); - await placeOrder({ - id: crypto.randomUUID(), - userId: "1", - side: "BUY", - type: "LIMIT", - symbol: "BTC_USD", - price: 60000n, - qty: 100n, - }); - times.push(performance.now() - start); - } - - report("Limit order (no match)", times); -} - -async function benchLimitMatch() { - resetState(); - const times: number[] = []; - - // Seed resting asks - for (let i = 0; i < N; i++) { - await placeOrder({ - id: crypto.randomUUID(), - userId: "1", - side: "SELL", - type: "LIMIT", - symbol: "BTC_USD", - price: 60000n + BigInt(i % 10), - qty: 100n, - }); - } - - // Cross with buys - for (let i = 0; i < N; i++) { - const start = performance.now(); - await placeOrder({ - id: crypto.randomUUID(), - userId: "2", - side: "BUY", - type: "LIMIT", - symbol: "BTC_USD", - price: 60000n + BigInt(i % 10), - qty: 100n, - }); - times.push(performance.now() - start); - } - - report("Limit order (with match)", times); -} - -async function benchMarketOrder() { - resetState(); - const times: number[] = []; - - // Seed resting asks - for (let i = 0; i < N; i++) { - await placeOrder({ - id: crypto.randomUUID(), - userId: "1", - side: "SELL", - type: "LIMIT", - symbol: "BTC_USD", - price: 60000n, - qty: 100n, - }); - } - - for (let i = 0; i < N; i++) { - const start = performance.now(); - await placeOrder({ - id: crypto.randomUUID(), - userId: "2", - side: "BUY", - type: "MARKET", - symbol: "BTC_USD", - price: null, - qty: 100n, - }); - times.push(performance.now() - start); - } - - report("Market order", times); -} - -async function benchGetDepth() { - resetState(); - - // Populate book - for (let i = 0; i < 50; i++) { - await placeOrder({ - id: crypto.randomUUID(), - userId: "1", - side: "SELL", - type: "LIMIT", - symbol: "BTC_USD", - price: 60000n + BigInt(i), - qty: 100n, - }); - await placeOrder({ - id: crypto.randomUUID(), - userId: "2", - side: "BUY", - type: "LIMIT", - symbol: "BTC_USD", - price: 59999n - BigInt(i), - qty: 100n, - }); - } - - const times: number[] = []; - for (let i = 0; i < N * 5; i++) { - const start = performance.now(); - await getDepth("BTC_USD"); - times.push(performance.now() - start); - } - - report("getDepth (100 levels each side)", times); -} - -async function benchCancelOrder() { - resetState(); - const times: number[] = []; - const ids: string[] = []; - - // Place orders - for (let i = 0; i < N; i++) { - const id = crypto.randomUUID(); - await placeOrder({ - id, - userId: "1", - side: "BUY", - type: "LIMIT", - symbol: "BTC_USD", - price: 50000n + BigInt(i), - qty: 100n, - }); - ids.push(id); - } - - // Cancel them - for (const id of ids) { - const start = performance.now(); - await cancelOrder("1", id); - times.push(performance.now() - start); - } - - report("cancelOrder", times); -} - -console.log("=== Engine Benchmarks ===\n"); - -await benchLimitNoMatch(); -console.log(); -await benchLimitMatch(); -console.log(); -await benchMarketOrder(); -console.log(); -await benchGetDepth(); -console.log(); -await benchCancelOrder(); -console.log(); diff --git a/benchmarks/package.json b/benchmarks/package.json index fd91f25..44795a8 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -2,8 +2,8 @@ "name": "benchmarks", "private": true, "scripts": { - "bench:api": "bun run api.bench.ts", - "bench:engine": "bun run engine.bench.ts" + "bench": "bun run src/index.ts", + "typecheck": "bunx tsc --noEmit" }, "devDependencies": { "@types/bun": "latest" diff --git a/benchmarks/run.sh b/benchmarks/run.sh new file mode 100644 index 0000000..12a3c80 --- /dev/null +++ b/benchmarks/run.sh @@ -0,0 +1,56 @@ +#!/bin/sh + +set -u + +compose() { + PAPERDRILL_PROJECT=paperdrill-benchmark ./scripts/compose.sh test "$@" +} + +cleanup() { + compose down --volumes --remove-orphans +} + +trap cleanup EXIT INT TERM + +status=0 + +compose down --volumes --remove-orphans || true +compose build || status=$? + +if [ "$status" -eq 0 ]; then + compose up -d --wait postgres redis || status=$? +fi + +if [ "$status" -eq 0 ]; then + PAPERDRILL_PROJECT=paperdrill-benchmark ./scripts/database.sh test prepare || status=$? +fi + +if [ "$status" -eq 0 ]; then + compose up -d --wait backend engine worker || status=$? +fi + +if [ "$status" -eq 0 ]; then + ( + set -a + . ./.env.test + set +a + + if [ "$NODE_ENV" != "test" ]; then + echo "Refusing to benchmark outside the test environment" >&2 + exit 1 + fi + + export API_URL="http://127.0.0.1:8001" + export WS_URL="ws://127.0.0.1:8001" + + cd benchmarks + bun run bench + ) || status=$? +fi + +if [ "$status" -ne 0 ]; then + compose ps + compose logs --no-color backend engine worker +fi + +exit "$status" diff --git a/benchmarks/src/client.ts b/benchmarks/src/client.ts new file mode 100644 index 0000000..73dbdd5 --- /dev/null +++ b/benchmarks/src/client.ts @@ -0,0 +1,55 @@ +import { config } from "./config"; + +export interface OrderInput { + type: "LIMIT" | "MARKET"; + side: "BUY" | "SELL"; + symbol: string; + price?: string; + qty: string; +} + +export interface OrderResponse { + id?: string; + status?: "OPEN" | "PARTIALLY_FILLED" | "FILLED" | "CANCELLED"; + filledQty?: string; + error?: string; +} + +export class ServiceClient { + constructor(private readonly email: string) {} + + private headers() { + return { + "content-type": "application/json", + "x-api-key": config.serviceToken, + "x-service-email": this.email, + }; + } + + async deposit(asset: string, amount: string) { + const response = await fetch(`${config.apiUrl}/v1/deposits`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify({ asset, amount }), + }); + + if (!response.ok) { + throw new Error(`Deposit failed for ${this.email}: ${response.status} ${await response.text()}`); + } + } + + async createOrder(input: OrderInput): Promise<{ ok: boolean; data: OrderResponse }> { + const response = await fetch(`${config.apiUrl}/v1/orders`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify(input), + }); + const data = (await response.json()) as OrderResponse; + return { ok: response.ok, data }; + } +} + +export async function assertHealthy() { + const response = await fetch(`${config.apiUrl}/health`); + if (!response.ok) throw new Error(`Backend health check failed with ${response.status}`); +} diff --git a/benchmarks/src/config.ts b/benchmarks/src/config.ts new file mode 100644 index 0000000..0034d32 --- /dev/null +++ b/benchmarks/src/config.ts @@ -0,0 +1,29 @@ +function positiveInteger(name: string, fallback: number): number { + const value = Number(process.env[name] ?? fallback); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return value; +} + +function required(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +if (process.env.NODE_ENV !== "test") { + throw new Error("Benchmarks only run against the isolated test environment"); +} + +export const config = { + apiUrl: required("API_URL").replace(/\/$/, ""), + wsUrl: required("WS_URL").replace(/\/$/, ""), + serviceToken: required("SERVICE_TOKEN"), + httpConcurrency: positiveInteger("HTTP_CONCURRENCY", 50), + unmatchedOrders: positiveInteger("UNMATCHED_ORDERS", 10_000), + matchedOrders: positiveInteger("MATCHED_ORDERS", 5_000), + sweepOrders: positiveInteger("SWEEP_ORDERS", 1_000), + websocketConnections: positiveInteger("WS_CONNECTIONS", 500), + websocketTimeoutMs: positiveInteger("WS_TIMEOUT_MS", 5_000), +}; diff --git a/benchmarks/src/index.ts b/benchmarks/src/index.ts new file mode 100644 index 0000000..bf74cad --- /dev/null +++ b/benchmarks/src/index.ts @@ -0,0 +1,255 @@ +import { arch, cpus, platform } from "node:os"; +import { performance } from "node:perf_hooks"; +import { ServiceClient, assertHealthy } from "./client"; +import { config } from "./config"; +import { percentile, printLoad, requireSuccessful, runLoad } from "./metrics"; + +const FUNDING_AMOUNT = "1000000000000000"; +const btcService = new ServiceClient("btc.service@paperdrill.dev"); +const solService = new ServiceClient("sol.service@paperdrill.dev"); +const ethService = new ServiceClient("eth.service@paperdrill.dev"); + +async function seedOrders(options: { + client: ServiceClient; + total: number; + input: (index: number) => Parameters[0]; +}) { + const result = await runLoad({ + total: options.total, + concurrency: config.httpConcurrency, + operation: async (index) => (await options.client.createOrder(options.input(index))).ok, + }); + requireSuccessful("Liquidity setup", result); +} + +async function benchmarkUnmatchedOrders() { + await btcService.deposit("USD", FUNDING_AMOUNT); + + const result = await runLoad({ + total: config.unmatchedOrders, + concurrency: config.httpConcurrency, + operation: async (index) => { + const cents = 100 + (index % 100); + const response = await btcService.createOrder({ + type: "LIMIT", + side: "BUY", + symbol: "BTC_USD", + price: `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`, + qty: "0.0100", + }); + return response.ok && response.data.status === "OPEN"; + }, + }); + + printLoad("Unmatched limit-order load", result); + requireSuccessful("Unmatched order benchmark", result); +} + +async function benchmarkMatchedOrders() { + await Promise.all([ + solService.deposit("SOL", FUNDING_AMOUNT), + btcService.deposit("USD", FUNDING_AMOUNT), + ]); + + await seedOrders({ + client: solService, + total: config.matchedOrders, + input: () => ({ + type: "LIMIT", + side: "SELL", + symbol: "SOL_USD", + price: "100.00", + qty: "0.01", + }), + }); + + const result = await runLoad({ + total: config.matchedOrders, + concurrency: config.httpConcurrency, + operation: async () => { + const response = await btcService.createOrder({ + type: "LIMIT", + side: "BUY", + symbol: "SOL_USD", + price: "100.00", + qty: "0.01", + }); + return response.ok && response.data.status === "FILLED" && response.data.filledQty === "0.01"; + }, + }); + + printLoad("Matched limit-order load (one fill per taker)", result, "matched orders"); + requireSuccessful("Matched order benchmark", result); +} + +async function benchmarkSweep() { + await Promise.all([ + ethService.deposit("ETH", FUNDING_AMOUNT), + btcService.deposit("USD", FUNDING_AMOUNT), + ]); + + await seedOrders({ + client: ethService, + total: config.sweepOrders, + input: () => ({ + type: "LIMIT", + side: "SELL", + symbol: "ETH_USD", + price: "200.00", + qty: "0.001", + }), + }); + + const quantity = (config.sweepOrders / 1000).toFixed(3); + const startedAt = performance.now(); + const response = await btcService.createOrder({ + type: "LIMIT", + side: "BUY", + symbol: "ETH_USD", + price: "200.00", + qty: quantity, + }); + const elapsedMs = performance.now() - startedAt; + + if (!response.ok || response.data.status !== "FILLED" || response.data.filledQty !== quantity) { + throw new Error(`Sweep failed: ${JSON.stringify(response.data)}`); + } + + console.log(`\nSingle-order sweep`); + console.log(` ${config.sweepOrders} maker orders matched in ${elapsedMs.toFixed(1)} ms`); +} + +async function waitFor( + condition: () => boolean, + timeoutMs: number, + description: string, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await Bun.sleep(10); + } + throw new Error(`Timed out waiting for ${description}`); +} + +async function benchmarkWebSockets() { + const sockets: WebSocket[] = []; + const probeRecipients = new Set(); + const deliveryLatencies = new Map(); + let targetPrice = ""; + let measuring = false; + let deliveryStartedAt = 0; + + const connectStartedAt = performance.now(); + await Promise.all( + Array.from( + { length: config.websocketConnections }, + (_, index) => + new Promise((resolve, reject) => { + const socket = new WebSocket(config.wsUrl); + sockets.push(socket); + + const timeout = setTimeout( + () => reject(new Error(`WebSocket ${index} connection timed out`)), + config.websocketTimeoutMs, + ); + + socket.addEventListener("open", () => { + clearTimeout(timeout); + socket.send(JSON.stringify({ method: "SUBSCRIBE", params: ["depth:BTC_USD"] })); + resolve(); + }); + socket.addEventListener("error", () => { + clearTimeout(timeout); + reject(new Error(`WebSocket ${index} failed to connect`)); + }); + socket.addEventListener("message", (event) => { + try { + const message = JSON.parse(String(event.data)) as { + bids?: Array<{ price: string }>; + }; + if (!message.bids?.some((level) => level.price === targetPrice)) return; + + if (measuring) { + if (!deliveryLatencies.has(index)) { + deliveryLatencies.set(index, performance.now() - deliveryStartedAt); + } + } else { + probeRecipients.add(index); + } + } catch { + // Ignore unrelated or malformed messages during the benchmark. + } + }); + }), + ), + ); + const connectMs = performance.now() - connectStartedAt; + + for (let attempt = 0; attempt < 10 && probeRecipients.size < sockets.length; attempt++) { + targetPrice = `${10 + attempt}.00`; + await btcService.createOrder({ + type: "LIMIT", + side: "BUY", + symbol: "BTC_USD", + price: targetPrice, + qty: "1.0000", + }); + try { + await waitFor(() => probeRecipients.size === sockets.length, 500, "WebSocket subscriptions"); + } catch { + // A later probe gives subscriptions that were still being registered another chance. + } + } + + if (probeRecipients.size !== sockets.length) { + for (const socket of sockets) socket.close(); + throw new Error(`Only ${probeRecipients.size}/${sockets.length} WebSockets subscribed`); + } + + targetPrice = "25.00"; + measuring = true; + deliveryStartedAt = performance.now(); + const trigger = await btcService.createOrder({ + type: "LIMIT", + side: "BUY", + symbol: "BTC_USD", + price: targetPrice, + qty: "1.0000", + }); + if (!trigger.ok) + throw new Error(`WebSocket trigger order failed: ${JSON.stringify(trigger.data)}`); + + await waitFor( + () => deliveryLatencies.size === sockets.length, + config.websocketTimeoutMs, + "WebSocket depth delivery", + ); + + console.log(`\nWebSocket fan-out`); + console.log( + ` ${sockets.length} connections opened in ${connectMs.toFixed(1)} ms | ${deliveryLatencies.size}/${sockets.length} received update | p95 ${percentile([...deliveryLatencies.values()], 0.95).toFixed(1)} ms`, + ); + + for (const socket of sockets) socket.close(); +} + +async function main() { + await assertHealthy(); + + const cpu = cpus()[0]?.model ?? "unknown CPU"; + console.log("PaperDrill v1 benchmark"); + console.log(` Bun ${Bun.version} | ${platform()} ${arch()} | ${cpus().length} cores | ${cpu}`); + console.log( + ` HTTP concurrency ${config.httpConcurrency} | ${config.unmatchedOrders} unmatched | ${config.matchedOrders} matched | ${config.websocketConnections} WebSockets`, + ); + + await benchmarkUnmatchedOrders(); + await benchmarkMatchedOrders(); + await benchmarkSweep(); + await benchmarkWebSockets(); + + console.log("\nAll benchmark correctness checks passed."); +} + +await main(); diff --git a/benchmarks/src/metrics.ts b/benchmarks/src/metrics.ts new file mode 100644 index 0000000..f94ab74 --- /dev/null +++ b/benchmarks/src/metrics.ts @@ -0,0 +1,74 @@ +import { performance } from "node:perf_hooks"; + +interface LoadOptions { + total: number; + concurrency: number; + operation: (index: number) => Promise; +} + +export interface LoadResult { + total: number; + succeeded: number; + failed: number; + elapsedMs: number; + operationsPerSecond: number; + p95Ms: number; +} + +export function percentile(samples: number[], value: number): number { + if (samples.length === 0) return 0; + const sorted = samples.toSorted((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * value) - 1); + return sorted[Math.max(0, index)] ?? 0; +} + +export async function runLoad(options: LoadOptions): Promise { + const latencies: number[] = []; + let nextIndex = 0; + let succeeded = 0; + let failed = 0; + const startedAt = performance.now(); + + async function worker() { + for (;;) { + const index = nextIndex++; + if (index >= options.total) return; + + const requestStartedAt = performance.now(); + try { + if (await options.operation(index)) succeeded++; + else failed++; + } catch { + failed++; + } + latencies.push(performance.now() - requestStartedAt); + } + } + + await Promise.all( + Array.from({ length: Math.min(options.concurrency, options.total) }, () => worker()), + ); + + const elapsedMs = performance.now() - startedAt; + return { + total: options.total, + succeeded, + failed, + elapsedMs, + operationsPerSecond: (succeeded / elapsedMs) * 1000, + p95Ms: percentile(latencies, 0.95), + }; +} + +export function printLoad(label: string, result: LoadResult, unit = "orders") { + console.log(`\n${label}`); + console.log( + ` ${result.succeeded}/${result.total} succeeded | ${result.operationsPerSecond.toFixed(0)} ${unit}/sec | p95 ${result.p95Ms.toFixed(1)} ms`, + ); +} + +export function requireSuccessful(label: string, result: LoadResult) { + if (result.failed > 0) { + throw new Error(`${label} failed ${result.failed} of ${result.total} operations`); + } +} diff --git a/bot/src/client.ts b/bot/src/client.ts index 8a10310..2661c3d 100644 --- a/bot/src/client.ts +++ b/bot/src/client.ts @@ -11,6 +11,8 @@ interface OrderResponse { qty: string; } +type Balances = Record; + export const MARKET: { pricePrecision: number; qtyPrecision: number } = { pricePrecision: 0, qtyPrecision: 0, @@ -29,7 +31,11 @@ async function api(path: string, options?: RequestInit): Promise { try { const data = await res.json(); - if (!res.ok) throw new Error((data as any)?.error || `API ${res.status}`); + if (!res.ok) { + const error = (data as any)?.error; + const message = typeof error === "object" ? error?.message : error; + throw new Error(message || `API ${res.status}`); + } return data as T; } catch (err) { log(`API error: ${err}`); @@ -57,6 +63,10 @@ export async function getOpenOrders(): Promise { return api("/orders/open"); } +export async function getBalances(): Promise { + return api("/balances"); +} + export async function placeOrder( side: "BUY" | "SELL", type: "LIMIT" | "MARKET", @@ -73,9 +83,9 @@ export async function cancelOrder(orderId: string): Promise { await api(`/orders/${orderId}`, { method: "DELETE" }); } -export async function depositFunds(amount: string, asset: string) { +export async function depositFunds(amount: bigint, asset: string) { return api<{ [asset: string]: { available: string; locked: string } }>("/deposits", { method: "POST", - body: JSON.stringify({ amount, asset }), + body: JSON.stringify({ amount: amount.toString(), asset }), }); } diff --git a/bot/src/config.ts b/bot/src/config.ts index 5ee7571..e64b837 100644 --- a/bot/src/config.ts +++ b/bot/src/config.ts @@ -19,7 +19,7 @@ export const config = { depthPerSide: 20, cycleIntervalMs: [2000, 5000], spreadPercent: 0.2, - randomnessPercent: 0.1, - priceRefreshMs: 20000, + randomnessPercent: 0.02, + priceRefreshMs: 5000, maxOrderQty: env.MAX_ORDER_QTY, }; diff --git a/bot/src/index.ts b/bot/src/index.ts index 0451610..2ff6fb5 100644 --- a/bot/src/index.ts +++ b/bot/src/index.ts @@ -1,24 +1,22 @@ import { sleep } from "bun"; -import { getOpenOrders, placeOrder, cancelOrder, initMarket, depositFunds } from "./client"; +import { + cancelOrder, + depositFunds, + getBalances, + getOpenOrders, + initMarket, + placeOrder, + MARKET, +} from "./client"; import { config } from "./config"; import { getMidPrice } from "./price"; import { generateOrders } from "./strategy"; import { log, randomInt, shuffle } from "./util"; await initMarket(); +await cleanOpenOrders(); const [baseAsset, quoteAsset] = config.market.split("_") as [string, string]; -const depositAmount = "1000000"; - -async function ensureFunds() { - const basebalance = await depositFunds(depositAmount, baseAsset); - const quoteBalance = await depositFunds(depositAmount, quoteAsset); - log(`Deposited ${depositAmount} ${baseAsset} and ${depositAmount} ${quoteAsset}`); - log(`Updated ${baseAsset} balance: ${basebalance[baseAsset]?.available}`); - log(`Updated ${quoteAsset} balance: ${quoteBalance[quoteAsset]?.available}`); -} - -await ensureFunds(); for (;;) { try { @@ -33,10 +31,12 @@ for (;;) { const bids = open.filter((o) => o.side === "BUY"); const asks = open.filter((o) => o.side === "SELL"); - if (bids.length === 0 || asks.length === 0) { + if (bids.length === 0 && asks.length === 0) { await seed(midPrice); + } else if (bids.length === 0 || asks.length === 0) { + await resetBook(midPrice, open); } else { - await maintain(midPrice); + await maintain(midPrice, bids, asks); } } catch (e) { log(`Cycle error: ${e}`); @@ -47,6 +47,7 @@ for (;;) { } async function seed(midPrice: number) { + await ensureFunds(midPrice); const orders = generateOrders(midPrice); let placed = 0; @@ -55,37 +56,73 @@ async function seed(midPrice: number) { await placeOrder(o.side, "LIMIT", o.price, o.qty); placed++; } catch (e) { - const msg = String(e); - if (msg.includes("Insufficient balance")) { - log("Insufficient balance, re-depositing funds"); - await ensureFunds(); - try { - await placeOrder(o.side, "LIMIT", o.price, o.qty); - placed++; - } catch (e2) { - log(`Seed order failed after re-deposit: ${e2}`); - } - } else { - log(`Seed order failed: ${e}`); - } + log(`Seed order failed: ${e}`); } } log(`Seeded ${placed} orders`); } -async function maintain(midPrice: number) { - const open = await getOpenOrders(); - const bids = open.filter((o) => o.side === "BUY"); - const asks = open.filter((o) => o.side === "SELL"); +async function ensureFunds(midPrice: number) { + const balances = await getBalances(); + const baseTarget = config.maxOrderQty * config.depthPerSide; + const quoteTarget = baseTarget * midPrice; + + await topUp(baseAsset, baseTarget, Number(balances[baseAsset]?.available ?? 0), MARKET.qtyPrecision); + await topUp( + quoteAsset, + quoteTarget, + Number(balances[quoteAsset]?.available ?? 0), + MARKET.pricePrecision, + ); +} + +async function topUp(asset: string, target: number, available: number, precision: number) { + if (available >= target) return; + const factor = 10 ** precision; + const amount = BigInt(Math.ceil((target - available) * factor)); + if (amount <= 0n) return; + + await depositFunds(amount, asset); + log(`Deposited ${(Number(amount) / factor).toFixed(precision)} ${asset}`); +} + +async function cleanOpenOrders() { + const orders = await getOpenOrders(); + for (const order of orders) { + await cancelOrder(order.id); + } + log(`Cleaned ${orders.length} open orders on startup`); +} + +async function resetBook(midPrice: number, orders: { id: string }[]) { + for (const order of orders) { + try { + await cancelOrder(order.id); + } catch { + log(`Failed to cancel order ${order.id}; skipping seed`); + return; + } + } + + await seed(midPrice); +} +async function maintain( + midPrice: number, + bids: { id: string; side: string }[], + asks: { id: string; side: string }[], +) { const cancelCount = randomInt(1, 3); const bidsToCancel = shuffle(bids).slice(0, cancelCount); const asksToCancel = shuffle(asks).slice(0, cancelCount); + let cancelledBids = 0; + let cancelledAsks = 0; for (const o of bidsToCancel) { try { await cancelOrder(o.id); + cancelledBids++; } catch { log(`Failed to cancel bid order ${o.id}`); } @@ -94,24 +131,26 @@ async function maintain(midPrice: number) { for (const o of asksToCancel) { try { await cancelOrder(o.id); + cancelledAsks++; } catch { log(`Failed to cancel ask order ${o.id}`); } } - const newOpen = await getOpenOrders(); - const openBids = newOpen.filter((o) => o.side === "BUY"); - const openAsks = newOpen.filter((o) => o.side === "SELL"); - - const needBids = config.depthPerSide - openBids.length; - const needAsks = config.depthPerSide - openAsks.length; + let needBids = Math.max(0, config.depthPerSide - (bids.length - cancelledBids)); + let needAsks = Math.max(0, config.depthPerSide - (asks.length - cancelledAsks)); if (needBids > 0 || needAsks > 0) { const orders = generateOrders(midPrice); for (const o of orders) { + if (o.side === "BUY" && needBids === 0) continue; + if (o.side === "SELL" && needAsks === 0) continue; + try { await placeOrder(o.side, "LIMIT", o.price, o.qty); + if (o.side === "BUY") needBids--; + else needAsks--; } catch { log(`Failed to place order: ${o.side} ${o.price} ${o.qty}`); } @@ -119,6 +158,6 @@ async function maintain(midPrice: number) { } log( - `Book: ${openBids.length} bids, ${openAsks.length} asks | Cancelled ${bidsToCancel.length} bids, ${asksToCancel.length} asks`, + `Book: ${bids.length - cancelledBids} bids, ${asks.length - cancelledAsks} asks | Cancelled ${cancelledBids} bids, ${cancelledAsks} asks`, ); } diff --git a/bot/src/price.ts b/bot/src/price.ts index 02f455f..183eafc 100644 --- a/bot/src/price.ts +++ b/bot/src/price.ts @@ -3,18 +3,6 @@ import { config } from "./config"; let lastPrice: number | null = null; let lastFetch = 0; -const IDS: Record = { - BTC_USD: "bitcoin", - ETH_USD: "ethereum", - SOL_USD: "solana", -}; - -const fallbackPrice: Record = { - BTC_USD: 65949, - ETH_USD: 1920, - SOL_USD: 77, -}; - export async function getMidPrice() { const now = Date.now(); @@ -22,28 +10,24 @@ export async function getMidPrice() { return lastPrice; } try { - const id = IDS[config.market]; - if (!id) throw new Error(`No price source configured for ${config.market}`); - + const product = config.market.replace("_", "-"); const res = await fetch( - `https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=usd`, + `https://api.exchange.coinbase.com/products/${product}/ticker`, ); + if (!res.ok) throw new Error(`Price request failed with ${res.status}`); - const data = (await res.json()) as Record; - const entry = data[id]; - if (!entry) throw new Error("No price data"); + const data = (await res.json()) as { bid?: string; ask?: string }; + const bid = Number(data.bid); + const ask = Number(data.ask); + if (!Number.isFinite(bid) || !Number.isFinite(ask) || bid <= 0 || ask <= 0) { + throw new Error("Invalid price data"); + } - lastPrice = entry.usd; + lastPrice = (bid + ask) / 2; lastFetch = now; return lastPrice; } catch { - if (lastPrice) { - return lastPrice; - } - - const fallback = fallbackPrice[config.market]; - if (fallback == null) throw new Error(`No fallback price configured for ${config.market}`); - return fallback; + return null; } } diff --git a/bot/src/strategy.ts b/bot/src/strategy.ts index b7f54e5..92f2fae 100644 --- a/bot/src/strategy.ts +++ b/bot/src/strategy.ts @@ -12,8 +12,8 @@ function formatPrice(price: number): string { } function randomQty(): string { - const maxQty = config.maxOrderQty ?? 0.5; - return (Math.random() * maxQty).toFixed(MARKET.qtyPrecision); + const minimumQty = 10 ** -MARKET.qtyPrecision; + return Math.max(Math.random() * config.maxOrderQty, minimumQty).toFixed(MARKET.qtyPrecision); } export function generateOrders(midPrice: number): Order[] { diff --git a/compose.bot.yml b/compose.bot.yml deleted file mode 100644 index 4736bb8..0000000 --- a/compose.bot.yml +++ /dev/null @@ -1,38 +0,0 @@ -services: - btc-bot: - build: ./bot - restart: on-failure:5 - env_file: .env.prod - environment: - MARKET: BTC_USD - SERVICE_EMAIL: btc.service@paperdrill.dev - MAX_ORDER_QTY: 0.1 - networks: - - paperdrill - - sol-bot: - build: ./bot - restart: on-failure:5 - env_file: .env.prod - environment: - MARKET: SOL_USD - SERVICE_EMAIL: sol.service@paperdrill.dev - MAX_ORDER_QTY: 10 - networks: - - paperdrill - - eth-bot: - build: ./bot - restart: on-failure:5 - env_file: .env.prod - environment: - MARKET: ETH_USD - SERVICE_EMAIL: eth.service@paperdrill.dev - MAX_ORDER_QTY: 1 - networks: - - paperdrill - -networks: - paperdrill: - name: paperdrill-network - external: true diff --git a/compose.dev.yml b/compose.dev.yml index 5d39595..06e7676 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -1,77 +1,94 @@ +x-dev-bot: &dev-bot + build: ./bot + env_file: ./.env.dev + profiles: ["bots"] + restart: on-failure:5 + depends_on: + backend: + condition: service_healthy + services: backend: build: context: ./backend target: dev - restart: unless-stopped + env_file: ./.env.dev ports: - "8000:8000" - env_file: ./.env.dev volumes: - ./backend:/usr/src/app - /usr/src/app/node_modules depends_on: - - postgres - - redis - networks: - - paperdrill + postgres: + condition: service_healthy engine: build: context: ./engine target: dev - restart: unless-stopped env_file: ./.env.dev volumes: - ./engine:/usr/src/app - /usr/src/app/node_modules depends_on: - - postgres - - redis - networks: - - paperdrill + postgres: + condition: service_healthy worker: build: context: ./worker target: dev - restart: unless-stopped env_file: ./.env.dev volumes: - ./worker:/usr/src/app - /usr/src/app/node_modules depends_on: - - postgres - - redis - networks: - - paperdrill + postgres: + condition: service_healthy postgres: image: timescale/timescaledb:2.28.2-pg17 - restart: unless-stopped ports: - "5433:5432" environment: POSTGRES_DB: paperdrill POSTGRES_USER: postgres POSTGRES_PASSWORD: password + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d paperdrill"] + interval: 5s + timeout: 5s + retries: 10 volumes: - - postgres_data:/var/lib/postgresql/data - networks: - - paperdrill + - postgres-data:/var/lib/postgresql/data redis: - image: redis:7.2 - restart: unless-stopped ports: - "6380:6379" - networks: - - paperdrill -volumes: - postgres_data: + btc-bot: + <<: *dev-bot + environment: + BASE_URL: http://backend:8000 + MARKET: BTC_USD + SERVICE_EMAIL: btc.service@paperdrill.dev + MAX_ORDER_QTY: 0.1 -networks: - paperdrill: - name: paperdrill-network - external: true + sol-bot: + <<: *dev-bot + environment: + BASE_URL: http://backend:8000 + MARKET: SOL_USD + SERVICE_EMAIL: sol.service@paperdrill.dev + MAX_ORDER_QTY: 10 + + eth-bot: + <<: *dev-bot + environment: + BASE_URL: http://backend:8000 + MARKET: ETH_USD + SERVICE_EMAIL: eth.service@paperdrill.dev + MAX_ORDER_QTY: 1 + +volumes: + postgres-data: diff --git a/compose.prod.yml b/compose.prod.yml index a240c1e..cc3dc4b 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -1,72 +1,48 @@ +x-prod-runtime: &prod-runtime + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +x-prod-app: &prod-app + <<: *prod-runtime + env_file: ./.env.prod + +x-bot-service: &bot-service + <<: *prod-app + image: "ghcr.io/hars-21/paperdrill-bot:${PAPERDRILL_IMAGE_TAG:?Set PAPERDRILL_IMAGE_TAG in .env.prod}" + profiles: ["bots"] + restart: on-failure:5 + depends_on: + backend: + condition: service_healthy + services: backend: - build: - context: ./backend - target: runner - restart: unless-stopped - expose: - - 8000 - env_file: ./.env.prod - healthcheck: - test: - [ - "CMD", - "bun", - "-e", - "const r = await fetch('http://localhost:8000/health'); if (!r.ok) process.exit(1)", - ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" - depends_on: - redis: - condition: service_healthy - networks: - - paperdrill + <<: *prod-app + image: "ghcr.io/hars-21/paperdrill-backend:${PAPERDRILL_IMAGE_TAG:?Set PAPERDRILL_IMAGE_TAG in .env.prod}" engine: - build: - context: ./engine - target: runner - restart: unless-stopped - env_file: ./.env.prod - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" - depends_on: - redis: - condition: service_healthy + <<: *prod-app + image: "ghcr.io/hars-21/paperdrill-engine:${PAPERDRILL_IMAGE_TAG:?Set PAPERDRILL_IMAGE_TAG in .env.prod}" volumes: - engine-snapshots:/usr/src/app/snapshots - networks: - - paperdrill worker: - build: - context: ./worker - target: runner - restart: unless-stopped - env_file: ./.env.prod - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" - depends_on: - redis: - condition: service_healthy - networks: - - paperdrill + <<: *prod-app + image: "ghcr.io/hars-21/paperdrill-worker:${PAPERDRILL_IMAGE_TAG:?Set PAPERDRILL_IMAGE_TAG in .env.prod}" + + redis: + <<: *prod-runtime + image: redis:7.2 + command: redis-server --appendonly yes --maxmemory 768mb --maxmemory-policy noeviction --tcp-keepalive 60 --appendfsync everysec --auto-aof-rewrite-percentage 100 --auto-aof-rewrite-min-size 64mb + volumes: + - redis-data:/data nginx: + <<: *prod-runtime image: nginx:stable ports: - "80:80" @@ -77,34 +53,28 @@ services: volumes: - ./nginx/conf.d/:/etc/nginx/conf.d:ro - /etc/letsencrypt:/etc/letsencrypt:ro - networks: - - paperdrill - redis: - image: redis:7.2 - command: redis-server --appendonly yes --maxmemory 768mb --maxmemory-policy noeviction --tcp-keepalive 60 --appendfsync everysec --auto-aof-rewrite-percentage 100 --auto-aof-rewrite-min-size 64mb - restart: unless-stopped - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 10s - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" - volumes: - - redis-data:/data - networks: - - paperdrill + btc-bot: + <<: *bot-service + environment: + MARKET: BTC_USD + SERVICE_EMAIL: btc.service@paperdrill.dev + MAX_ORDER_QTY: 0.1 + + sol-bot: + <<: *bot-service + environment: + MARKET: SOL_USD + SERVICE_EMAIL: sol.service@paperdrill.dev + MAX_ORDER_QTY: 10 + + eth-bot: + <<: *bot-service + environment: + MARKET: ETH_USD + SERVICE_EMAIL: eth.service@paperdrill.dev + MAX_ORDER_QTY: 1 volumes: engine-snapshots: redis-data: - -networks: - paperdrill: - name: paperdrill-network - external: true diff --git a/compose.test.yml b/compose.test.yml new file mode 100644 index 0000000..1e5b531 --- /dev/null +++ b/compose.test.yml @@ -0,0 +1,55 @@ +x-test-app: &test-app + env_file: ./.env.test + healthcheck: + interval: 2s + timeout: 5s + retries: 20 + +services: + postgres: + image: timescale/timescaledb:2.28.2-pg17 + ports: + - "5434:5432" + environment: + POSTGRES_DB: paperdrill_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d paperdrill_test"] + interval: 2s + timeout: 5s + retries: 15 + tmpfs: + - /var/lib/postgresql/data + + redis: + ports: + - "6381:6379" + healthcheck: + interval: 2s + timeout: 5s + retries: 15 + tmpfs: + - /data + + backend: + <<: *test-app + build: + context: ./backend + target: runner + ports: + - "8001:8000" + + engine: + <<: *test-app + build: + context: ./engine + target: runner + tmpfs: + - /usr/src/app/snapshots + + worker: + <<: *test-app + build: + context: ./worker + target: runner diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..6b6d88d --- /dev/null +++ b/compose.yml @@ -0,0 +1,66 @@ +services: + backend: + expose: + - "8000" + depends_on: + redis: + condition: service_healthy + engine: + condition: service_healthy + worker: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "bun", + "-e", + "const r = await fetch('http://localhost:8000/health'); if (!r.ok) process.exit(1)", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + engine: + depends_on: + redis: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "bun", + "-e", + "const value = Number(await Bun.file('/tmp/health').text()); const age = Date.now() - value; if (!Number.isFinite(age) || age < 0 || age > 30000) process.exit(1)", + ] + interval: 15s + timeout: 5s + retries: 3 + start_period: 20s + + worker: + depends_on: + redis: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "bun", + "-e", + "const value = Number(await Bun.file('/tmp/health').text()); const age = Date.now() - value; if (!Number.isFinite(age) || age < 0 || age > 30000) process.exit(1)", + ] + interval: 15s + timeout: 5s + retries: 3 + start_period: 20s + + redis: + image: redis:7.2 + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/engine/bun.lock b/engine/bun.lock index bd397a5..579f5a7 100644 --- a/engine/bun.lock +++ b/engine/bun.lock @@ -5,6 +5,7 @@ "": { "name": "engine", "dependencies": { + "@sentry/bun": "^10.74.0", "dotenv": "^17.4.2", "pg": "^8.23.0", "redis": "^6.0.0", @@ -20,6 +21,28 @@ }, }, "packages": { + "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.18.1", "", { "dependencies": { "@types/estree": "^1.0.8", "astring": "^1.9.0", "esquery": "^1.7.0", "meriyah": "^6.1.4", "semifies": "^1.0.0", "source-map": "^0.6.0" }, "bin": { "code-transformer": "cli.js" } }, "sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ=="], + + "@apm-js-collab/code-transformer-bundler-plugins": ["@apm-js-collab/code-transformer-bundler-plugins@0.7.4", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.18.1", "es-module-lexer": "^2.1.0", "magic-string": "^0.30.21", "module-details-from-path": "^1.0.4" } }, "sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA=="], + + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/sdk-trace": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@redis/bloom": ["@redis/bloom@6.0.0", "", { "peerDependencies": { "@redis/client": "^6.0.0" } }, "sha512-P0n5NkV9IIdT6nYXOfMHG83sho8pE7Nay7yw27wOGVLv4DthgvzebpGz6m7VuMTizeJmw3LPw2Xek5wFUhGpVw=="], "@redis/client": ["@redis/client@6.0.0", "", { "dependencies": { "cluster-key-slot": "1.1.2" }, "peerDependencies": { "@node-rs/xxhash": "^1.1.0", "@opentelemetry/api": ">=1 <2" }, "optionalPeers": ["@node-rs/xxhash", "@opentelemetry/api"] }, "sha512-NS4iIT25r24sAjNQ2nSRdCW5jPJoV0rxkBee27oTeR+RXaOu89cjIsrww5rPBaYVGVdL1QCx9uz9141gZiSKdQ=="], @@ -30,18 +53,56 @@ "@redis/time-series": ["@redis/time-series@6.0.0", "", { "peerDependencies": { "@redis/client": "^6.0.0" } }, "sha512-QWhkYsg+3lhBrBf+cbzybtV8LQcSrk7iXIgTaGU+pHNFTkql7TpVRE24ROS6M2ybVIV6O/zxTqfxgxxYiqyw0Q=="], + "@sentry/bun": ["@sentry/bun@10.74.0", "", { "dependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.74.0", "@sentry/node": "10.74.0", "@sentry/server-utils": "10.74.0" } }, "sha512-KDX+sBp0ISatJ31r+GN4Uk1FUogDM+e3xqTTD1sTATpmYH0PEmggqZC/RaG+VM2DjDxf7jxjdeNuLuC1xKhV3A=="], + + "@sentry/conventions": ["@sentry/conventions@0.16.0", "", {}, "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ=="], + + "@sentry/core": ["@sentry/core@10.74.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-u9rY8vcZfktccwm6LznfCZlqP5C9A+p76r4/pFS1grqpuTO0m21Cl8rosnlESrDGP/Xd9tfr91rWYk0jPH8jeQ=="], + + "@sentry/node": ["@sentry/node@10.74.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.74.0", "@sentry/node-core": "10.74.0", "@sentry/opentelemetry": "10.74.0", "@sentry/server-utils": "10.74.0", "import-in-the-middle": "^3.0.0" } }, "sha512-u1wsarTOWHn9CCev81Da5T4IQHZgdcosXRfX2+4DMII/lVJMBYesKixTQuMwXexjVG2+pkf16zTmgjZDa+75jA=="], + + "@sentry/node-core": ["@sentry/node-core@10.74.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.74.0", "@sentry/opentelemetry": "10.74.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-btgZXcGmkOGgojbxHo/gfGiyqYpzC9E8zVR78Q3MtM6Xnemk9gwJiQlhNmEX/FM+C36WBRPZrdcZcnMaJhbfJw=="], + + "@sentry/opentelemetry": ["@sentry/opentelemetry@10.74.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.74.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-ua5mt0NDBfye+/ACKjAw9Ad2i+y42lOLvhxXZepZXjszblMz80MEsIZflMB7uLZUCTNH7MbZN8tlzCy8KsJKmQ=="], + + "@sentry/server-utils": ["@sentry/server-utils@10.74.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.74.0" } }, "sha512-AHmPIGE8yVRyywpZRhAkm/H0cHTgsQSPhFZFbaeQLcYHFE/eOP9bAMh8Nj/Apr7WMagFXYHd04tR/63BxtLgKw=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@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.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "import-in-the-middle": ["import-in-the-middle@3.5.1", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^3.0.2", "module-details-from-path": "^1.0.4" } }, "sha512-mPKuL8bPQzecui2KK6Gb+M8JvJoHnhS1FeYGa22QopBmlevF5F0FE6ued/B5EgHDeIoMTONIpDWWFKUPOG0DBQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], + + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "pg": ["pg@8.23.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.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-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg=="], "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], @@ -68,6 +129,12 @@ "redis": ["redis@6.0.0", "", { "dependencies": { "@redis/bloom": "6.0.0", "@redis/client": "6.0.0", "@redis/json": "6.0.0", "@redis/search": "6.0.0", "@redis/time-series": "6.0.0" } }, "sha512-n9Thfc39OXleEoPT2k5gwKsqY+HfCww3YS71ofcr9KKbkn89bpjU9dToIlD+JRdM3/GYQkwMtVgTxLyed+LptQ=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + + "semifies": ["semifies@1.0.0", "", {}, "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -77,5 +144,7 @@ "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "import-in-the-middle/es-module-lexer": ["es-module-lexer@3.0.2", "", {}, "sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg=="], } } diff --git a/engine/package.json b/engine/package.json index c817f9e..60e0397 100644 --- a/engine/package.json +++ b/engine/package.json @@ -17,6 +17,7 @@ "typescript": "^5" }, "dependencies": { + "@sentry/bun": "^10.74.0", "dotenv": "^17.4.2", "pg": "^8.23.0", "redis": "^6.0.0", diff --git a/engine/src/config/index.ts b/engine/src/config/index.ts index 273c64b..0f87642 100644 --- a/engine/src/config/index.ts +++ b/engine/src/config/index.ts @@ -12,6 +12,8 @@ export const envSchema = z.object({ RECENT_TRADES_LIMIT: z.coerce.number().positive().default(50), STREAM_READ_BATCH_SIZE: z.coerce.number().positive().default(200), RESPONSE_BATCH_SIZE: z.coerce.number().positive().default(50), + SENTRY_DSN: z.string().trim().optional(), + SENTRY_ENVIRONMENT: z.string().trim().optional(), }); const env = envSchema.parse(process.env); @@ -27,4 +29,9 @@ export const config = { recentTradesLimit: env.RECENT_TRADES_LIMIT, streamReadBatchSize: env.STREAM_READ_BATCH_SIZE, responseBatchSize: env.RESPONSE_BATCH_SIZE, + sentry: { + enabled: Boolean(env.SENTRY_DSN) && env.NODE_ENV !== "test", + dsn: env.SENTRY_DSN, + environment: env.SENTRY_ENVIRONMENT ?? env.NODE_ENV, + }, }; diff --git a/engine/src/core/dispatcher.ts b/engine/src/core/dispatcher.ts index 3ecc825..5f621a8 100644 --- a/engine/src/core/dispatcher.ts +++ b/engine/src/core/dispatcher.ts @@ -8,6 +8,7 @@ import { getTradesHandler } from "../handlers/getTrades"; import { createDepositHandler } from "../handlers/createDeposit"; import { initializeBalanceHandler } from "../handlers/initializeBalance"; import { getAllBalancesHandler } from "../handlers/getAllBalances"; +import { applyCreditHandler } from "../handlers/applyCredit"; type EngineHandler = (payload: Record) => Promise; @@ -19,6 +20,7 @@ const handlers: Record = { get_all_balances: getAllBalancesHandler, get_open_orders: getOpenOrdersHandler, get_trades: getTradesHandler, + apply_credit: applyCreditHandler, create_deposit: createDepositHandler, initialize_balance: initializeBalanceHandler, }; diff --git a/engine/src/handlers/applyCredit.ts b/engine/src/handlers/applyCredit.ts new file mode 100644 index 0000000..6462a47 --- /dev/null +++ b/engine/src/handlers/applyCredit.ts @@ -0,0 +1,13 @@ +import { applyCredit } from "../modules/balance"; +import { creditPayloadSchema } from "../schema"; + +export async function applyCreditHandler(payload: Record) { + const parsed = creditPayloadSchema.safeParse(payload); + + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? "Invalid payload"); + } + + const { creditId, userId, asset, amount } = parsed.data; + return applyCredit(creditId, userId, asset, amount); +} diff --git a/engine/src/health.ts b/engine/src/health.ts new file mode 100644 index 0000000..2f5228d --- /dev/null +++ b/engine/src/health.ts @@ -0,0 +1,37 @@ +import { unlink } from "node:fs/promises"; +import { pool } from "./db"; +import { cacheClient } from "./redis/client"; +import { logger } from "./util/logger"; + +const HEALTH_FILE = "/tmp/health"; +const HEALTH_INTERVAL_MS = 10_000; + +let healthInterval: ReturnType | undefined; +let healthCheck: Promise | undefined; + +function refreshHealth() { + if (healthCheck) return healthCheck; + + healthCheck = (async () => { + await Promise.all([pool.query("SELECT 1"), cacheClient.ping()]); + await Bun.write(HEALTH_FILE, Date.now().toString()); + })().finally(() => { + healthCheck = undefined; + }); + + return healthCheck; +} + +export async function startHealth() { + await refreshHealth(); + healthInterval = setInterval(() => { + void refreshHealth().catch((error) => logger.error("Engine health check failed", error)); + }, HEALTH_INTERVAL_MS); +} + +export async function stopHealth() { + if (healthInterval) clearInterval(healthInterval); + healthInterval = undefined; + await healthCheck?.catch(() => undefined); + await unlink(HEALTH_FILE).catch(() => undefined); +} diff --git a/engine/src/index.ts b/engine/src/index.ts index 0bd3322..8764cdf 100644 --- a/engine/src/index.ts +++ b/engine/src/index.ts @@ -1,3 +1,4 @@ +import { flushSentry } from "./instrument"; import type { EngineRequest, EngineResponse } from "./types/request"; import { cacheClient, @@ -14,15 +15,10 @@ import { connectDB, disconnectDB } from "./db"; import { bigintReplacer } from "./util"; import { initMarkets } from "./modules/market"; import { registerEventHandlers } from "./core/events"; +import { startHealth, stopHealth } from "./health"; const abortController = new AbortController(); -await connectRedis(); -await connectDB(); -await initMarkets(); -await loadSnapshot(); -registerEventHandlers(); - async function sendResponse(responseQueue: string, response: EngineResponse) { await streamProducer.lPush(responseQueue, JSON.stringify(response, bigintReplacer)); } @@ -133,27 +129,50 @@ async function main() { } } -main().catch((err) => logger.error("Engine process error", err)); - -setInterval(() => { - snapshot().catch((err) => logger.error("Snapshot error", err)); -}, config.snapshotInterval).unref(); +async function start() { + await connectRedis(); + await connectDB(); + await initMarkets(); + await loadSnapshot(); + registerEventHandlers(); + await startHealth(); + logger.info("Engine started", { + incomingStream: config.incomingStream, + snapshotIntervalMs: config.snapshotInterval, + }); + + setInterval(() => { + snapshot().catch((err) => logger.error("Snapshot error", err)); + }, config.snapshotInterval).unref(); + + await main(); +} async function gracefulShutdown(signal: string) { logger.info(`Received ${signal}, shutting down...`); - const forceExit = setTimeout(() => { + const forceExit = setTimeout(async () => { logger.error("Graceful shutdown timed out, forcing exit"); + await flushSentry(1_000); process.exit(1); }, 10000); abortController.abort(); + await stopHealth(); await disconnectRedis(); await disconnectDB(); + await flushSentry(); clearTimeout(forceExit); + logger.info("Engine shutdown complete"); process.exit(0); } process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); process.on("SIGINT", () => gracefulShutdown("SIGINT")); + +start().catch(async (err) => { + logger.error("Engine process error", err); + await flushSentry(); + process.exit(1); +}); diff --git a/engine/src/instrument.ts b/engine/src/instrument.ts new file mode 100644 index 0000000..d99885e --- /dev/null +++ b/engine/src/instrument.ts @@ -0,0 +1,30 @@ +import "dotenv/config"; +import * as Sentry from "@sentry/bun"; +import { config } from "./config"; + +Sentry.init({ + dsn: config.sentry.dsn, + enabled: config.sentry.enabled, + environment: config.sentry.environment, + sendDefaultPii: false, + maxBreadcrumbs: 50, + initialScope: { tags: { service: "engine" } }, +}); + +export function captureEngineException( + error: unknown, + context?: { tags?: Record; extra?: Record }, +) { + if (!config.sentry.enabled) return; + + Sentry.withScope((scope) => { + if (context?.tags) scope.setTags(context.tags); + if (context?.extra) scope.setExtras(context.extra); + Sentry.captureException(error); + }); +} + +export async function flushSentry(timeoutMs = 2_000) { + if (!config.sentry.enabled) return true; + return Sentry.flush(timeoutMs); +} diff --git a/engine/src/modules/balance.ts b/engine/src/modules/balance.ts index 296d4da..0fd49db 100644 --- a/engine/src/modules/balance.ts +++ b/engine/src/modules/balance.ts @@ -1,4 +1,4 @@ -import { ASSETS, BALANCES } from "../store"; +import { APPLIED_CREDITS, ASSETS, BALANCES } from "../store"; import type { CreateOrderInput, InternalOrder, Fill, UserBalance } from "../types/domain"; import { getMarket } from "./market"; @@ -59,6 +59,17 @@ export function addBalance(userId: string, asset: string, amount: bigint) { return { [asset]: assetBalance }; } +export function applyCredit(creditId: string, userId: string, asset: string, amount: bigint) { + if (APPLIED_CREDITS.has(creditId)) { + return { applied: false, balance: getUserBalance(userId, asset) }; + } + + const balance = addBalance(userId, asset, amount); + APPLIED_CREDITS.add(creditId); + + return { applied: true, balance }; +} + export function getUserBalance(userId: string, asset?: string): UserBalance { const userBalance = BALANCES[userId] ?? {}; diff --git a/engine/src/modules/market.ts b/engine/src/modules/market.ts index 7100fd0..6b76996 100644 --- a/engine/src/modules/market.ts +++ b/engine/src/modules/market.ts @@ -52,7 +52,7 @@ export async function initMarkets() { break; } catch (err) { logger.error("Failed to load markets from database", err); - wait(5000); + await wait(5000); } } } diff --git a/engine/src/redis/client.ts b/engine/src/redis/client.ts index 8557e88..199ed53 100644 --- a/engine/src/redis/client.ts +++ b/engine/src/redis/client.ts @@ -3,19 +3,19 @@ import { config } from "../config"; import { logger } from "../util/logger"; export const streamConsumer = createClient({ url: config.redisUrl }).on("error", (err) => - logger.error("Redis streamConsumer error", { error: (err as Error).message }), + logger.error("Redis streamConsumer error", err), ); export const streamProducer = createClient({ url: config.redisUrl }).on("error", (err) => - logger.error("Redis streamProducer error", { error: (err as Error).message }), + logger.error("Redis streamProducer error", err), ); export const publisher = createClient({ url: config.redisUrl }).on("error", (err) => - logger.error("Redis publisher error", { error: (err as Error).message }), + logger.error("Redis publisher error", err), ); export const cacheClient = createClient({ url: config.redisUrl }).on("error", (err) => - logger.error("Redis cacheClient error", { error: (err as Error).message }), + logger.error("Redis cacheClient error", err), ); export async function connectRedis() { @@ -29,7 +29,7 @@ export async function connectRedis() { logger.info(`Engine listening on Redis queue: ${config.incomingStream}`); } catch (err) { logger.error("Redis connection error", err); - process.exit(1); + throw err; } } diff --git a/engine/src/schema/index.ts b/engine/src/schema/index.ts index 4c3f0db..bab46b9 100644 --- a/engine/src/schema/index.ts +++ b/engine/src/schema/index.ts @@ -49,3 +49,7 @@ export const depositPayloadSchema = userPayloadSchema.extend({ }); export const initializeBalancePayloadSchema = depositPayloadSchema; + +export const creditPayloadSchema = depositPayloadSchema.extend({ + creditId: z.string().trim().min(1, "creditId is required"), +}); diff --git a/engine/src/store.ts b/engine/src/store.ts index d8dd5ea..2769ecf 100644 --- a/engine/src/store.ts +++ b/engine/src/store.ts @@ -28,6 +28,7 @@ import { config } from "./config"; */ export const BALANCES: Record = {}; export const ASSETS = new Set(); +export const APPLIED_CREDITS = new Set(); /* ORDERBOOK = { diff --git a/engine/src/types/request.ts b/engine/src/types/request.ts index b7e46c8..1788dab 100644 --- a/engine/src/types/request.ts +++ b/engine/src/types/request.ts @@ -6,6 +6,7 @@ export type EngineCommandType = | "get_open_orders" | "get_trades" | "cancel_order" + | "apply_credit" | "create_deposit" | "initialize_balance"; diff --git a/engine/src/util/logger.ts b/engine/src/util/logger.ts index f6940f3..f1e9174 100644 --- a/engine/src/util/logger.ts +++ b/engine/src/util/logger.ts @@ -1,4 +1,5 @@ import { config } from "../config"; +import { captureEngineException } from "../instrument"; type LogLevel = "debug" | "info" | "warn" | "error"; @@ -6,11 +7,18 @@ const LEVEL = { debug: 0, info: 1, warn: 2, error: 3 }; function log(level: LogLevel, message: string, meta?: unknown) { if (LEVEL[level] < LEVEL[config.logLevel]) return; + if (level === "error" && meta instanceof Error) { + captureEngineException(meta, { tags: { source: "engine_logger" } }); + } const ts = new Date().toISOString(); const prefix = `[${ts}] [${level.toUpperCase()}]`; + const details = + meta instanceof Error ? { name: meta.name, message: meta.message, stack: meta.stack } : meta; const line = - meta !== undefined ? `${prefix} ${message} ${JSON.stringify(meta)}` : `${prefix} ${message}`; + details !== undefined + ? `${prefix} ${message} ${JSON.stringify(details)}` + : `${prefix} ${message}`; if (level === "error") console.error(line); else console.log(line); } diff --git a/engine/src/util/snapshot.ts b/engine/src/util/snapshot.ts index 6bbd9c2..4645ccc 100644 --- a/engine/src/util/snapshot.ts +++ b/engine/src/util/snapshot.ts @@ -1,10 +1,10 @@ import fs from "fs/promises"; -import { BALANCES, ORDERBOOK, ORDERS, RECENT_TRADES } from "../store"; -import type { Fill, InternalOrder, PriceLevel } from "../types/domain"; +import { APPLIED_CREDITS, BALANCES, ORDERBOOK, ORDERS, RECENT_TRADES } from "../store"; +import type { Fill, InternalOrder, PriceLevel, RestingOrder } from "../types/domain"; import { logger } from "./logger"; import { cacheClient } from "../redis/client"; -const SNAPSHOT_VERSION = 3; +const SNAPSHOT_VERSION = 4; const SNAPSHOT_PATH = "snapshots/snapshot.json"; function bigintReplacer(_key: string, value: unknown) { @@ -45,6 +45,7 @@ export async function snapshot() { ), recentTrades: RECENT_TRADES, orders: Object.fromEntries(ORDERS), + appliedCreditIds: [...APPLIED_CREDITS], }; const tmpPath = `${SNAPSHOT_PATH}.tmp`; @@ -52,6 +53,29 @@ export async function snapshot() { await fs.mkdir("snapshots", { recursive: true }); await fs.writeFile(tmpPath, JSON.stringify(snapshotData, bigintReplacer)); await fs.rename(tmpPath, SNAPSHOT_PATH); + logger.info("Snapshot saved", { + jobsLastId, + orders: ORDERS.size, + appliedCredits: APPLIED_CREDITS.size, + }); +} + +function restorePriceLevels(savedLevels: Record | undefined) { + return new Map( + Object.entries(savedLevels ?? {}).map(([price, level]) => [ + BigInt(price), + { + totalQty: level.totalQty, + orders: level.orders.map((savedOrder) => { + const order = ORDERS.get(savedOrder.id); + if (!order) { + throw new Error(`Snapshot order ${savedOrder.id} is missing from the order index`); + } + return order as RestingOrder; + }), + }, + ]), + ); } export async function loadSnapshot() { @@ -67,21 +91,22 @@ export async function loadSnapshot() { Object.assign(BALANCES, parsed.balances); + for (const [key, value] of Object.entries(parsed.orders)) { + ORDERS.set(key, value as InternalOrder); + } + for (const [symbol, market] of Object.entries(ORDERBOOK)) { const saved = parsed.orderbook?.[symbol]; if (!saved) continue; market.bestBid = saved.bestBid; market.bestAsk = saved.bestAsk; - market.bids = new Map( - Object.entries(saved.bids).map(([k, v]) => [BigInt(k), v as PriceLevel]), - ); - market.asks = new Map( - Object.entries(saved.asks).map(([k, v]) => [BigInt(k), v as PriceLevel]), - ); + market.bids = restorePriceLevels(saved.bids); + market.asks = restorePriceLevels(saved.asks); } - for (const [key, value] of Object.entries(parsed.orders)) { - ORDERS.set(key, value as InternalOrder); + APPLIED_CREDITS.clear(); + for (const creditId of parsed.appliedCreditIds ?? []) { + if (typeof creditId === "string") APPLIED_CREDITS.add(creditId); } if (parsed.recentTrades) { diff --git a/engine/tests/daily-credit.test.ts b/engine/tests/daily-credit.test.ts new file mode 100644 index 0000000..cb61c0a --- /dev/null +++ b/engine/tests/daily-credit.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, expect, test } from "bun:test"; +import { applyCreditHandler } from "../src/handlers/applyCredit"; +import { APPLIED_CREDITS, BALANCES } from "../src/store"; +import { resetState } from "./utils"; + +beforeEach(resetState); + +test("a daily credit is applied only once for the same credit id", async () => { + const payload = { creditId: "daily-credit-1", userId: "3", asset: "USD", amount: "10000" }; + + const first = await applyCreditHandler(payload); + const duplicate = await applyCreditHandler(payload); + + expect(first.applied).toBe(true); + expect(duplicate.applied).toBe(false); + expect(BALANCES["3"]?.USD).toEqual({ available: 10000n, locked: 0n }); + expect(APPLIED_CREDITS.has("daily-credit-1")).toBe(true); +}); + +test("a rejected credit id remains retryable", async () => { + const payload = { creditId: "daily-credit-2", userId: "3", asset: "UNKNOWN", amount: "10000" }; + + await expect(applyCreditHandler(payload)).rejects.toThrow("Unknown asset: UNKNOWN"); + expect(APPLIED_CREDITS.has("daily-credit-2")).toBe(false); +}); diff --git a/engine/tests/utils.ts b/engine/tests/utils.ts index 35df954..14ff1a5 100644 --- a/engine/tests/utils.ts +++ b/engine/tests/utils.ts @@ -1,4 +1,12 @@ -import { ASSETS, BALANCES, ORDERBOOK, ORDERS, ARCHIVED_ORDERS, RECENT_TRADES } from "../src/store"; +import { + APPLIED_CREDITS, + ASSETS, + BALANCES, + ORDERBOOK, + ORDERS, + ARCHIVED_ORDERS, + RECENT_TRADES, +} from "../src/store"; import { createOrderHandler } from "../src/handlers/createOrder"; import { cancelOrderHandler } from "../src/handlers/cancelOrder"; @@ -6,6 +14,7 @@ export function resetState() { for (const symbol of Object.keys(ORDERBOOK)) delete ORDERBOOK[symbol]; for (const symbol of Object.keys(RECENT_TRADES)) delete RECENT_TRADES[symbol]; for (const userId of Object.keys(BALANCES)) delete BALANCES[userId]; + APPLIED_CREDITS.clear(); ASSETS.clear(); ASSETS.add("BTC"); ASSETS.add("USD"); diff --git a/frontend/build.ts b/frontend/build.ts index c126474..11e1ed9 100644 --- a/frontend/build.ts +++ b/frontend/build.ts @@ -137,6 +137,10 @@ const result = await Bun.build({ "process.env.NODE_ENV": JSON.stringify("production"), __API_BASE_URL__: JSON.stringify(process.env.API_BASE_URL ?? "http://localhost:8000"), __WS_URL__: JSON.stringify(process.env.WS_URL ?? "ws://localhost:8000"), + "process.env.BUN_PUBLIC_POSTHOG_HOST": JSON.stringify(process.env.BUN_PUBLIC_POSTHOG_HOST ?? ""), + "process.env.BUN_PUBLIC_POSTHOG_PROJECT_TOKEN": JSON.stringify( + process.env.BUN_PUBLIC_POSTHOG_PROJECT_TOKEN ?? "", + ), }, ...cliConfig, }); diff --git a/frontend/bun.lock b/frontend/bun.lock index b6467e6..bc00a00 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -5,15 +5,18 @@ "": { "name": "bun-react-template", "dependencies": { + "@posthog/react": "^1.11.1", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-query": "^5.103.1", "bun-plugin-tailwind": "^0.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "highlight.js": "^11.12.0", "lightweight-charts": "^5.2.0", "lucide-react": "^1.31.0", + "posthog-js": "^1.434.2", "radix-ui": "^1.6.0", "react": "^19", "react-dom": "^19", @@ -154,6 +157,14 @@ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="], + "@posthog/browser-common": ["@posthog/browser-common@0.9.0", "", { "dependencies": { "@posthog/core": "^1.54.2", "@posthog/types": "^1.412.1" } }, "sha512-EWoVzs1vVY7ihpn8yOjj3jyjriHUEEMVSBNL/TW3hk09fTriWRe76vJXrRg0G0yjTYxHmtlSkNn4mOV24ZEzzA=="], + + "@posthog/core": ["@posthog/core@1.55.0", "", { "dependencies": { "@posthog/types": "^1.412.2" } }, "sha512-x+0FR+uHsK3uJeofd5X4S2YS7hjaWkzkPN0X1GROHm9SvKw2RJ91kfQ7gT1bPtFwZSHNiRtteB7fX/xEMRN0IQ=="], + + "@posthog/react": ["@posthog/react@1.11.1", "", { "peerDependencies": { "@types/react": ">=16.8.0", "posthog-js": ">=1.257.2", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-mHpef0Vp7e0103ePGOWbFDC7v/E0jpMI8OeTCkTCsL2DSV4PP2oIcBSiuxELQPo5MV6yBpGx/hwkWNcbDp65Aw=="], + + "@posthog/types": ["@posthog/types@1.412.2", "", {}, "sha512-taxTh8o6n7ZVlC8qBypZvF+6w7/18yNkS9XDiGCbutGTKqIljfzYi/7pumgwN3mcnl/n0e9A+JhF05+nLd4Xaw=="], + "@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="], @@ -278,6 +289,10 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@tanstack/query-core": ["@tanstack/query-core@5.103.1", "", {}, "sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.103.1", "", { "dependencies": { "@tanstack/query-core": "5.103.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag=="], + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], @@ -300,6 +315,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], @@ -394,6 +411,8 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], @@ -430,6 +449,8 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="], + "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], @@ -492,6 +513,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.4.9", "", {}, "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -810,8 +833,12 @@ "postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="], + "posthog-js": ["posthog-js@1.434.2", "", { "dependencies": { "@posthog/browser-common": "^0.9.0", "@posthog/core": "^1.55.0", "@posthog/types": "^1.412.2", "core-js": "^3.49.0", "dompurify": "^3.4.13", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^6.2.1", "web-vitals-soft-navs": "npm:web-vitals@6.2.1" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-xyvjj88U2Cp1JfDzmifLvNbdiY/z+YbTT1DaRRKRZ+qzaF9IKqMLumpv8jTKGh6y3m4vc3xz0ku8h5Tw0XU9zQ=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -822,6 +849,8 @@ "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "radix-ui": ["radix-ui@1.6.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-accessible-icon": "1.1.10", "@radix-ui/react-accordion": "1.2.14", "@radix-ui/react-alert-dialog": "1.1.17", "@radix-ui/react-arrow": "1.1.10", "@radix-ui/react-aspect-ratio": "1.1.10", "@radix-ui/react-avatar": "1.2.0", "@radix-ui/react-checkbox": "1.3.5", "@radix-ui/react-collapsible": "1.1.14", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-context-menu": "2.3.1", "@radix-ui/react-dialog": "1.1.17", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-dropdown-menu": "2.1.18", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-form": "0.1.10", "@radix-ui/react-hover-card": "1.1.17", "@radix-ui/react-label": "2.1.10", "@radix-ui/react-menu": "2.1.18", "@radix-ui/react-menubar": "1.1.18", "@radix-ui/react-navigation-menu": "1.2.16", "@radix-ui/react-one-time-password-field": "0.1.10", "@radix-ui/react-password-toggle-field": "0.1.5", "@radix-ui/react-popover": "1.1.17", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-progress": "1.1.10", "@radix-ui/react-radio-group": "1.4.1", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-scroll-area": "1.2.12", "@radix-ui/react-select": "2.3.1", "@radix-ui/react-separator": "1.1.10", "@radix-ui/react-slider": "1.4.1", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-switch": "1.3.1", "@radix-ui/react-tabs": "1.1.15", "@radix-ui/react-toast": "1.2.17", "@radix-ui/react-toggle": "1.1.12", "@radix-ui/react-toggle-group": "1.1.13", "@radix-ui/react-toolbar": "1.1.13", "@radix-ui/react-tooltip": "1.2.10", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-escape-keydown": "1.1.2", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6" }, "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-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg=="], @@ -994,6 +1023,10 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "web-vitals": ["web-vitals@6.2.2", "", {}, "sha512-oto5x6dLEgrRqfcWed+pZEUb2q6ikbFmqF54CRDhI/QGbn+qxC49b4C4OkbH+kb9C3a8shpFD3SgR9kRcs80ZQ=="], + + "web-vitals-soft-navs": ["web-vitals@6.2.1", "", {}, "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw=="], + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], diff --git a/frontend/package.json b/frontend/package.json index dc25780..52c3c1e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.1.0", + "version": "1.0.0", "private": true, "type": "module", "scripts": { @@ -9,15 +9,18 @@ "preview": "bunx serve dist -s" }, "dependencies": { + "@posthog/react": "^1.11.1", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-query": "^5.103.1", "bun-plugin-tailwind": "^0.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "highlight.js": "^11.12.0", "lightweight-charts": "^5.2.0", "lucide-react": "^1.31.0", + "posthog-js": "^1.434.2", "radix-ui": "^1.6.0", "react": "^19", "react-dom": "^19", diff --git a/frontend/public/llms.txt b/frontend/public/llms.txt index 075da42..e52ae20 100644 --- a/frontend/public/llms.txt +++ b/frontend/public/llms.txt @@ -15,7 +15,6 @@ PaperDrill runs BTC/USD, ETH/USD, and SOL/USD spot markets with price-time prior ## Developers - [Documentation](https://paperdrill.dev/docs): How PaperDrill works — markets, trading, API overview -- [Changelog](https://paperdrill.dev/changelog): Product updates and release notes - [API](https://api.paperdrill.dev): REST API and WebSocket endpoints (see docs for usage) ## Legal diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt index 6cde9a9..844cfd3 100644 --- a/frontend/public/robots.txt +++ b/frontend/public/robots.txt @@ -3,7 +3,6 @@ Allow: / # Public marketing and docs pages Allow: /docs -Allow: /changelog Allow: /terms Allow: /privacy Allow: /markets diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml index a865c21..9984667 100644 --- a/frontend/public/sitemap.xml +++ b/frontend/public/sitemap.xml @@ -35,11 +35,6 @@ monthly 0.8 - - https://paperdrill.dev/changelog - weekly - 0.6 - https://paperdrill.dev/signup monthly diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 63d4b59..abab24c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,6 @@ import { ProfilePage } from "./pages/profile"; import { TradePage } from "./pages/trade"; import { MarketsPage } from "./pages/markets"; import { DocsPage } from "./pages/docs"; -import { ChangelogPage } from "./pages/changelog"; import { TermsPage } from "./pages/terms"; import { PrivacyPage } from "./pages/privacy"; import { AppLayout } from "./components/app-layout"; @@ -43,7 +42,6 @@ export function App() { } /> } /> - } /> } /> } /> diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 5842312..eb881f4 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -36,7 +36,6 @@ export function AppSidebar(props: ComponentProps) { diff --git a/frontend/src/components/brand-logo.tsx b/frontend/src/components/brand-logo.tsx index 4bca541..b73b51d 100644 --- a/frontend/src/components/brand-logo.tsx +++ b/frontend/src/components/brand-logo.tsx @@ -6,7 +6,6 @@ import { cn } from "@/lib/utils"; interface BrandLogoProps { href?: string; showText?: boolean; - showBeta?: boolean; className?: string; imageClassName?: string; } @@ -14,7 +13,6 @@ interface BrandLogoProps { export function BrandLogo({ href = "/", showText = true, - showBeta = true, className, imageClassName, }: BrandLogoProps) { @@ -32,14 +30,7 @@ export function BrandLogo({ decoding="async" /> {showText && ( - - PaperDrill - {showBeta && ( - - Beta - - )} - + PaperDrill )} ); diff --git a/frontend/src/components/chart/chart.tsx b/frontend/src/components/chart/chart.tsx index 1008d25..5b00c1c 100644 --- a/frontend/src/components/chart/chart.tsx +++ b/frontend/src/components/chart/chart.tsx @@ -1,3 +1,4 @@ +import { Activity, CircleAlert } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { Candle, OrderBook, Ticker } from "@/types"; import { type CandleInterval, useCandles } from "../../hooks/use-candles"; @@ -82,7 +83,7 @@ export function Chart({ symbol, orderbook, ticker }: ChartProps) { return (
-
+
{TABS.map((item) => (
diff --git a/frontend/src/components/landing/hero.tsx b/frontend/src/components/landing/hero.tsx index a8e4d29..e64a8f7 100644 --- a/frontend/src/components/landing/hero.tsx +++ b/frontend/src/components/landing/hero.tsx @@ -16,7 +16,7 @@ export function Hero() {

A live, always-on exchange with a real matching engine and order book. Trade on the UI or - connect a bot via API - no KYC, no real money, no risk. + connect a bot via API - no KYC and no real funds at risk.

diff --git a/frontend/src/components/landing/navbar.tsx b/frontend/src/components/landing/navbar.tsx index 985844a..74e3421 100644 --- a/frontend/src/components/landing/navbar.tsx +++ b/frontend/src/components/landing/navbar.tsx @@ -10,7 +10,6 @@ const LINKS = [ { label: "Trading", to: "/trade/BTC_USD" }, { label: "Leaderboard", to: "/leaderboard" }, { label: "Docs", to: "/docs" }, - { label: "Changelog", to: "/changelog" }, ]; export function Navbar() { @@ -63,7 +62,7 @@ export function Navbar() {
- + diff --git a/frontend/src/components/login-form.tsx b/frontend/src/components/login-form.tsx index 0ca4a22..81abcf5 100644 --- a/frontend/src/components/login-form.tsx +++ b/frontend/src/components/login-form.tsx @@ -7,6 +7,7 @@ import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui import { Input } from "@/components/ui/input"; import { useAuth } from "@/context/AuthContext"; import { api } from "@/lib/api"; +import { useAnalytics } from "@/lib/analytics"; import { toast } from "sonner"; export function LoginForm({ className, ...props }: React.ComponentProps<"div">) { @@ -14,6 +15,7 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"div">) const [password, setPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); const { setUser } = useAuth(); + const posthog = useAnalytics(); const navigate = useNavigate(); const handleSubmit = async (e: React.SubmitEvent) => { @@ -27,6 +29,7 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"div">) try { const user = await api.signin(email.trim(), password); setUser(user); + posthog.capture("user_signed_in", { email_verified: user.emailVerified }); toast.success("Signed in successfully"); navigate(user.emailVerified ? "/dashboard" : "/verify-email"); } catch (err) { diff --git a/frontend/src/components/market/data-panel.tsx b/frontend/src/components/market/data-panel.tsx index 45de7be..52a5a2a 100644 --- a/frontend/src/components/market/data-panel.tsx +++ b/frontend/src/components/market/data-panel.tsx @@ -5,6 +5,9 @@ import { ArrowUpDown, ChevronLeft, ChevronRight, + CircleAlert, + Inbox, + LockKeyhole, Search, SlidersHorizontal, X, @@ -14,8 +17,13 @@ import { toast } from "sonner"; import type { OrderRecord, UserBalance, UserTrade } from "@/types"; import { useAuth } from "@/context/AuthContext"; import { useMarkets } from "@/context/MarketContext"; +import { + useCancelOrder, + useOpenOrders, + useOrderHistory, + useTradeHistory, +} from "@/hooks/use-account"; import { useBalance } from "@/hooks/use-balance"; -import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; import { formatDateTime, formatPrice, formatQty } from "@/utils/format"; import { AssetIcon } from "../icons/asset-icon"; @@ -44,11 +52,11 @@ type SortDirection = "asc" | "desc"; type DataPanelProps = { loading?: boolean; - refreshKey?: number; symbol?: string; }; const PAGE_SIZE = 10; +const ORDER_HISTORY_PARAMS = { limit: 100 } as const; function titleCase(value: string) { return value @@ -153,8 +161,26 @@ function TableLoading({ columns }: { columns: number }) { function EmptyState({ children }: { children: string }) { return ( -
- {children} +
+
+ +
+

{children}

+

+ There is nothing to show for this view yet. +

+
+ ); +} + +function ErrorState({ message }: { message: string }) { + return ( +
+
+ +
+

Could not load account data

+

{message}

); } @@ -205,15 +231,10 @@ function Pagination({ ); } -export function DataPanel({ loading = false, refreshKey, symbol }: DataPanelProps) { +export function DataPanel({ loading = false, symbol }: DataPanelProps) { const { authenticated, verified } = useAuth(); const { markets } = useMarkets(); const [tab, setTab] = useState("open"); - const [openOrders, setOpenOrders] = useState([]); - const [orders, setOrders] = useState([]); - const [trades, setTrades] = useState([]); - const [fetching, setFetching] = useState(true); - const [cancelling, setCancelling] = useState(null); const [search, setSearch] = useState(""); const [currentMarketOnly, setCurrentMarketOnly] = useState(Boolean(symbol)); const [marketFilter, setMarketFilter] = useState("all"); @@ -228,29 +249,39 @@ export function DataPanel({ loading = false, refreshKey, symbol }: DataPanelProp loading: balanceLoading, error: balanceError, } = useBalance({ enabled: authenticated }); - - const fetchData = useCallback(async () => { - setFetching(true); - try { - const [open, orderHistory, tradeHistory] = await Promise.all([ - api.getOpenOrders(), - api.getOrders({ limit: 100 }), - api.getTradeHistory(100), - ]); - setOpenOrders(open); - setOrders(orderHistory); - setTrades(tradeHistory); - } catch (error) { - console.error("Failed to load account data:", error); - toast.error("Failed to load account data"); - } finally { - setFetching(false); - } - }, []); + const { + openOrders, + loading: openOrdersLoading, + error: openOrdersError, + } = useOpenOrders({ enabled: authenticated }); + const { + orders, + loading: ordersLoading, + error: ordersError, + } = useOrderHistory(ORDER_HISTORY_PARAMS, { enabled: authenticated }); + const { + trades, + loading: tradesLoading, + error: tradesError, + } = useTradeHistory(100, { enabled: authenticated }); + const cancelOrder = useCancelOrder(); + const cancelling = cancelOrder.isPending ? (cancelOrder.variables ?? null) : null; + const fetching = openOrdersLoading || ordersLoading || tradesLoading; + const accountDataError = balanceError ?? openOrdersError ?? ordersError ?? tradesError; + const activeDataError = + tab === "balance" + ? balanceError + : tab === "open" + ? openOrdersError + : tab === "orders" + ? ordersError + : tradesError; useEffect(() => { - if (authenticated) fetchData(); - }, [authenticated, refreshKey, fetchData]); + if (!accountDataError) return; + console.error("Failed to load account data:", accountDataError); + toast.error("Failed to load account data"); + }, [accountDataError]); useEffect(() => { setPage(1); @@ -357,15 +388,11 @@ export function DataPanel({ loading = false, refreshKey, symbol }: DataPanelProp const handleCancel = async (orderId: string) => { if (!verified) return; - setCancelling(orderId); try { - await api.cancelOrder(orderId); + await cancelOrder.mutateAsync(orderId); toast.success("Order cancelled"); - await fetchData(); } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to cancel order"); - } finally { - setCancelling(null); } }; @@ -386,24 +413,30 @@ export function DataPanel({ loading = false, refreshKey, symbol }: DataPanelProp if (!authenticated) { return ( -
- Please  - - sign in - -  or  - - sign up - -  to view account data. +
+
+ +
+
+

Your account activity

+

Sign in to view balances and orders.

+
+
+ + +
); } return ( -
+
-
+
{tabs.map((item) => (