From c54cb6426017eb163344ed05b1c0b3838b130980 Mon Sep 17 00:00:00 2001 From: GideonBature Date: Sun, 19 Jul 2026 05:59:39 +0100 Subject: [PATCH] feat(graphql): add query engine for markets, positions, trades, and users Introduce a GraphQL API with DataLoader-backed resolvers, per-user rate limits, query complexity enforcement, and Redis-backed subscriptions for live market data. Schema is versioned with deprecated snake_case aliases for backward-compatible clients. Refs #83 --- backend/api.ts | 28 +- backend/graphql/README.md | 5 + backend/package-lock.json | 178 +++- backend/package.json | 6 + backend/src/config/db.ts | 5 + backend/src/config/env.ts | 57 ++ backend/src/config/redis.ts | 44 + backend/src/graphql/SCHEMA.md | 147 ++++ backend/src/graphql/complexity.ts | 132 +++ backend/src/graphql/context.ts | 26 + backend/src/graphql/dataloaders.ts | 81 ++ backend/src/graphql/execute.ts | 57 ++ backend/src/graphql/index.ts | 168 ++++ backend/src/graphql/mappers.ts | 160 ++++ backend/src/graphql/pagination.ts | 16 + backend/src/graphql/pubsub.ts | 152 ++++ backend/src/graphql/rateLimit.ts | 44 + backend/src/graphql/resolvers.ts | 421 ++++++++++ backend/src/graphql/schema.ts | 8 + backend/src/graphql/typeDefs.ts | 250 ++++++ backend/src/graphql/version.ts | 13 + backend/src/index.ts | 68 ++ backend/src/middleware/error.middleware.ts | 46 + .../src/middleware/rate-limit.middleware.ts | 33 + backend/src/models/Bet.ts | 26 + backend/src/models/Market.ts | 71 ++ backend/src/services/MarketService.ts | 785 ++++++++++++++++++ backend/src/services/StellarService.ts | 434 ++++++++++ backend/src/services/cache.service.ts | 110 +++ backend/src/services/metrics.service.ts | 40 + backend/src/utils/AppError.ts | 36 + backend/src/utils/__mocks__/logger.ts | 6 + backend/src/utils/logger.ts | 10 + backend/src/websocket/realtime.ts | 260 ++++++ backend/tests/graphql/complexity.test.ts | 76 ++ backend/tests/graphql/dataloader.test.ts | 107 +++ backend/tests/graphql/query.test.ts | 164 ++++ backend/tests/graphql/rate-limit.test.ts | 47 ++ backend/tests/graphql/subscription.test.ts | 38 + .../graphql-subscription.integration.test.ts | 66 ++ 40 files changed, 4388 insertions(+), 33 deletions(-) create mode 100644 backend/graphql/README.md create mode 100644 backend/src/config/db.ts create mode 100644 backend/src/config/env.ts create mode 100644 backend/src/config/redis.ts create mode 100644 backend/src/graphql/SCHEMA.md create mode 100644 backend/src/graphql/complexity.ts create mode 100644 backend/src/graphql/context.ts create mode 100644 backend/src/graphql/dataloaders.ts create mode 100644 backend/src/graphql/execute.ts create mode 100644 backend/src/graphql/index.ts create mode 100644 backend/src/graphql/mappers.ts create mode 100644 backend/src/graphql/pagination.ts create mode 100644 backend/src/graphql/pubsub.ts create mode 100644 backend/src/graphql/rateLimit.ts create mode 100644 backend/src/graphql/resolvers.ts create mode 100644 backend/src/graphql/schema.ts create mode 100644 backend/src/graphql/typeDefs.ts create mode 100644 backend/src/graphql/version.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/middleware/error.middleware.ts create mode 100644 backend/src/middleware/rate-limit.middleware.ts create mode 100644 backend/src/models/Bet.ts create mode 100644 backend/src/models/Market.ts create mode 100644 backend/src/services/MarketService.ts create mode 100644 backend/src/services/StellarService.ts create mode 100644 backend/src/services/cache.service.ts create mode 100644 backend/src/services/metrics.service.ts create mode 100644 backend/src/utils/AppError.ts create mode 100644 backend/src/utils/__mocks__/logger.ts create mode 100644 backend/src/utils/logger.ts create mode 100644 backend/src/websocket/realtime.ts create mode 100644 backend/tests/graphql/complexity.test.ts create mode 100644 backend/tests/graphql/dataloader.test.ts create mode 100644 backend/tests/graphql/query.test.ts create mode 100644 backend/tests/graphql/rate-limit.test.ts create mode 100644 backend/tests/graphql/subscription.test.ts create mode 100644 backend/tests/integration/graphql-subscription.integration.test.ts diff --git a/backend/api.ts b/backend/api.ts index 6dc70788..e5c4b7a9 100644 --- a/backend/api.ts +++ b/backend/api.ts @@ -1,25 +1,21 @@ /** * @file api.ts * @description Backend API for prediction market + * + * GraphQL query engine: `src/graphql/` + * - Schema + docs: `src/graphql/typeDefs.ts`, `src/graphql/SCHEMA.md` + * - DataLoaders: `src/graphql/dataloaders.ts` + * - Complexity / rate limits: `src/graphql/complexity.ts`, `src/graphql/rateLimit.ts` + * - Subscriptions (Redis): `src/graphql/pubsub.ts` + * - Mount helper: `mountGraphQL` from `src/graphql` + * + * Wired from `src/index.ts` at `POST /graphql` (subscriptions on `ws:///graphql`). */ /** - * Initialize and manage prediction market API endpoints - * TODO: Setup Express/Fastify server - * TODO: Create market endpoints (GET, POST, PUT, DELETE) - * TODO: Implement user authentication and authorization - * TODO: Create bet submission endpoints - * TODO: Add market resolution endpoints - * TODO: Implement dispute handling API - * TODO: Create oracle price feed integration - * TODO: Add websocket for real-time updates - * TODO: Implement pagination for market listings - * TODO: Add filtering and sorting for markets - * TODO: Create balance and portfolio endpoints - * TODO: Add analytics and history endpoints - * TODO: Implement error handling and validation middleware - * TODO: Setup database connection and migrations + * Initialize and manage prediction market API endpoints. + * Prefer `mountGraphQL(app, server)` from `./src/graphql` (used by `src/index.ts`). */ export function initializePredictionMarketAPI(): void { - // Blueprint for API initialization + // GraphQL is mounted in src/index.ts via mountGraphQL(). } diff --git a/backend/graphql/README.md b/backend/graphql/README.md new file mode 100644 index 00000000..2713dc79 --- /dev/null +++ b/backend/graphql/README.md @@ -0,0 +1,5 @@ +# GraphQL Query Engine + +Implementation lives in [`../src/graphql/`](../src/graphql/). + +See [`../src/graphql/SCHEMA.md`](../src/graphql/SCHEMA.md) for the full schema reference. diff --git a/backend/package-lock.json b/backend/package-lock.json index 03dd38a5..db3284ab 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,13 +8,19 @@ "name": "boxmeout-backend", "version": "0.1.0", "dependencies": { + "@graphql-tools/schema": "^10.0.38", "@sentry/node": "8.55.0", "@types/bcrypt": "^6.0.0", "@types/nodemailer": "^8.0.0", "bcrypt": "^6.0.0", + "dataloader": "^2.2.3", "drizzle-kit": "^0.20.18", "drizzle-orm": "^0.30.10", "express": "^4.19.2", + "graphql": "^16.14.2", + "graphql-http": "^1.22.4", + "graphql-subscriptions": "^3.0.0", + "graphql-ws": "^6.1.0", "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", "node-cron": "^3.0.3", @@ -135,7 +141,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1541,6 +1546,66 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@graphql-tools/merge": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.2.tgz", + "integrity": "sha512-DSLLAztOIQId7QE3m8Ehk5lV+0pjxNSSRDHPzlYQ9E4KJ9AoUMBprC4C+eX3v4srh05S2ujm5/veqAr5yEWFSQ==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.2.2", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema": { + "version": "10.0.38", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.38.tgz", + "integrity": "sha512-Kckk2/vm+rELJ7ijvFaAn9ouWSVUTD0D4SJIvYF4rKeuQHAfCzE/jzMFonZVpTXleqJjPXLZjVbuaMorw7A5Og==", + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.2.2", + "@graphql-tools/utils": "^11.2.2", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz", + "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -2168,7 +2233,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2190,7 +2254,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" }, @@ -2227,7 +2290,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", @@ -2778,7 +2840,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -3282,7 +3343,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3585,7 +3645,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -3746,6 +3805,18 @@ "dev": true, "license": "ISC" }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3764,7 +3835,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4274,7 +4344,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4810,6 +4879,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4838,6 +4919,12 @@ "node": ">=0.12" } }, + "node_modules/dataloader": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", + "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", + "license": "MIT" + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -5179,7 +5266,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5510,7 +5596,6 @@ "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5591,7 +5676,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6511,6 +6595,65 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-http": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/graphql-http/-/graphql-http-1.22.4.tgz", + "integrity": "sha512-OC3ucK988teMf+Ak/O+ZJ0N2ukcgrEurypp8ePyJFWq83VzwRAmHxxr+XxrMpxO/FIwI4a7m/Fzv3tWGJv0wPA==", + "license": "MIT", + "workspaces": [ + "implementations/**/*" + ], + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, + "node_modules/graphql-subscriptions": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-3.0.0.tgz", + "integrity": "sha512-kZCdevgmzDjGAOqH7GlDmQXYAkuHoKpMlJrqF40HMPhUhM5ZWSFSxCwD/nSi6AkaijmMfsFhoJRGJ27UseCvRA==", + "license": "MIT", + "peerDependencies": { + "graphql": "^15.7.2 || ^16.0.0" + } + }, + "node_modules/graphql-ws": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.1.0.tgz", + "integrity": "sha512-7ft6KWkuaLnLABwzEIimjUMeF0iByo2ThD6q0MICgsvp6nDuT5ppubKzEHniu8Kmlp5GNsLgr5dil8JMrIwUEQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fastify/websocket": "^10 || ^11", + "crossws": "~0.3", + "graphql": "^15.10.1 || ^16 || ^17", + "ws": "^8" + }, + "peerDependenciesMeta": { + "@fastify/websocket": { + "optional": true + }, + "crossws": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -6624,7 +6767,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -7094,7 +7236,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8725,7 +8866,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -10562,7 +10702,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10683,6 +10822,12 @@ "node": ">=0.10.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", @@ -10759,7 +10904,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend/package.json b/backend/package.json index c8c10a3b..7f3a1e38 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,13 +14,19 @@ "test:integration": "jest --passWithNoTests --testPathPatterns=tests/integration" }, "dependencies": { + "@graphql-tools/schema": "^10.0.38", "@sentry/node": "8.55.0", "@types/bcrypt": "^6.0.0", "@types/nodemailer": "^8.0.0", "bcrypt": "^6.0.0", + "dataloader": "^2.2.3", "drizzle-kit": "^0.20.18", "drizzle-orm": "^0.30.10", "express": "^4.19.2", + "graphql": "^16.14.2", + "graphql-http": "^1.22.4", + "graphql-subscriptions": "^3.0.0", + "graphql-ws": "^6.1.0", "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", "node-cron": "^3.0.3", diff --git a/backend/src/config/db.ts b/backend/src/config/db.ts new file mode 100644 index 00000000..6d649b58 --- /dev/null +++ b/backend/src/config/db.ts @@ -0,0 +1,5 @@ +import { Pool } from 'pg'; + +const DATABASE_URL = process.env.DATABASE_URL ?? 'postgresql://boxmeout:boxmeout@localhost:5432/boxmeout'; + +export const pool = new Pool({ connectionString: DATABASE_URL }); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 00000000..6df7eb5a --- /dev/null +++ b/backend/src/config/env.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; +import { logger } from '../utils/logger'; + +const envSchema = z.object({ + DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL'), + REDIS_URL: z.string().url('REDIS_URL must be a valid URL'), + STELLAR_RPC_URL: z.string().url('STELLAR_RPC_URL must be a valid URL'), + ORACLE_KEYPAIR: z.string().min(1, 'ORACLE_KEYPAIR is required'), + ADMIN_JWT_SECRET: z.string().min(1, 'ADMIN_JWT_SECRET is required'), + FACTORY_CONTRACT_ADDRESS: z.string().min(1, 'FACTORY_CONTRACT_ADDRESS is required'), + PORT: z.coerce.number().int().positive().default(3000), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + JWT_SECRET: z.string().min(1).default('change-me-in-production'), + STELLAR_NETWORK: z.string().default('testnet'), + HORIZON_URL: z.string().url().optional(), + ORACLE_PUBLIC_KEY: z.string().optional(), + ADMIN_PUBLIC_KEY: z.string().optional(), + ORACLE_API_KEY: z.string().optional(), + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), + ENABLE_SWAGGER: z.coerce.boolean().default(false), + GENESIS_LEDGER: z.coerce.number().int().positive().default(100000), + POLL_INTERVAL_MS: z.coerce.number().int().positive().default(5000), + BOXING_API_URL: z.string().url().optional(), + SENTRY_DSN: z.string().url().optional(), + WS_BUFFER_THRESHOLD_BYTES: z.coerce.number().int().positive().default(16384), +}); + +export type Env = z.infer; + +let validatedEnv: Env | null = null; + +export function validateEnv(): Env { + if (validatedEnv) return validatedEnv; + + const result = envSchema.safeParse(process.env); + + if (!result.success) { + const errors = result.error.issues.map(issue => { + const path = issue.path.join('.'); + return `${path}: ${issue.message}`; + }); + logger.error('Environment validation failed:'); + errors.forEach(err => logger.error(` - ${err}`)); + process.exit(1); + } + + validatedEnv = result.data; + logger.info('Environment variables validated successfully'); + return validatedEnv; +} + +export function getEnv(): Env { + if (!validatedEnv) { + throw new Error('Environment not validated. Call validateEnv() first.'); + } + return validatedEnv; +} diff --git a/backend/src/config/redis.ts b/backend/src/config/redis.ts new file mode 100644 index 00000000..c8cb46bb --- /dev/null +++ b/backend/src/config/redis.ts @@ -0,0 +1,44 @@ +import Redis from 'ioredis'; + +const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379'; + +export const redis = new Redis(REDIS_URL, { lazyConnect: true }); + +/** Dedicated subscriber client — must be separate from `redis` for pub/sub. */ +export const redisSub = new Redis(REDIS_URL, { lazyConnect: true }); + +export const MARKET_EVENTS_PATTERN = 'market:*:events'; + +export function marketEventChannel(marketId: string): string { + return `market:${marketId}:events`; +} + +export function parseMarketIdFromChannel(channel: string): string | null { + const match = /^market:(.+):events$/.exec(channel); + return match ? match[1] : null; +} + +export async function connectRedisClients(): Promise { + if (redis.status === 'wait') { + await redis.connect(); + } + if (redisSub.status === 'wait') { + await redisSub.connect(); + } +} + +export async function closeRedisClients(): Promise { + await Promise.allSettled([ + redis.status !== 'end' ? redis.quit() : undefined, + redisSub.status !== 'end' ? redisSub.quit() : undefined, + ]); +} + +export function disconnectRedisClients(): void { + if (redis.status !== 'end') { + redis.disconnect(); + } + if (redisSub.status !== 'end') { + redisSub.disconnect(); + } +} diff --git a/backend/src/graphql/SCHEMA.md b/backend/src/graphql/SCHEMA.md new file mode 100644 index 00000000..ce4aa2f0 --- /dev/null +++ b/backend/src/graphql/SCHEMA.md @@ -0,0 +1,147 @@ +# BoxMeOut GraphQL Schema + +**Version:** `1.0.0` +**Min compatible client version:** `1.0.0` +**Endpoint:** `POST /graphql` +**Subscriptions:** `ws:///graphql` (graphql-ws protocol) + +This document is the canonical reference for the market-data GraphQL API. +Query `schemaInfo` at runtime for the live version and deprecated field list. + +## Design principles + +- **Read model** for markets, bets, aggregated positions, trades, and wallet users +- **Backward compatible** additive evolution; snake_case aliases marked `@deprecated` +- **N+1 safe** via request-scoped DataLoaders +- **Abuse protected** via per-IP Express limits, per-user Redis limits, and query complexity/depth caps +- **Live data** via Redis `market:{id}:events` fan-out (target delivery < 500ms) + +## Root operations + +### Query + +| Field | Args | Returns | Notes | +|-------|------|---------|-------| +| `schemaInfo` | — | `SchemaInfo!` | Versioning / deprecation metadata | +| `market` | `id: ID!` | `Market` | `null` when missing | +| `markets` | `filter`, `sort`, `page`, `limit`, `first`, `after` | `MarketConnection!` | Any filter combination; offset or cursor pagination | +| `bets` | `marketId`, `bettorAddress`, `limit`, `offset` | `[Bet!]!` | | +| `positions` | `marketId`, `ownerAddress`, `limit`, `offset` | `[Position!]!` | Aggregated bets by owner+side | +| `trades` | `marketId`, `traderAddress`, `limit`, `offset` | `[Trade!]!` | Bet rows as trade fills | +| `user` | `address: ID!` | `User` | Address-centric projection | +| `portfolio` | `address: ID!` | `Portfolio!` | | +| `platformStats` | — | `PlatformStats!` | | + +### Subscription + +| Field | Args | Returns | Source | +|-------|------|---------|--------| +| `marketActivity` | `marketId` | `ActivityPayload!` | Redis activity bus | +| `marketUpdated` | `marketId` | `Market!` | Derived from activity events | +| `tradeCreated` | `marketId` | `Trade!` | `type: trade` events | + +## Filters (`MarketFilterInput`) + +All fields optional; combined with AND: + +- `status` — `open \| locked \| resolved \| cancelled \| disputed` +- `weightClass` — exact match +- `fighter` — case-insensitive substring on fighter A/B +- `dateFrom` / `dateTo` — ISO-8601 on `scheduledAt` +- `titleFight` — boolean +- `venue` — case-insensitive substring +- `marketIds` — explicit allow-list + +## Pagination + +1. **Offset:** `page` + `limit` (default 1 / 50, max 100) +2. **Cursor:** `first` + `after` (Relay-style connection). Cursor encodes `scheduledAt|marketId`. + +## Complexity limits + +| Limit | Default | +|-------|---------| +| Max depth | 10 | +| Max complexity | 1000 | + +List fields (`markets`, `bets`, `positions`, `trades`, `edges`, portfolio bet lists) multiply nested cost by 10. +Introspection is exempt. + +Errors: + +- `QUERY_DEPTH_EXCEEDED` +- `QUERY_COMPLEXITY_EXCEEDED` + +## Rate limiting + +| Layer | Key | Default | +|-------|-----|---------| +| Express middleware | IP | 60 req / 60s on `/graphql` | +| GraphQL executor | `x-user-id` or IP | 60 ops / 60s | + +Over limit → HTTP `429` + GraphQL error `RATE_LIMITED` + `Retry-After`. + +## Deprecated fields (removed in v2) + +| Field | Replacement | +|-------|-------------| +| `Market.market_id` | `Market.marketId` | +| `MarketOdds.odds_a` | `MarketOdds.oddsA` | +| `MarketOdds.odds_b` | `MarketOdds.oddsB` | +| `MarketOdds.odds_draw` | `MarketOdds.oddsDraw` | + +## Example queries + +### Filtered markets + +```graphql +query OpenHeavyweights { + markets( + filter: { status: open, weightClass: "heavyweight", fighter: "Ali" } + sort: { field: SCHEDULED_AT, direction: ASC } + first: 10 + ) { + pageInfo { totalCount hasNextPage endCursor } + edges { + node { + marketId + fighterA + fighterB + odds { oddsA oddsB oddsDraw } + positions { ownerAddress side totalAmountXlm } + } + } + } +} +``` + +### Nested user portfolio (DataLoader-batched) + +```graphql +query Portfolio($address: ID!) { + user(address: $address) { + address + positions { marketId side totalAmountXlm market { fighterA fighterB status } } + portfolio { totalStakedXlm pendingClaims { id amountXlm } } + } +} +``` + +### Subscription + +```graphql +subscription OnTrades($marketId: ID!) { + tradeCreated(marketId: $marketId) { + id + side + amountXlm + priceBps + executedAt + } +} +``` + +## Out of scope + +- REST → GraphQL migration +- Apollo Federation / supergraph diff --git a/backend/src/graphql/complexity.ts b/backend/src/graphql/complexity.ts new file mode 100644 index 00000000..cfa1aa5e --- /dev/null +++ b/backend/src/graphql/complexity.ts @@ -0,0 +1,132 @@ +import { + GraphQLError, + Kind, + type DocumentNode, + type FieldNode, + type FragmentDefinitionNode, + type OperationDefinitionNode, + type SelectionSetNode, +} from 'graphql'; + +export const DEFAULT_MAX_COMPLEXITY = 1_000; +export const DEFAULT_MAX_DEPTH = 10; + +const LIST_MULTIPLIER = 10; + +export interface ComplexityOptions { + maxComplexity?: number; + maxDepth?: number; +} + +/** + * Lightweight query complexity / depth guard. + * Each field costs 1; list-shaped fields multiply child cost by LIST_MULTIPLIER. + */ +export function assertQueryComplexity( + document: DocumentNode, + options: ComplexityOptions = {}, +): { complexity: number; depth: number } { + const maxComplexity = options.maxComplexity ?? DEFAULT_MAX_COMPLEXITY; + const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; + + const fragments = new Map(); + for (const def of document.definitions) { + if (def.kind === Kind.FRAGMENT_DEFINITION) { + fragments.set(def.name.value, def); + } + } + + let complexity = 0; + let depth = 0; + + for (const def of document.definitions) { + if (def.kind !== Kind.OPERATION_DEFINITION) continue; + const result = scoreSelection(def.selectionSet, fragments, 1, 1); + complexity += result.complexity; + depth = Math.max(depth, result.depth); + } + + if (depth > maxDepth) { + throw new GraphQLError(`Query depth ${depth} exceeds maximum of ${maxDepth}`, { + extensions: { code: 'QUERY_DEPTH_EXCEEDED', depth, maxDepth }, + }); + } + + if (complexity > maxComplexity) { + throw new GraphQLError( + `Query complexity ${complexity} exceeds maximum of ${maxComplexity}`, + { + extensions: { code: 'QUERY_COMPLEXITY_EXCEEDED', complexity, maxComplexity }, + }, + ); + } + + return { complexity, depth }; +} + +function scoreSelection( + selectionSet: SelectionSetNode, + fragments: Map, + depth: number, + multiplier: number, +): { complexity: number; depth: number } { + let complexity = 0; + let maxDepth = depth; + + for (const selection of selectionSet.selections) { + if (selection.kind === Kind.FIELD) { + const field = selection as FieldNode; + if (field.name.value === '__typename') continue; + + complexity += 1 * multiplier; + const childMultiplier = isListField(field) ? multiplier * LIST_MULTIPLIER : multiplier; + + if (field.selectionSet) { + const child = scoreSelection(field.selectionSet, fragments, depth + 1, childMultiplier); + complexity += child.complexity; + maxDepth = Math.max(maxDepth, child.depth); + } + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const frag = fragments.get(selection.name.value); + if (frag) { + const child = scoreSelection(frag.selectionSet, fragments, depth, multiplier); + complexity += child.complexity; + maxDepth = Math.max(maxDepth, child.depth); + } + } else if (selection.kind === Kind.INLINE_FRAGMENT && selection.selectionSet) { + const child = scoreSelection(selection.selectionSet, fragments, depth, multiplier); + complexity += child.complexity; + maxDepth = Math.max(maxDepth, child.depth); + } + } + + return { complexity, depth: maxDepth }; +} + +function isListField(field: FieldNode): boolean { + const name = field.name.value; + return ( + name === 'markets' || + name === 'bets' || + name === 'positions' || + name === 'trades' || + name === 'edges' || + name === 'activeBets' || + name === 'pastBets' || + name === 'pendingClaims' + ); +} + +/** Introspection / schema probe ops are exempt from complexity limits. */ +export function isIntrospectionDocument(document: DocumentNode): boolean { + for (const def of document.definitions) { + if (def.kind !== Kind.OPERATION_DEFINITION) continue; + const op = def as OperationDefinitionNode; + for (const sel of op.selectionSet.selections) { + if (sel.kind === Kind.FIELD && (sel.name.value === '__schema' || sel.name.value === '__type')) { + return true; + } + } + } + return false; +} diff --git a/backend/src/graphql/context.ts b/backend/src/graphql/context.ts new file mode 100644 index 00000000..faa49517 --- /dev/null +++ b/backend/src/graphql/context.ts @@ -0,0 +1,26 @@ +import type { Request } from 'express'; +import { createLoaders, type GraphQLLoaders } from './dataloaders'; + +export interface GraphQLContext { + loaders: GraphQLLoaders; + userId: string | null; + clientIp: string; + identity: string; +} + +export function buildContext(req?: Request): GraphQLContext { + const userId = + (req as Request & { user?: { id?: string } } | undefined)?.user?.id ?? + (typeof req?.header === 'function' ? req.header('x-user-id') : null) ?? + null; + + const clientIp = req?.ip ?? req?.socket?.remoteAddress ?? 'anonymous'; + const identity = userId ?? clientIp; + + return { + loaders: createLoaders(), + userId, + clientIp, + identity, + }; +} diff --git a/backend/src/graphql/dataloaders.ts b/backend/src/graphql/dataloaders.ts new file mode 100644 index 00000000..74248901 --- /dev/null +++ b/backend/src/graphql/dataloaders.ts @@ -0,0 +1,81 @@ +import DataLoader from 'dataloader'; +import type { Bet } from '../models/Bet'; +import type { Market } from '../models/Market'; +import { + getBetsByAddress, + getBetsByMarket, + getMarketById, + getMarketOdds, + getMarketStats, + getPortfolioByAddress, + type MarketOdds, + type Portfolio, +} from '../services/MarketService'; +import type { MarketStats } from '../models/Market'; + +export interface GraphQLLoaders { + marketById: DataLoader; + oddsByMarketId: DataLoader; + statsByMarketId: DataLoader; + betsByMarketId: DataLoader; + betsByAddress: DataLoader; + portfolioByAddress: DataLoader; +} + +/** + * Request-scoped DataLoaders — one set per GraphQL operation to batch/cache + * nested market → bets → user lookups and eliminate N+1 queries. + */ +export function createLoaders(): GraphQLLoaders { + return { + marketById: new DataLoader(async (ids) => { + const results = await Promise.all( + ids.map(async (id) => { + try { + const market = await getMarketById(id); + return market; + } catch { + return null; + } + }), + ); + return results; + }), + + oddsByMarketId: new DataLoader(async (ids) => { + return Promise.all( + ids.map(async (id) => { + try { + return await getMarketOdds(id); + } catch { + return null; + } + }), + ); + }), + + statsByMarketId: new DataLoader(async (ids) => { + return Promise.all( + ids.map(async (id) => { + try { + return await getMarketStats(id); + } catch { + return null; + } + }), + ); + }), + + betsByMarketId: new DataLoader(async (ids) => { + return Promise.all(ids.map((id) => getBetsByMarket(id))); + }), + + betsByAddress: new DataLoader(async (addresses) => { + return Promise.all(addresses.map((address) => getBetsByAddress(address))); + }), + + portfolioByAddress: new DataLoader(async (addresses) => { + return Promise.all(addresses.map((address) => getPortfolioByAddress(address))); + }), + }; +} diff --git a/backend/src/graphql/execute.ts b/backend/src/graphql/execute.ts new file mode 100644 index 00000000..072ac87e --- /dev/null +++ b/backend/src/graphql/execute.ts @@ -0,0 +1,57 @@ +import { + execute, + parse, + validate, + type ExecutionResult, + type GraphQLSchema, +} from 'graphql'; +import { assertQueryComplexity, isIntrospectionDocument } from './complexity'; +import { buildContext, type GraphQLContext } from './context'; +import { assertGraphQLRateLimit } from './rateLimit'; +import type { Request } from 'express'; + +export interface ExecuteGraphQLOptions { + schema: GraphQLSchema; + query: string; + variables?: Record | null; + operationName?: string | null; + req?: Request; + context?: GraphQLContext; + skipRateLimit?: boolean; + maxComplexity?: number; + maxDepth?: number; +} + +/** + * Parse → rate-limit → complexity → validate → execute for a GraphQL HTTP request. + */ +export async function executeGraphQL( + options: ExecuteGraphQLOptions, +): Promise { + const document = parse(options.query); + const context = options.context ?? buildContext(options.req); + + if (!options.skipRateLimit && !isIntrospectionDocument(document)) { + await assertGraphQLRateLimit(context.identity); + } + + if (!isIntrospectionDocument(document)) { + assertQueryComplexity(document, { + maxComplexity: options.maxComplexity, + maxDepth: options.maxDepth, + }); + } + + const errors = validate(options.schema, document); + if (errors.length > 0) { + return { errors }; + } + + return execute({ + schema: options.schema, + document, + variableValues: options.variables ?? undefined, + operationName: options.operationName ?? undefined, + contextValue: context, + }); +} diff --git a/backend/src/graphql/index.ts b/backend/src/graphql/index.ts new file mode 100644 index 00000000..3a0364c9 --- /dev/null +++ b/backend/src/graphql/index.ts @@ -0,0 +1,168 @@ +import type { Server } from 'http'; +import type { Express, NextFunction, Request, Response } from 'express'; +import { GraphQLError, parse } from 'graphql'; +// Deep import — package subpath exports need moduleResolution node16+. +import { useServer } from 'graphql-ws/dist/use/ws'; +import { WebSocketServer } from 'ws'; +import type { Context as WsContext } from 'graphql-ws'; +import { rateLimit } from '../middleware/rate-limit.middleware'; +import { AppError } from '../utils/AppError'; +import { logger } from '../utils/logger'; +import { assertQueryComplexity, isIntrospectionDocument } from './complexity'; +import { buildContext } from './context'; +import { executeGraphQL } from './execute'; +import { graphqlPubSub } from './pubsub'; +import { assertGraphQLRateLimit } from './rateLimit'; +import { schema } from './schema'; + +export { schema } from './schema'; +export { executeGraphQL } from './execute'; +export { graphqlPubSub } from './pubsub'; +export { createLoaders } from './dataloaders'; +export { SCHEMA_VERSION, MIN_COMPATIBLE_VERSION, DEPRECATED_FIELDS } from './version'; + +export interface MountGraphQLOptions { + /** Express path for HTTP queries/mutations. Default: /graphql */ + path?: string; + /** Enable graphql-ws subscriptions on the same path. Default: true */ + subscriptions?: boolean; +} + +/** + * Mount GraphQL HTTP handler (queries) and optionally graphql-ws (subscriptions). + * Applies Express IP rate limiting plus per-user GraphQL operation rate limits. + */ +export async function mountGraphQL( + app: Express, + server: Server, + options: MountGraphQLOptions = {}, +): Promise<{ wsServer: WebSocketServer | null }> { + const path = options.path ?? '/graphql'; + const enableSubscriptions = options.subscriptions !== false; + + await graphqlPubSub.init(); + + app.use(path, rateLimit({ windowMs: 60_000, max: 60, keyBy: 'ip' })); + + app.post(path, async (req: Request, res: Response, next: NextFunction) => { + try { + const body = req.body as { + query?: string; + variables?: Record; + operationName?: string; + }; + + if (!body?.query || typeof body.query !== 'string') { + res.status(400).json({ + errors: [{ message: 'Must provide a GraphQL query string' }], + }); + return; + } + + const result = await executeGraphQL({ + schema, + query: body.query, + variables: body.variables, + operationName: body.operationName, + req, + }); + + const hasRateLimit = result.errors?.some( + (e) => e.extensions?.code === 'RATE_LIMITED', + ); + if (hasRateLimit) { + const retryAfter = result.errors?.[0]?.extensions?.retryAfter; + if (typeof retryAfter === 'number') { + res.set('Retry-After', String(retryAfter)); + } + res.status(429).json(result); + return; + } + + const hasComplexity = result.errors?.some( + (e) => + e.extensions?.code === 'QUERY_COMPLEXITY_EXCEEDED' || + e.extensions?.code === 'QUERY_DEPTH_EXCEEDED', + ); + if (hasComplexity) { + res.status(400).json(result); + return; + } + + res.status(200).json(result); + } catch (err) { + if (err instanceof GraphQLError) { + const code = err.extensions?.code; + if (code === 'RATE_LIMITED') { + const retryAfter = err.extensions?.retryAfter; + if (typeof retryAfter === 'number') { + res.set('Retry-After', String(retryAfter)); + } + res.status(429).json({ errors: [err] }); + return; + } + if (code === 'QUERY_COMPLEXITY_EXCEEDED' || code === 'QUERY_DEPTH_EXCEEDED') { + res.status(400).json({ errors: [err] }); + return; + } + } + if (err instanceof AppError) { + return next(err); + } + next(err); + } + }); + + let wsServer: WebSocketServer | null = null; + + if (enableSubscriptions) { + wsServer = new WebSocketServer({ server, path }); + useServer( + { + schema, + context: (ctx: WsContext) => { + const req = (ctx.extra as { request: Request }).request; + return buildContext(req); + }, + onSubscribe: async ( + ctx: WsContext, + _id: string, + payload: { query?: string }, + ) => { + const req = (ctx.extra as { request: Request }).request; + const identity = + (req as Request & { user?: { id?: string } }).user?.id ?? + (typeof req.headers['x-user-id'] === 'string' + ? req.headers['x-user-id'] + : null) ?? + req.socket.remoteAddress ?? + 'anonymous'; + + await assertGraphQLRateLimit(String(identity)); + + const query = payload.query; + if (query) { + const document = parse(query); + if (!isIntrospectionDocument(document)) { + assertQueryComplexity(document); + } + } + }, + }, + wsServer, + ); + logger.info(`GraphQL subscriptions ready on ws path ${path}`); + } + + logger.info(`GraphQL HTTP endpoint mounted at POST ${path}`); + return { wsServer }; +} + +export async function shutdownGraphQL(wsServer: WebSocketServer | null): Promise { + await graphqlPubSub.shutdown(); + if (wsServer) { + await new Promise((resolve) => { + wsServer.close(() => resolve()); + }); + } +} diff --git a/backend/src/graphql/mappers.ts b/backend/src/graphql/mappers.ts new file mode 100644 index 00000000..5aaf433e --- /dev/null +++ b/backend/src/graphql/mappers.ts @@ -0,0 +1,160 @@ +import type { Bet } from '../models/Bet'; +import type { Market, MarketStats } from '../models/Market'; +import type { MarketOdds, Portfolio } from '../services/MarketService'; + +export interface GqlPosition { + id: string; + marketId: string; + ownerAddress: string; + side: Bet['side']; + totalAmount: string; + totalAmountXlm: number; + betCount: number; +} + +export interface GqlTrade { + id: string; + marketId: string; + traderAddress: string; + side: Bet['side']; + amount: string; + amountXlm: number; + priceBps: number | null; + executedAt: string; + txHash: string; +} + +export function mapMarket(market: Market) { + return { + id: String(market.id), + marketId: market.market_id, + market_id: market.market_id, + contractAddress: market.contract_address, + matchId: market.match_id, + fighterA: market.fighter_a, + fighterB: market.fighter_b, + weightClass: market.weight_class, + titleFight: market.title_fight, + venue: market.venue, + scheduledAt: toIso(market.scheduled_at), + status: market.status, + outcome: market.outcome, + poolA: market.pool_a, + poolB: market.pool_b, + poolDraw: market.pool_draw, + totalPool: market.total_pool, + feeBps: market.fee_bps, + lockBeforeSecs: market.lock_before_secs, + resolvedAt: market.resolved_at ? toIso(market.resolved_at) : null, + oracleUsed: market.oracle_used, + createdAt: toIso(market.created_at), + updatedAt: toIso(market.updated_at), + ledgerSequence: market.ledger_sequence, + _raw: market, + }; +} + +export function mapOdds(odds: MarketOdds) { + return { + oddsA: odds.odds_a, + oddsB: odds.odds_b, + oddsDraw: odds.odds_draw, + odds_a: odds.odds_a, + odds_b: odds.odds_b, + odds_draw: odds.odds_draw, + }; +} + +export function mapBet(bet: Bet) { + return { + id: String(bet.id), + marketId: bet.market_id, + bettorAddress: bet.bettor_address, + side: bet.side, + amount: bet.amount, + amountXlm: bet.amount_xlm, + placedAt: toIso(bet.placed_at), + claimed: bet.claimed, + claimedAt: bet.claimed_at ? toIso(bet.claimed_at) : null, + payout: bet.payout, + txHash: bet.tx_hash, + ledgerSequence: bet.ledger_sequence, + _raw: bet, + }; +} + +export function mapStats(stats: MarketStats) { + return { + marketId: stats.market_id, + totalBets: stats.total_bets, + uniqueBettors: stats.unique_bettors, + largestBetXlm: stats.largest_bet_xlm, + averageBetXlm: stats.average_bet_xlm, + totalPooledXlm: stats.total_pooled_xlm, + }; +} + +export function mapPortfolio(portfolio: Portfolio) { + return { + address: portfolio.address, + activeBets: portfolio.active_bets.map(mapBet), + pastBets: portfolio.past_bets.map(mapBet), + pendingClaims: portfolio.pending_claims.map(mapBet), + totalStakedXlm: portfolio.total_staked_xlm, + totalWonXlm: portfolio.total_won_xlm, + totalLostXlm: portfolio.total_lost_xlm, + }; +} + +export function betToTrade(bet: Bet, priceBps: number | null = null): GqlTrade { + return { + id: `trade:${bet.id}`, + marketId: bet.market_id, + traderAddress: bet.bettor_address, + side: bet.side, + amount: bet.amount, + amountXlm: bet.amount_xlm, + priceBps, + executedAt: toIso(bet.placed_at), + txHash: bet.tx_hash, + }; +} + +export function aggregatePositions(bets: Bet[]): GqlPosition[] { + const map = new Map(); + + for (const bet of bets) { + const key = `${bet.market_id}:${bet.bettor_address}:${bet.side}`; + const existing = map.get(key); + if (!existing) { + map.set(key, { + id: `pos:${key}`, + marketId: bet.market_id, + ownerAddress: bet.bettor_address, + side: bet.side, + totalAmount: bet.amount, + totalAmountXlm: bet.amount_xlm, + betCount: 1, + }); + continue; + } + + existing.totalAmount = addDecimalStrings(existing.totalAmount, bet.amount); + existing.totalAmountXlm += bet.amount_xlm; + existing.betCount += 1; + } + + return [...map.values()]; +} + +function addDecimalStrings(a: string, b: string): string { + try { + return (BigInt(a) + BigInt(b)).toString(); + } catch { + return String(Number(a) + Number(b)); + } +} + +function toIso(value: Date | string): string { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} diff --git a/backend/src/graphql/pagination.ts b/backend/src/graphql/pagination.ts new file mode 100644 index 00000000..98ed8857 --- /dev/null +++ b/backend/src/graphql/pagination.ts @@ -0,0 +1,16 @@ +export function encodeCursor(marketId: string, scheduledAt: Date | string): string { + const ts = scheduledAt instanceof Date ? scheduledAt.toISOString() : scheduledAt; + return Buffer.from(`${ts}|${marketId}`, 'utf8').toString('base64url'); +} + +export function decodeCursor(cursor: string): { scheduledAt: string; marketId: string } { + const raw = Buffer.from(cursor, 'base64url').toString('utf8'); + const sep = raw.indexOf('|'); + if (sep <= 0) { + throw new Error('Invalid cursor'); + } + return { + scheduledAt: raw.slice(0, sep), + marketId: raw.slice(sep + 1), + }; +} diff --git a/backend/src/graphql/pubsub.ts b/backend/src/graphql/pubsub.ts new file mode 100644 index 00000000..93e45238 --- /dev/null +++ b/backend/src/graphql/pubsub.ts @@ -0,0 +1,152 @@ +import { EventEmitter } from 'events'; +import { + connectRedisClients, + MARKET_EVENTS_PATTERN, + marketEventChannel, + parseMarketIdFromChannel, + redis, + redisSub, +} from '../config/redis'; +import type { ActivityEvent } from '../websocket/realtime'; +import { logger } from '../utils/logger'; + +export const GRAPHQL_MARKET_UPDATED = 'GRAPHQL_MARKET_UPDATED'; +export const GRAPHQL_TRADE_CREATED = 'GRAPHQL_TRADE_CREATED'; + +type Listener = (payload: unknown) => void; + +/** + * Hybrid pub/sub: in-process EventEmitter for same-instance delivery, + * Redis fan-out for cluster-wide GraphQL subscriptions. + */ +class GraphQLPubSub { + private ee = new EventEmitter(); + private redisReady = false; + private redisHandlerAttached = false; + + constructor() { + this.ee.setMaxListeners(100); + } + + async init(): Promise { + if (this.redisReady) return; + await connectRedisClients(); + + if (!this.redisHandlerAttached) { + redisSub.on('pmessage', this.onRedisMessage); + this.redisHandlerAttached = true; + } + + await redisSub.psubscribe(MARKET_EVENTS_PATTERN); + this.redisReady = true; + logger.info('GraphQL pub/sub listening on market:*:events'); + } + + private onRedisMessage = (_pattern: string, channel: string, message: string): void => { + const marketId = parseMarketIdFromChannel(channel); + if (!marketId) return; + + let event: ActivityEvent; + try { + event = JSON.parse(message) as ActivityEvent; + } catch { + return; + } + + this.ee.emit(`activity:${marketId}`, event); + + if (event.type === 'trade') { + this.ee.emit(`${GRAPHQL_TRADE_CREATED}:${marketId}`, event); + } + + if (event.type === 'resolved' || event.type === 'dispute' || event.type === 'trade') { + this.ee.emit(`${GRAPHQL_MARKET_UPDATED}:${marketId}`, event); + } + }; + + /** Fan out locally (same process) then via Redis for other instances. */ + emitLocal(marketId: string, event: ActivityEvent): void { + this.ee.emit(`activity:${marketId}`, event); + if (event.type === 'trade') { + this.ee.emit(`${GRAPHQL_TRADE_CREATED}:${marketId}`, event); + } + if (event.type === 'resolved' || event.type === 'dispute' || event.type === 'trade') { + this.ee.emit(`${GRAPHQL_MARKET_UPDATED}:${marketId}`, event); + } + } + + /** + * Publish an activity event for GraphQL subscribers. + * Prefer Redis (cluster fan-out; publisher also receives via pmessage). + * Fall back to in-process emit when Redis is unavailable. + */ + async publishActivity(marketId: string, event: ActivityEvent): Promise { + try { + await connectRedisClients(); + await redis.publish(marketEventChannel(marketId), JSON.stringify(event)); + } catch (err) { + logger.warn({ err, marketId }, 'GraphQL pub/sub Redis publish failed; using local emit'); + this.emitLocal(marketId, event); + } + } + + asyncIterator(eventName: string): AsyncIterableIterator { + const ee = this.ee; + const queue: T[] = []; + let resolveNext: ((value: IteratorResult) => void) | null = null; + let done = false; + + const onEvent: Listener = (payload) => { + const value = payload as T; + if (resolveNext) { + const resolve = resolveNext; + resolveNext = null; + resolve({ value, done: false }); + } else { + queue.push(value); + } + }; + + ee.on(eventName, onEvent); + + return { + [Symbol.asyncIterator]() { + return this; + }, + next(): Promise> { + if (done) return Promise.resolve({ value: undefined as T, done: true }); + if (queue.length > 0) { + return Promise.resolve({ value: queue.shift() as T, done: false }); + } + return new Promise((resolve) => { + resolveNext = resolve; + }); + }, + return(): Promise> { + done = true; + ee.off(eventName, onEvent); + if (resolveNext) { + resolveNext({ value: undefined as T, done: true }); + resolveNext = null; + } + return Promise.resolve({ value: undefined as T, done: true }); + }, + throw(err: unknown): Promise> { + done = true; + ee.off(eventName, onEvent); + return Promise.reject(err); + }, + }; + } + + async shutdown(): Promise { + if (this.redisHandlerAttached) { + redisSub.off('pmessage', this.onRedisMessage); + this.redisHandlerAttached = false; + } + this.redisReady = false; + this.ee.removeAllListeners(); + } +} + +export const graphqlPubSub = new GraphQLPubSub(); diff --git a/backend/src/graphql/rateLimit.ts b/backend/src/graphql/rateLimit.ts new file mode 100644 index 00000000..ca552c1d --- /dev/null +++ b/backend/src/graphql/rateLimit.ts @@ -0,0 +1,44 @@ +import { GraphQLError } from 'graphql'; +import { redis } from '../config/redis'; + +export interface GraphQLRateLimitOptions { + windowMs?: number; + max?: number; +} + +const DEFAULT_WINDOW_MS = 60_000; +const DEFAULT_MAX = 60; + +/** + * Per-user Redis rate limiter for GraphQL operations. + * Keyed by userId when present, otherwise by IP / anonymous id. + */ +export async function assertGraphQLRateLimit( + identity: string, + options: GraphQLRateLimitOptions = {}, +): Promise { + const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS; + const max = options.max ?? DEFAULT_MAX; + const windowSec = Math.ceil(windowMs / 1000); + const key = `rl:graphql:${identity}`; + + const count = await redis.incr(key); + if (count === 1) { + await redis.expire(key, windowSec); + } + + if (count > max) { + const ttl = await redis.ttl(key); + throw new GraphQLError('Too Many Requests', { + extensions: { + code: 'RATE_LIMITED', + retryAfter: ttl > 0 ? ttl : windowSec, + }, + }); + } +} + +/** Test helper — clears rate-limit keys for an identity. */ +export async function resetGraphQLRateLimit(identity: string): Promise { + await redis.del(`rl:graphql:${identity}`); +} diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts new file mode 100644 index 00000000..56da2f36 --- /dev/null +++ b/backend/src/graphql/resolvers.ts @@ -0,0 +1,421 @@ +import type { ActivityEvent } from '../websocket/realtime'; +import { + getBetsByAddress, + getBetsByMarket, + getMarkets, + getPlatformStats, + type MarketFilters, +} from '../services/MarketService'; +import type { GraphQLContext } from './context'; +import { + aggregatePositions, + betToTrade, + mapBet, + mapMarket, + mapOdds, + mapPortfolio, + mapStats, +} from './mappers'; +import { decodeCursor, encodeCursor } from './pagination'; +import { + GRAPHQL_MARKET_UPDATED, + GRAPHQL_TRADE_CREATED, + graphqlPubSub, +} from './pubsub'; +import { DEPRECATED_FIELDS, MIN_COMPATIBLE_VERSION, SCHEMA_VERSION } from './version'; + +type MarketParent = ReturnType; +type BetParent = ReturnType; + +function slicePage(items: T[], limit: number, offset: number): T[] { + return items.slice(offset, offset + limit); +} + +function compareMarkets( + a: { scheduledAt: string; totalPool: string; createdAt: string; marketId: string }, + b: typeof a, + field: string, + direction: string, +): number { + const dir = direction === 'ASC' ? 1 : -1; + let cmp = 0; + switch (field) { + case 'TOTAL_POOL': { + try { + const aPool = BigInt(a.totalPool); + const bPool = BigInt(b.totalPool); + cmp = aPool > bPool ? 1 : aPool < bPool ? -1 : 0; + } catch { + cmp = Number(a.totalPool) - Number(b.totalPool); + } + break; + } + case 'CREATED_AT': + cmp = a.createdAt.localeCompare(b.createdAt); + break; + case 'SCHEDULED_AT': + default: + cmp = a.scheduledAt.localeCompare(b.scheduledAt); + break; + } + if (cmp === 0) cmp = a.marketId.localeCompare(b.marketId); + return cmp * dir; +} + +export const resolvers = { + Query: { + schemaInfo: () => ({ + version: SCHEMA_VERSION, + minCompatibleVersion: MIN_COMPATIBLE_VERSION, + deprecatedFields: [...DEPRECATED_FIELDS], + }), + + market: async (_: unknown, args: { id: string }, ctx: GraphQLContext) => { + const market = await ctx.loaders.marketById.load(args.id); + return market ? mapMarket(market) : null; + }, + + markets: async ( + _: unknown, + args: { + filter?: { + status?: string; + weightClass?: string; + fighter?: string; + dateFrom?: string; + dateTo?: string; + titleFight?: boolean; + venue?: string; + marketIds?: string[]; + }; + sort?: { field?: string; direction?: string }; + page?: number; + limit?: number; + first?: number; + after?: string; + }, + ) => { + const filters: MarketFilters = { + status: args.filter?.status, + weight_class: args.filter?.weightClass, + fighter: args.filter?.fighter, + dateFrom: args.filter?.dateFrom ? new Date(args.filter.dateFrom) : undefined, + dateTo: args.filter?.dateTo ? new Date(args.filter.dateTo) : undefined, + }; + + // Fetch a wide page from the service, then apply GraphQL-only filters/sort/cursors. + const { markets } = await getMarkets(filters, { page: 1, limit: 1_000 }); + let nodes = markets.map(mapMarket); + + if (args.filter?.titleFight !== undefined && args.filter?.titleFight !== null) { + nodes = nodes.filter((m) => m.titleFight === args.filter!.titleFight); + } + if (args.filter?.venue) { + const venue = args.filter.venue.toLowerCase(); + nodes = nodes.filter((m) => m.venue.toLowerCase().includes(venue)); + } + if (args.filter?.marketIds?.length) { + const ids = new Set(args.filter.marketIds); + nodes = nodes.filter((m) => ids.has(m.marketId)); + } + + const sortField = args.sort?.field ?? 'SCHEDULED_AT'; + const sortDir = args.sort?.direction ?? 'DESC'; + nodes = [...nodes].sort((a, b) => compareMarkets(a, b, sortField, sortDir)); + + const totalCount = nodes.length; + const useCursor = args.first != null || args.after != null; + + if (useCursor) { + const first = Math.min(args.first ?? 50, 100); + let start = 0; + if (args.after) { + const cursor = decodeCursor(args.after); + const idx = nodes.findIndex( + (m) => m.marketId === cursor.marketId && m.scheduledAt === cursor.scheduledAt, + ); + start = idx >= 0 ? idx + 1 : 0; + } + const pageNodes = nodes.slice(start, start + first); + const edges = pageNodes.map((node) => ({ + cursor: encodeCursor(node.marketId, node.scheduledAt), + node, + })); + return { + edges, + pageInfo: { + hasNextPage: start + first < totalCount, + hasPreviousPage: start > 0, + startCursor: edges[0]?.cursor ?? null, + endCursor: edges[edges.length - 1]?.cursor ?? null, + totalCount, + }, + }; + } + + const page = Math.max(args.page ?? 1, 1); + const limit = Math.min(Math.max(args.limit ?? 50, 1), 100); + const offset = (page - 1) * limit; + const pageNodes = nodes.slice(offset, offset + limit); + const edges = pageNodes.map((node) => ({ + cursor: encodeCursor(node.marketId, node.scheduledAt), + node, + })); + + return { + edges, + pageInfo: { + hasNextPage: offset + limit < totalCount, + hasPreviousPage: offset > 0, + startCursor: edges[0]?.cursor ?? null, + endCursor: edges[edges.length - 1]?.cursor ?? null, + totalCount, + }, + }; + }, + + bets: async ( + _: unknown, + args: { marketId?: string; bettorAddress?: string; limit?: number; offset?: number }, + ) => { + let bets = args.marketId + ? await getBetsByMarket(args.marketId, args.bettorAddress) + : args.bettorAddress + ? await getBetsByAddress(args.bettorAddress) + : []; + if (args.marketId && args.bettorAddress) { + bets = bets.filter((b) => b.bettor_address === args.bettorAddress); + } + return slicePage(bets.map(mapBet), args.limit ?? 50, args.offset ?? 0); + }, + + positions: async ( + _: unknown, + args: { marketId?: string; ownerAddress?: string; limit?: number; offset?: number }, + ctx: GraphQLContext, + ) => { + let bets = args.marketId + ? await ctx.loaders.betsByMarketId.load(args.marketId) + : args.ownerAddress + ? await ctx.loaders.betsByAddress.load(args.ownerAddress) + : []; + if (args.ownerAddress) { + bets = bets.filter((b) => b.bettor_address === args.ownerAddress); + } + if (args.marketId) { + bets = bets.filter((b) => b.market_id === args.marketId); + } + return slicePage(aggregatePositions(bets), args.limit ?? 50, args.offset ?? 0); + }, + + trades: async ( + _: unknown, + args: { marketId?: string; traderAddress?: string; limit?: number; offset?: number }, + ctx: GraphQLContext, + ) => { + let bets = args.marketId + ? await ctx.loaders.betsByMarketId.load(args.marketId) + : args.traderAddress + ? await ctx.loaders.betsByAddress.load(args.traderAddress) + : []; + if (args.traderAddress) { + bets = bets.filter((b) => b.bettor_address === args.traderAddress); + } + return slicePage(bets.map((b) => betToTrade(b)), args.limit ?? 50, args.offset ?? 0); + }, + + user: (_: unknown, args: { address: string }) => ({ address: args.address }), + + portfolio: async (_: unknown, args: { address: string }, ctx: GraphQLContext) => { + const portfolio = await ctx.loaders.portfolioByAddress.load(args.address); + return mapPortfolio(portfolio); + }, + + platformStats: async () => { + const stats = await getPlatformStats(); + return { + totalMarkets: stats.totalMarkets, + activeMarkets: stats.activeMarkets, + totalVolume: stats.totalVolume, + totalBets: stats.totalBets, + }; + }, + }, + + Market: { + odds: async (parent: MarketParent, _: unknown, ctx: GraphQLContext) => { + const odds = await ctx.loaders.oddsByMarketId.load(parent.marketId); + if (!odds) { + return mapOdds({ odds_a: 3333, odds_b: 3333, odds_draw: 3334 }); + } + return mapOdds(odds); + }, + stats: async (parent: MarketParent, _: unknown, ctx: GraphQLContext) => { + const stats = await ctx.loaders.statsByMarketId.load(parent.marketId); + return stats ? mapStats(stats) : null; + }, + bets: async ( + parent: MarketParent, + args: { limit?: number; offset?: number; bettorAddress?: string }, + ctx: GraphQLContext, + ) => { + let bets = await ctx.loaders.betsByMarketId.load(parent.marketId); + if (args.bettorAddress) { + bets = bets.filter((b) => b.bettor_address === args.bettorAddress); + } + return slicePage(bets.map(mapBet), args.limit ?? 50, args.offset ?? 0); + }, + positions: async ( + parent: MarketParent, + args: { limit?: number; offset?: number }, + ctx: GraphQLContext, + ) => { + const bets = await ctx.loaders.betsByMarketId.load(parent.marketId); + return slicePage(aggregatePositions(bets), args.limit ?? 50, args.offset ?? 0); + }, + trades: async ( + parent: MarketParent, + args: { limit?: number; offset?: number }, + ctx: GraphQLContext, + ) => { + const bets = await ctx.loaders.betsByMarketId.load(parent.marketId); + return slicePage(bets.map((b) => betToTrade(b)), args.limit ?? 50, args.offset ?? 0); + }, + }, + + Bet: { + market: async (parent: BetParent, _: unknown, ctx: GraphQLContext) => { + const market = await ctx.loaders.marketById.load(parent.marketId); + return market ? mapMarket(market) : null; + }, + user: (parent: BetParent) => ({ address: parent.bettorAddress }), + }, + + Position: { + market: async ( + parent: { marketId: string }, + _: unknown, + ctx: GraphQLContext, + ) => { + const market = await ctx.loaders.marketById.load(parent.marketId); + return market ? mapMarket(market) : null; + }, + user: (parent: { ownerAddress: string }) => ({ address: parent.ownerAddress }), + }, + + Trade: { + market: async ( + parent: { marketId: string }, + _: unknown, + ctx: GraphQLContext, + ) => { + const market = await ctx.loaders.marketById.load(parent.marketId); + return market ? mapMarket(market) : null; + }, + user: (parent: { traderAddress: string }) => ({ address: parent.traderAddress }), + }, + + User: { + bets: async ( + parent: { address: string }, + args: { limit?: number; offset?: number }, + ctx: GraphQLContext, + ) => { + const bets = await ctx.loaders.betsByAddress.load(parent.address); + return slicePage(bets.map(mapBet), args.limit ?? 50, args.offset ?? 0); + }, + positions: async ( + parent: { address: string }, + args: { limit?: number; offset?: number }, + ctx: GraphQLContext, + ) => { + const bets = await ctx.loaders.betsByAddress.load(parent.address); + return slicePage(aggregatePositions(bets), args.limit ?? 50, args.offset ?? 0); + }, + trades: async ( + parent: { address: string }, + args: { limit?: number; offset?: number }, + ctx: GraphQLContext, + ) => { + const bets = await ctx.loaders.betsByAddress.load(parent.address); + return slicePage(bets.map((b) => betToTrade(b)), args.limit ?? 50, args.offset ?? 0); + }, + portfolio: async (parent: { address: string }, _: unknown, ctx: GraphQLContext) => { + const portfolio = await ctx.loaders.portfolioByAddress.load(parent.address); + return mapPortfolio(portfolio); + }, + }, + + Subscription: { + marketActivity: { + subscribe: (_: unknown, args: { marketId: string }) => + graphqlPubSub.asyncIterator(`activity:${args.marketId}`), + resolve: (event: ActivityEvent) => ({ + type: event.type, + marketId: event.marketId, + timestamp: + 'timestamp' in event && typeof event.timestamp === 'string' + ? event.timestamp + : new Date().toISOString(), + payload: JSON.stringify(event), + }), + }, + + marketUpdated: { + subscribe: (_: unknown, args: { marketId: string }) => + graphqlPubSub.asyncIterator(`${GRAPHQL_MARKET_UPDATED}:${args.marketId}`), + resolve: async (event: ActivityEvent, _args: unknown, ctx: GraphQLContext) => { + const market = await ctx.loaders.marketById.load(event.marketId); + if (!market) { + return mapMarket({ + id: 0, + market_id: event.marketId, + contract_address: '', + match_id: '', + fighter_a: '', + fighter_b: '', + weight_class: '', + title_fight: false, + venue: '', + scheduled_at: new Date(), + status: event.type === 'resolved' ? 'resolved' : 'open', + outcome: null, + pool_a: '0', + pool_b: '0', + pool_draw: '0', + total_pool: '0', + fee_bps: 0, + lock_before_secs: 3600, + resolved_at: null, + oracle_used: null, + created_at: new Date(), + updated_at: new Date(), + ledger_sequence: 0, + }); + } + // Clear loader cache so subscribers see fresh pools/status after activity. + ctx.loaders.marketById.clear(event.marketId); + const fresh = await ctx.loaders.marketById.load(event.marketId); + return mapMarket(fresh ?? market); + }, + }, + + tradeCreated: { + subscribe: (_: unknown, args: { marketId: string }) => + graphqlPubSub.asyncIterator(`${GRAPHQL_TRADE_CREATED}:${args.marketId}`), + resolve: (event: ActivityEvent & { type: 'trade' }) => ({ + id: `trade:live:${event.timestamp}`, + marketId: event.marketId, + traderAddress: '', + side: event.side === 'fighter_a' || event.side === 'fighter_b' || event.side === 'draw' + ? event.side + : 'fighter_a', + amount: String(event.sharesAmount ?? 0), + amountXlm: Number(event.sharesAmount ?? 0) / 10_000_000, + priceBps: event.priceBps ?? null, + executedAt: event.timestamp, + txHash: '', + }), + }, + }, +}; diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts new file mode 100644 index 00000000..1df78021 --- /dev/null +++ b/backend/src/graphql/schema.ts @@ -0,0 +1,8 @@ +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { resolvers } from './resolvers'; +import { typeDefs } from './typeDefs'; + +export const schema = makeExecutableSchema({ + typeDefs, + resolvers, +}); diff --git a/backend/src/graphql/typeDefs.ts b/backend/src/graphql/typeDefs.ts new file mode 100644 index 00000000..c03f9871 --- /dev/null +++ b/backend/src/graphql/typeDefs.ts @@ -0,0 +1,250 @@ +/** + * GraphQL SDL for market data queries and live subscriptions. + * Schema version: 1.0.0 — see SCHEMA.md for field docs and deprecations. + */ + +export const typeDefs = /* GraphQL */ ` + enum MarketStatus { + open + locked + resolved + cancelled + disputed + } + + enum Outcome { + fighter_a + fighter_b + draw + no_contest + } + + enum BetSide { + fighter_a + fighter_b + draw + } + + enum TradeSide { + fighter_a + fighter_b + draw + } + + enum MarketSortField { + SCHEDULED_AT + TOTAL_POOL + CREATED_AT + } + + enum SortDirection { + ASC + DESC + } + + type MarketOdds { + oddsA: Int! + oddsB: Int! + oddsDraw: Int! + odds_a: Int! @deprecated(reason: "Use oddsA — snake_case fields removed in schema v2") + odds_b: Int! @deprecated(reason: "Use oddsB — snake_case fields removed in schema v2") + odds_draw: Int! @deprecated(reason: "Use oddsDraw — snake_case fields removed in schema v2") + } + + type Market { + id: ID! + marketId: ID! + contractAddress: String! + matchId: String! + fighterA: String! + fighterB: String! + weightClass: String! + titleFight: Boolean! + venue: String! + scheduledAt: String! + status: MarketStatus! + outcome: Outcome + poolA: String! + poolB: String! + poolDraw: String! + totalPool: String! + feeBps: Int! + lockBeforeSecs: Int! + resolvedAt: String + oracleUsed: String + createdAt: String! + updatedAt: String! + ledgerSequence: Int! + odds: MarketOdds! + stats: MarketStats + bets(limit: Int = 50, offset: Int = 0, bettorAddress: String): [Bet!]! + positions(limit: Int = 50, offset: Int = 0): [Position!]! + trades(limit: Int = 50, offset: Int = 0): [Trade!]! + market_id: ID! @deprecated(reason: "Use marketId — snake_case fields removed in schema v2") + } + + type MarketStats { + marketId: ID! + totalBets: Int! + uniqueBettors: Int! + largestBetXlm: Float! + averageBetXlm: Float! + totalPooledXlm: Float! + } + + type Bet { + id: ID! + marketId: ID! + bettorAddress: String! + side: BetSide! + amount: String! + amountXlm: Float! + placedAt: String! + claimed: Boolean! + claimedAt: String + payout: String + txHash: String! + ledgerSequence: Int! + market: Market + user: User + } + + """Aggregated stake for a bettor on one market side (position view).""" + type Position { + id: ID! + marketId: ID! + ownerAddress: String! + side: BetSide! + totalAmount: String! + totalAmountXlm: Float! + betCount: Int! + market: Market + user: User + } + + """Individual fill / bet as a trade in the activity stream.""" + type Trade { + id: ID! + marketId: ID! + traderAddress: String! + side: TradeSide! + amount: String! + amountXlm: Float! + priceBps: Int + executedAt: String! + txHash: String! + market: Market + user: User + } + + """Wallet-centric user projection (no separate users table).""" + type User { + address: ID! + bets(limit: Int = 50, offset: Int = 0): [Bet!]! + positions(limit: Int = 50, offset: Int = 0): [Position!]! + trades(limit: Int = 50, offset: Int = 0): [Trade!]! + portfolio: Portfolio! + } + + type Portfolio { + address: ID! + activeBets: [Bet!]! + pastBets: [Bet!]! + pendingClaims: [Bet!]! + totalStakedXlm: Float! + totalWonXlm: Float! + totalLostXlm: Float! + } + + type PlatformStats { + totalMarkets: Int! + activeMarkets: Int! + totalVolume: Float! + totalBets: Int! + } + + type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + totalCount: Int! + } + + type MarketEdge { + cursor: String! + node: Market! + } + + type MarketConnection { + edges: [MarketEdge!]! + pageInfo: PageInfo! + } + + type SchemaInfo { + version: String! + minCompatibleVersion: String! + deprecatedFields: [String!]! + } + + input MarketFilterInput { + status: MarketStatus + weightClass: String + fighter: String + dateFrom: String + dateTo: String + titleFight: Boolean + venue: String + marketIds: [ID!] + } + + input MarketSortInput { + field: MarketSortField = SCHEDULED_AT + direction: SortDirection = DESC + } + + type ActivityPayload { + type: String! + marketId: ID! + timestamp: String! + payload: String! + } + + type Query { + """Schema versioning metadata for backward-compatible clients.""" + schemaInfo: SchemaInfo! + + market(id: ID!): Market + + """ + List markets with any combination of filters, sorting, and + either offset pagination (page/limit) or cursor pagination (first/after). + """ + markets( + filter: MarketFilterInput + sort: MarketSortInput + page: Int = 1 + limit: Int = 50 + first: Int + after: String + ): MarketConnection! + + bets(marketId: ID, bettorAddress: String, limit: Int = 50, offset: Int = 0): [Bet!]! + positions(marketId: ID, ownerAddress: String, limit: Int = 50, offset: Int = 0): [Position!]! + trades(marketId: ID, traderAddress: String, limit: Int = 50, offset: Int = 0): [Trade!]! + user(address: ID!): User + portfolio(address: ID!): Portfolio! + platformStats: PlatformStats! + } + + type Subscription { + """Live activity for a market (trades, disputes, resolutions). Target latency < 500ms.""" + marketActivity(marketId: ID!): ActivityPayload! + + """Emitted when a market row-relevant field changes (status, pools, outcome).""" + marketUpdated(marketId: ID!): Market! + + """Emitted for each new trade on a market.""" + tradeCreated(marketId: ID!): Trade! + } +`; diff --git a/backend/src/graphql/version.ts b/backend/src/graphql/version.ts new file mode 100644 index 00000000..123fd1cc --- /dev/null +++ b/backend/src/graphql/version.ts @@ -0,0 +1,13 @@ +/** Current GraphQL schema version. Bump minor for additive changes, major for breaking. */ +export const SCHEMA_VERSION = '1.0.0'; + +/** Oldest client schema version still supported without forced upgrade. */ +export const MIN_COMPATIBLE_VERSION = '1.0.0'; + +/** Fields retained for backward compatibility; removed in the next major. */ +export const DEPRECATED_FIELDS = [ + 'Market.market_id', + 'MarketOdds.odds_a', + 'MarketOdds.odds_b', + 'MarketOdds.odds_draw', +] as const; diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 00000000..06c65fcb --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,68 @@ +import express from 'express'; +import http from 'http'; +import pinoHttp from 'pino-http'; +import { closeRedisClients } from './config/redis'; +import { errorMiddleware } from './middleware/error.middleware'; +import { AppError } from './utils/AppError'; +import { logger } from './utils/logger'; +import { + mountGraphQL, + shutdownGraphQL, +} from './graphql'; +import { + initActivityFeed, + shutdownActivityFeed, +} from './websocket/realtime'; +import type { WebSocketServer } from 'ws'; + +const PORT = Number(process.env.PORT ?? 3001); + +const app = express(); +app.use(pinoHttp({ logger })); +app.use(express.json()); + +app.get('/health', (_req, res) => { + res.json({ status: 'ok' }); +}); + +// 404 after routes are mounted in bootstrap() +const server = http.createServer(app); +let graphqlWs: WebSocketServer | null = null; + +async function bootstrap(): Promise { + const mounted = await mountGraphQL(app, server, { path: '/graphql' }); + graphqlWs = mounted.wsServer; + + app.use((_req, _res, next) => { + next(AppError.notFound('Route not found')); + }); + app.use(errorMiddleware); + + await initActivityFeed(server); + + server.listen(PORT, () => { + logger.info(`Server running on port ${PORT}`); + logger.info(`GraphQL endpoint at http://localhost:${PORT}/graphql`); + }); +} + +const shutdown = async (): Promise => { + await shutdownGraphQL(graphqlWs); + await shutdownActivityFeed(); + await closeRedisClients(); + server.close(() => process.exit(0)); +}; + +process.on('SIGTERM', () => { + void shutdown(); +}); +process.on('SIGINT', () => { + void shutdown(); +}); + +void bootstrap().catch((err) => { + logger.error({ err }, 'Failed to start server'); + process.exit(1); +}); + +export { app, server }; diff --git a/backend/src/middleware/error.middleware.ts b/backend/src/middleware/error.middleware.ts new file mode 100644 index 00000000..95b48c86 --- /dev/null +++ b/backend/src/middleware/error.middleware.ts @@ -0,0 +1,46 @@ +import type { Request, Response, NextFunction } from 'express'; +import { AppError } from '../utils/AppError'; +import { logger } from '../utils/logger'; + +const isProd = process.env.NODE_ENV === 'production'; + +export function errorMiddleware( + err: unknown, + _req: Request, + res: Response, + _next: NextFunction, +): void { + if (err instanceof AppError) { + if (err.statusCode >= 500) { + logger.error({ + message: err.message, + statusCode: err.statusCode, + code: err.code, + details: err.details, + ...(!isProd && { stack: err.stack }), + }); + } + res.status(err.statusCode).json({ + error: { + statusCode: err.statusCode, + message: err.message, + ...(err.code && { code: err.code }), + ...(err.details !== undefined && { details: err.details }), + }, + }); + return; + } + + const message = err instanceof Error ? err.message : 'Internal server error'; + logger.error({ + message, + ...(!isProd && { stack: err instanceof Error ? err.stack : undefined }), + }); + + res.status(500).json({ + error: { + statusCode: 500, + message: isProd ? 'Internal server error' : message, + }, + }); +} diff --git a/backend/src/middleware/rate-limit.middleware.ts b/backend/src/middleware/rate-limit.middleware.ts new file mode 100644 index 00000000..865a59be --- /dev/null +++ b/backend/src/middleware/rate-limit.middleware.ts @@ -0,0 +1,33 @@ +import type { Request, Response, NextFunction } from 'express'; +import { redis } from '../services/cache.service'; +import { AppError } from '../utils/AppError'; + +export interface RateLimitOptions { + windowMs: number; + max: number; + keyBy: 'ip' | 'userId'; +} + +export function rateLimit(opts: RateLimitOptions) { + const windowSec = Math.ceil(opts.windowMs / 1000); + + return async (req: Request, _res: Response, next: NextFunction): Promise => { + const id = + opts.keyBy === 'userId' + ? (req as Request & { user?: { id: string } }).user?.id ?? req.ip + : req.ip; + + const key = `rl:${req.path}:${id}`; + + const count = await redis.incr(key); + if (count === 1) await redis.expire(key, windowSec); + + if (count > opts.max) { + const ttl = await redis.ttl(key); + (_res as Response).set('Retry-After', String(ttl)); + return next(new AppError(429, 'Too Many Requests')); + } + + next(); + }; +} diff --git a/backend/src/models/Bet.ts b/backend/src/models/Bet.ts new file mode 100644 index 00000000..5904e15b --- /dev/null +++ b/backend/src/models/Bet.ts @@ -0,0 +1,26 @@ +// ============================================================ +// BOXMEOUT — Bet Database Model +// ============================================================ + +export interface Bet { + id: number; + /** Foreign key to markets.market_id */ + market_id: string; + /** Stellar G... address of the bettor */ + bettor_address: string; + side: BetSideDB; + /** Amount in stroops as string (i128 precision) */ + amount: string; + /** Denormalized XLM amount for display queries */ + amount_xlm: number; + placed_at: Date; + claimed: boolean; + claimed_at: Date | null; + /** Actual payout received, null until claimed */ + payout: string | null; + /** Stellar transaction hash for the place_bet call */ + tx_hash: string; + ledger_sequence: number; +} + +export type BetSideDB = 'fighter_a' | 'fighter_b' | 'draw'; diff --git a/backend/src/models/Market.ts b/backend/src/models/Market.ts new file mode 100644 index 00000000..ab967433 --- /dev/null +++ b/backend/src/models/Market.ts @@ -0,0 +1,71 @@ +// ============================================================ +// BOXMEOUT — Market Database Model +// ORM definition for the markets table. +// Contributors: do not add business logic here — model only. +// ============================================================ + +export interface Market { + /** Auto-increment primary key */ + id: number; + /** On-chain market_id (stored as string to preserve u64 precision) */ + market_id: string; + /** Deployed Market Soroban contract address */ + contract_address: string; + /** Unique fight identifier matching FightDetails.match_id */ + match_id: string; + fighter_a: string; + fighter_b: string; + weight_class: string; + title_fight: boolean; + venue: string; + /** ISO timestamp of scheduled fight start */ + scheduled_at: Date; + status: MarketStatusDB; + outcome: OutcomeDB | null; + /** Staked on FighterA — stored as string to preserve i128 precision */ + pool_a: string; + pool_b: string; + pool_draw: string; + total_pool: string; + /** Platform fee in basis points */ + fee_bps: number; + /** Seconds before scheduled_at to stop accepting bets (default 3600) */ + lock_before_secs: number; + resolved_at: Date | null; + oracle_used: 'primary' | 'fallback' | 'admin' | null; + created_at: Date; + updated_at: Date; + /** Stellar ledger sequence at which this market was created */ + ledger_sequence: number; + /** LMSR liquidity parameter b in stroops. Defaults to 10_000_000_000 (1000 XLM) if absent. */ + lmsr_b?: string; +} + +export type MarketStatusDB = + | 'open' + | 'locked' + | 'resolved' + | 'cancelled' + | 'disputed'; + +export type OutcomeDB = + | 'fighter_a' + | 'fighter_b' + | 'draw' + | 'no_contest'; + +export interface MarketStats { + market_id: string; + total_bets: number; + unique_bettors: number; + largest_bet_xlm: number; + average_bet_xlm: number; + total_pooled_xlm: number; +} + +export interface PlatformStats { + totalMarkets: number; + activeMarkets: number; + totalVolume: number; + totalBets: number; +} diff --git a/backend/src/services/MarketService.ts b/backend/src/services/MarketService.ts new file mode 100644 index 00000000..b0faaaa5 --- /dev/null +++ b/backend/src/services/MarketService.ts @@ -0,0 +1,785 @@ +// ============================================================ +// BOXMEOUT — Market Service +// Business logic layer between controllers and the DB/chain. +// Contributors: implement every function marked TODO. +// ============================================================ + +import type { Market, MarketStats, PlatformStats } from '../models/Market'; +import type { Bet } from '../models/Bet'; +import { pool } from '../config/db'; +import * as cache from './cache.service'; +import * as StellarService from './StellarService'; +import { AppError } from '../utils/AppError'; + +// --------------------------------------------------------------------------- +// DB adapter — thin abstraction so tests can inject a mock +// --------------------------------------------------------------------------- +export interface DbAdapter { + findMarkets(filters?: MarketFilters): Promise; + findMarketById(market_id: string): Promise; + findBetsByAddress(bettor_address: string): Promise; + findBetsByMarket(market_id: string, bettor_address?: string): Promise; + updateMarketStatus(market_id: string, status: string): Promise; +} + +let _db: DbAdapter | null = null; + +export function setDbAdapter(adapter: DbAdapter): void { + _db = adapter; +} + +function db(): DbAdapter { + if (!_db) throw new Error('DbAdapter not initialised'); + return _db; +} + +export { db }; + +export interface MarketFilters { + status?: string; + weight_class?: string; + fighter?: string; + dateFrom?: Date; + dateTo?: Date; +} + +export interface Pagination { + page: number; + limit: number; +} + +export interface MarketListResult { + markets: Market[]; + total: number; +} + +export interface MarketOdds { + odds_a: number; // Implied probability in basis points + odds_b: number; + odds_draw: number; +} + +export interface MarketWithOdds extends Market { + odds: MarketOdds; +} + +export interface OutcomeOdds { + outcome: string; + multiplier: number; + implied_probability: number; + pool: string; + total_pool: string; +} + +export interface AllOutcomeOdds { + market_id: string; + fighter_a: OutcomeOdds; + fighter_b: OutcomeOdds; + draw: OutcomeOdds; + total_pool: string; +} + +export interface Portfolio { + address: string; + active_bets: Bet[]; + past_bets: Bet[]; + total_staked_xlm: number; + total_won_xlm: number; + total_lost_xlm: number; + pending_claims: Bet[]; +} + +export interface BettorStats { + bettor_address: string; + total_bets: number; + total_wagered_xlm: number; + total_winnings_xlm: number; + win_rate: number; + favorite_fighter: string | null; +} + +export interface ProjectedPayout { + amount: string; + formatted_xlm: number; +} + +/** + * Returns paginated markets from the database. + * + * Steps: + * 1. Build WHERE clause from filters (status, weight_class, fighter name, date range) + * 2. Apply pagination (LIMIT / OFFSET) + * 3. Check Redis cache — return cached result if fresh (TTL 30s) + * 4. Query DB if cache miss; store result in cache before returning + * 5. Sort by scheduled_at DESC by default + */ +export async function getMarkets( + filters?: MarketFilters, + pagination?: Pagination, +): Promise { + const statusKey = filters?.status ?? ''; + const weightKey = filters?.weight_class ?? ''; + const fighterKey = filters?.fighter ?? ''; + const dateFromKey = filters?.dateFrom?.toISOString() ?? ''; + const dateToKey = filters?.dateTo?.toISOString() ?? ''; + const page = pagination?.page ?? 1; + const limit = pagination?.limit ?? 50; + const cacheKey = `markets:${statusKey}:${weightKey}:${fighterKey}:${dateFromKey}:${dateToKey}:${page}:${limit}`; + const cached = await cache.get(cacheKey); + if (cached) return cached; + + let result: MarketListResult; + if (_db) { + const markets = await db().findMarkets(filters); + const filtered = markets.filter((market) => { + if (filters?.status && market.status !== filters.status) return false; + if (filters?.weight_class && market.weight_class !== filters.weight_class) return false; + if (filters?.fighter) { + const fighterLower = filters.fighter.toLowerCase(); + if (!market.fighter_a.toLowerCase().includes(fighterLower) && + !market.fighter_b.toLowerCase().includes(fighterLower)) { + return false; + } + } + if (filters?.dateFrom && new Date(market.scheduled_at) < filters.dateFrom) return false; + if (filters?.dateTo && new Date(market.scheduled_at) > filters.dateTo) return false; + return true; + }); + + const sorted = [...filtered].sort( + (a, b) => new Date(b.scheduled_at).getTime() - new Date(a.scheduled_at).getTime(), + ); + + const offset = (page - 1) * limit; + const paged = sorted.slice(offset, offset + limit); + result = { markets: paged, total: sorted.length }; + } else { + const whereClauses: string[] = []; + const values: unknown[] = []; + + if (filters?.status) { + values.push(filters.status); + whereClauses.push(`status = $${values.length}`); + } + if (filters?.weight_class) { + values.push(filters.weight_class); + whereClauses.push(`weight_class = $${values.length}`); + } + if (filters?.fighter) { + values.push(`%${filters.fighter}%`); + whereClauses.push(`(fighter_a ILIKE $${values.length} OR fighter_b ILIKE $${values.length})`); + } + if (filters?.dateFrom) { + values.push(filters.dateFrom); + whereClauses.push(`scheduled_at >= $${values.length}`); + } + if (filters?.dateTo) { + values.push(filters.dateTo); + whereClauses.push(`scheduled_at <= $${values.length}`); + } + + const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''; + const offset = (page - 1) * limit; + + const rows = await pool.query( + `SELECT * FROM markets ${whereSql} ORDER BY scheduled_at DESC LIMIT $${values.length + 1} OFFSET $${values.length + 2}`, + [...values, limit, offset], + ); + + const countRows = await pool.query( + `SELECT COUNT(*) AS total FROM markets ${whereSql}`, + values, + ); + + result = { + markets: rows.rows.map((row) => ({ + ...row, + scheduled_at: new Date(row.scheduled_at), + created_at: new Date(row.created_at), + updated_at: new Date(row.updated_at), + resolved_at: row.resolved_at ? new Date(row.resolved_at) : null, + } as Market)), + total: Number(countRows.rows[0]?.total ?? 0), + }; + } + + await cache.set(cacheKey, result, 30); + return result; +} + +/** + * Invalidates cache for a market when it's updated. + * Clears the market cache and related pattern caches. + */ +export async function invalidateMarketCache(market_id: string): Promise { + await cache.del(`market:${market_id}`); + await cache.delPattern(`markets:*`); + await cache.del(`market:${market_id}:stats`); +} + +/** + * Returns a single market by its on-chain market_id string, enriched with + * live odds from getMarketOdds(). + * + * Steps: + * 1. Check Redis cache — return cached result if fresh (TTL 10s) + * 2. Query DB; throw AppError 404 if no row found + * 3. Fetch live odds via getMarketOdds() + * 4. Merge market + odds, store in cache for 10 seconds, then return + */ +export async function getMarketById(market_id: string): Promise { + const cacheKey = `market:${market_id}`; + const cached = await cache.get(cacheKey); + if (cached) return cached; + + const market = await db().findMarketById(market_id); + if (!market) throw AppError.notFound(`Market not found: ${market_id}`); + + const odds = await getMarketOdds(market_id); + const result: MarketWithOdds = { ...market, odds }; + + await cache.set(cacheKey, result, 10); + return result; +} + +// --------------------------------------------------------------------------- +// LMSR helpers (TypeScript may use Math.exp/Math.log; only the Soroban WASM contract +// is restricted to integer arithmetic) +// --------------------------------------------------------------------------- + +/** LMSR default b = 1000 XLM in stroops. Used when the DB row lacks lmsr_b. */ +const LMSR_B_DEFAULT = 10_000_000_000n; + +/** + * LMSR cost function: C(q) = b * ln(e^(q_a/b) + e^(q_b/b) + e^(q_d/b)). + * Uses log-sum-exp trick (subtract max) for numerical stability. + */ +function lmsrCostFn(q_a: number, q_b: number, q_d: number, b: number): number { + const xA = q_a / b; + const xB = q_b / b; + const xD = q_d / b; + const maxX = Math.max(xA, xB, xD); + return b * (maxX + Math.log(Math.exp(xA - maxX) + Math.exp(xB - maxX) + Math.exp(xD - maxX))); +} + +/** + * LMSR implied probabilities for a 3-outcome market, expressed in basis points (0..10000). + * p_i = e^(q_i/b) / Σ e^(q_j/b). + * Uses log-sum-exp trick for numerical stability. + * Returns uniform prior {a:3333, b:3333, draw:3334} when all pools are zero. + */ +function lmsrPriceBps( + q_a: bigint, q_b: bigint, q_draw: bigint, b: bigint, +): { a: number; b: number; draw: number } { + const bF = Number(b); + const xA = Number(q_a) / bF; + const xB = Number(q_b) / bF; + const xD = Number(q_draw) / bF; + + const maxX = Math.max(xA, xB, xD); + const eA = Math.exp(xA - maxX); + const eB = Math.exp(xB - maxX); + const eD = Math.exp(xD - maxX); + const sum = eA + eB + eD; + + const pA = Math.floor((eA / sum) * 10000); + const pB = Math.floor((eB / sum) * 10000); + const pD = 10000 - pA - pB; // assign remainder to draw so bps sum to exactly 10000 + + return { a: pA, b: pB, draw: pD }; +} + +/** + * LMSR marginal cost for a bet of `delta` stroops on `outcome` given current pools. + * cost = C(q + Δe_i) - C(q). Always less than delta. + */ +function lmsrMarginalCost( + q_a: bigint, q_b: bigint, q_draw: bigint, delta: bigint, outcome: string, b: bigint, +): bigint { + const bF = Number(b); + const qA = Number(q_a); const qB = Number(q_b); const qD = Number(q_draw); + const dF = Number(delta); + const before = lmsrCostFn(qA, qB, qD, bF); + const after = outcome === 'fighter_a' + ? lmsrCostFn(qA + dF, qB, qD, bF) + : outcome === 'fighter_b' + ? lmsrCostFn(qA, qB + dF, qD, bF) + : lmsrCostFn(qA, qB, qD + dF, bF); + return BigInt(Math.round(after - before)); +} + +// --------------------------------------------------------------------------- + +/** + * Returns live LMSR odds for a market in basis points. + * Computes p_i = e^(q_i/b) / Σ e^(q_j/b) from pool quantities stored in DB. + * Falls back to on-chain read when DB row is stale (updated_at > 30 s ago). + */ +export async function getMarketOdds(market_id: string): Promise { + const market = await db().findMarketById(market_id); + if (!market) throw AppError.notFound(`Market not found: ${market_id}`); + + const now = new Date(); + const isStale = (now.getTime() - market.updated_at.getTime()) > 30_000; + + let q_a: bigint, q_b: bigint, q_draw: bigint; + + if (isStale) { + const onChainData = await StellarService.readContractState(market.contract_address, 'get_state', []) as { pool_a: string; pool_b: string; pool_draw: string }; + q_a = BigInt(onChainData.pool_a); + q_b = BigInt(onChainData.pool_b); + q_draw = BigInt(onChainData.pool_draw); + } else { + q_a = BigInt(market.pool_a); + q_b = BigInt(market.pool_b); + q_draw = BigInt(market.pool_draw); + } + + const b = BigInt(market.lmsr_b ?? LMSR_B_DEFAULT); + const { a, b: bps_b, draw } = lmsrPriceBps(q_a, q_b, q_draw, b); + return { odds_a: a, odds_b: bps_b, odds_draw: draw }; +} + + +/** Shared market loader for odds calculations. */ +async function loadMarketPools(market_id: string) { + const market = await db().findMarketById(market_id); + if (!market) throw AppError.notFound(`Market not found: ${market_id}`); + return { + totalPool: BigInt(market.total_pool), + poolA: BigInt(market.pool_a), + poolB: BigInt(market.pool_b), + poolDraw: BigInt(market.pool_draw), + feeBps: market.fee_bps, + totalPoolStr: market.total_pool, + b: BigInt(market.lmsr_b ?? LMSR_B_DEFAULT), + }; +} + +/** + * Build an OutcomeOdds from LMSR implied probability (basis points). + * + * LMSR payout multiplier = (net_pool / winning_pool) ≈ (1 - fee) / p_i for large pools. + * For small/empty pools we fall back to the LMSR probability directly. + * + * implied_probability — directly from LMSR p_i in bps → percent. + * multiplier — (net_pool / outcome_pool) when outcome_pool > 0, else 0. + */ +function buildOutcomeOdds( + priceBps: number, + outcomePool: bigint, + totalPool: bigint, + feeBps: number, + outcome: string, + totalPoolStr: string, +): OutcomeOdds { + const implied_probability = priceBps / 100; + + let multiplier = 0; + if (outcomePool > 0n && totalPool > 0n) { + const fee = (totalPool * BigInt(feeBps)) / 10000n; + const netPool = totalPool - fee; + multiplier = Math.round((Number(netPool) / Number(outcomePool)) * 100) / 100; + } + + return { + outcome, + multiplier, + implied_probability: Math.round(implied_probability * 100) / 100, + pool: outcomePool.toString(), + total_pool: totalPoolStr, + }; +} + +/** + * Returns LMSR-derived odds for a single outcome. + * implied_probability = e^(q_i/b) / Σ e^(q_j/b). + * multiplier = net_pool / outcome_pool (parimutuel payout on LMSR-accumulated pools). + */ +export async function calculateSingleOutcomeOdds( + market_id: string, + outcome: 'fighter_a' | 'fighter_b' | 'draw', +): Promise { + const { totalPool, poolA, poolB, poolDraw, feeBps, totalPoolStr, b } = await loadMarketPools(market_id); + const prices = lmsrPriceBps(poolA, poolB, poolDraw, b); + const priceBps = outcome === 'fighter_a' ? prices.a : outcome === 'fighter_b' ? prices.b : prices.draw; + const pool = outcome === 'fighter_a' ? poolA : outcome === 'fighter_b' ? poolB : poolDraw; + return buildOutcomeOdds(priceBps, pool, totalPool, feeBps, outcome, totalPoolStr); +} + +/** + * Returns LMSR-derived odds for all three outcomes. + * implied_probability = e^(q_i/b) / Σ e^(q_j/b), expressed as a percentage. + * multiplier = net_pool / outcome_pool (parimutuel payout on LMSR-accumulated pools). + */ +export async function calculateOutcomeOdds(market_id: string): Promise { + const { totalPool, poolA, poolB, poolDraw, feeBps, totalPoolStr, b } = await loadMarketPools(market_id); + const prices = lmsrPriceBps(poolA, poolB, poolDraw, b); + + return { + market_id, + fighter_a: buildOutcomeOdds(prices.a, poolA, totalPool, feeBps, 'fighter_a', totalPoolStr), + fighter_b: buildOutcomeOdds(prices.b, poolB, totalPool, feeBps, 'fighter_b', totalPoolStr), + draw: buildOutcomeOdds(prices.draw, poolDraw, totalPool, feeBps, 'draw', totalPoolStr), + total_pool: totalPoolStr, + }; +} + +/** + * Returns all bets placed by a given Stellar address across all markets. + * Returns an empty array (never 404) when the address has no bets. + */ +export async function getBetsByAddress(bettor_address: string): Promise { + if (_db) { + return db().findBetsByAddress(bettor_address); + } + + const { rows } = await pool.query( + 'SELECT * FROM bets WHERE bettor_address = $1 ORDER BY placed_at DESC', + [bettor_address], + ); + + return rows.map((row) => ({ + ...row, + placed_at: new Date(row.placed_at), + claimed_at: row.claimed_at ? new Date(row.claimed_at) : null, + } as Bet)); +} + +/** + * Returns aggregate statistics for a bettor address. + * Totals are computed in XLM (divide stroops by 10_000_000). + * Returns zeroed stats when no bets exist. + */ +export async function getBettorStats(bettor_address: string): Promise { + const bets = await getBetsByAddress(bettor_address); + const total_bets = bets.length; + const total_wagered_xlm = bets.reduce((sum, bet) => sum + Number(bet.amount) / 10_000_000, 0); + const total_winnings_xlm = bets + .filter((bet) => bet.claimed && bet.payout) + .reduce((sum, bet) => sum + Number(bet.payout ?? '0') / 10_000_000, 0); + + const outcomeCounts = bets.reduce>((counts, bet) => { + counts[bet.side] = (counts[bet.side] ?? 0) + 1; + return counts; + }, {}); + + const favorite_fighter = Object.entries(outcomeCounts).reduce((best, [side, count]) => { + if (best === null) return side; + return count > (outcomeCounts[best] ?? 0) ? side : best; + }, null); + + const win_rate = total_bets === 0 + ? 0 + : Math.round((bets.filter((bet) => bet.claimed && bet.payout).length * 10000) / total_bets) / 100; + + return { + bettor_address, + total_bets, + total_wagered_xlm, + total_winnings_xlm, + win_rate, + favorite_fighter, + }; +} + +/** + * Returns all bets for a given market. + * If bettor_address is provided, filters to only that bettor's bets. + */ +export async function getBetsByMarket( + market_id: string, + bettor_address?: string, +): Promise { + if (_db) { + return db().findBetsByMarket(market_id, bettor_address); + } + + const values: unknown[] = [market_id]; + let sql = 'SELECT * FROM bets WHERE market_id = $1'; + + if (bettor_address) { + values.push(bettor_address); + sql += ` AND bettor_address = $${values.length}`; + } + + sql += ' ORDER BY placed_at DESC'; + + const { rows } = await pool.query(sql, values); + return rows.map((row) => ({ + ...row, + placed_at: new Date(row.placed_at), + claimed_at: row.claimed_at ? new Date(row.claimed_at) : null, + } as Bet)); +} + +/** + * Returns aggregate statistics for a market. + * Values are computed from the bets table, not from on-chain. + * Results cached in Redis for 60 seconds. + */ +export async function getMarketStats(market_id: string): Promise { + const cacheKey = `market:${market_id}:stats`; + const cached = await cache.get(cacheKey); + if (cached) return cached; + + const bets = await db().findBetsByMarket(market_id); + + const total_bets = bets.length; + const unique_bettors = new Set(bets.map(b => b.bettor_address)).size; + const amounts_xlm = bets.map(b => Number(b.amount) / 10_000_000); + const largest_bet_xlm = amounts_xlm.length > 0 ? Math.max(...amounts_xlm) : 0; + const average_bet_xlm = amounts_xlm.length > 0 ? amounts_xlm.reduce((s, a) => s + a, 0) / amounts_xlm.length : 0; + const total_pooled_xlm = amounts_xlm.reduce((s, a) => s + a, 0); + + const stats: MarketStats = { + market_id, + total_bets, + unique_bettors, + largest_bet_xlm, + average_bet_xlm, + total_pooled_xlm, + }; + + await cache.set(cacheKey, stats, 60); + return stats; +} + +/** + * Returns a portfolio summary for a Stellar address. + * + * active_bets: bets in Open/Locked markets + * past_bets: bets in Resolved/Cancelled markets + * pending_claims: unclaimed winning bets in Resolved markets + * Totals are computed in XLM (divide stroops by 10_000_000). + */ +export async function getPortfolioByAddress( + bettor_address: string, +): Promise { + const bets = await db().findBetsByAddress(bettor_address); + const marketIds = [...new Set(bets.map(b => b.market_id))]; + const markets = await Promise.all(marketIds.map(id => db().findMarketById(id))); + const marketMap = new Map(markets.filter(Boolean).map(m => [m!.market_id, m!])); + + const active_bets: Bet[] = []; + const past_bets: Bet[] = []; + const pending_claims: Bet[] = []; + + for (const bet of bets) { + const market = marketMap.get(bet.market_id); + const status = market?.status; + if (status === 'open' || status === 'locked') { + active_bets.push(bet); + } else { + past_bets.push(bet); + if (status === 'resolved' && !bet.claimed && market?.outcome === bet.side) { + pending_claims.push(bet); + } + } + } + + const total_staked_xlm = bets.reduce((s, b) => s + Number(b.amount) / 10_000_000, 0); + const total_won_xlm = bets + .filter(b => b.claimed && b.payout) + .reduce((s, b) => s + Number(b.payout) / 10_000_000, 0); + const total_lost_xlm = past_bets + .filter(b => !b.claimed && !pending_claims.includes(b)) + .reduce((s, b) => s + Number(b.amount) / 10_000_000, 0); + + return { + address: bettor_address, + active_bets, + past_bets, + total_staked_xlm, + total_won_xlm, + total_lost_xlm, + pending_claims, + }; +} + +/** + * Simulates projected payout for a hypothetical bet on a market using LMSR pricing. + * + * Steps: + * 1. Compute LMSR marginal cost: cost = C(q + Δe_i) - C(q) (cost ≤ amount always) + * 2. Projected winning pool after this bet: outcome_pool + cost + * 3. Projected total pool: total_pool + cost + * 4. Projected net pool: (total_pool + cost) * (1 - fee) + * 5. Projected payout: cost / (outcome_pool + cost) * net_pool + * + * Returns zero when the market is cancelled or amount is non-positive. + */ +export async function simulateProjectedPayout( + market_id: string, + amount: string, + outcome: 'fighter_a' | 'fighter_b' | 'draw', +): Promise { + const market = await db().findMarketById(market_id); + if (!market) throw AppError.notFound(`Market not found: ${market_id}`); + + if (market.status === 'cancelled') return { amount: '0', formatted_xlm: 0 }; + + const delta = BigInt(amount); + if (delta <= 0n) return { amount: '0', formatted_xlm: 0 }; + + const q_a = BigInt(market.pool_a); + const q_b = BigInt(market.pool_b); + const q_draw = BigInt(market.pool_draw); + const total_pool = BigInt(market.total_pool); + const b = BigInt(market.lmsr_b ?? LMSR_B_DEFAULT); + + const cost = lmsrMarginalCost(q_a, q_b, q_draw, delta, outcome, b); + if (cost <= 0n) return { amount: '0', formatted_xlm: 0 }; + + const winning_pool_before = outcome === 'fighter_a' ? q_a : outcome === 'fighter_b' ? q_b : q_draw; + const winning_pool_after = winning_pool_before + cost; + const total_after = total_pool + cost; + const fee = (total_after * BigInt(market.fee_bps)) / 10000n; + const net_pool = total_after - fee; + + const payout = (cost * net_pool) / winning_pool_after; + return { + amount: payout.toString(), + formatted_xlm: Number(payout) / 10_000_000, + }; +} + +/** + * Returns aggregate platform statistics for the home page banner. + * Queries: COUNT(*) WHERE status='Open', SUM(total_pool), COUNT(bets) + * Results cached in Redis for 60 seconds. + */ +export async function getPlatformStats(): Promise { + const cacheKey = 'platform:stats'; + const cached = await cache.get(cacheKey); + if (cached) return cached; + + if (_db) { + // If using test adapter, compute from in-memory data + const allMarkets = await db().findMarkets(); + const openMarkets = allMarkets.filter(m => m.status === 'open'); + const allBets = await Promise.all( + allMarkets.map(m => db().findBetsByMarket(m.market_id)) + ).then(results => results.flat()); + + const totalVolume = allMarkets.reduce((sum, m) => sum + Number(m.total_pool) / 10_000_000, 0); + + const stats: PlatformStats = { + totalMarkets: allMarkets.length, + activeMarkets: openMarkets.length, + totalVolume, + totalBets: allBets.length, + }; + + await cache.set(cacheKey, stats, 60); + return stats; + } + + const marketsResult = await pool.query( + "SELECT COUNT(*) as total, SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as active, SUM(total_pool) as volume FROM markets" + ); + + const betsResult = await pool.query('SELECT COUNT(*) as total FROM bets'); + + const { total: totalMarkets, active: activeMarkets, volume: totalPoolStroops } = marketsResult.rows[0]; + const { total: totalBets } = betsResult.rows[0]; + + const stats: PlatformStats = { + totalMarkets: Number(totalMarkets) || 0, + activeMarkets: Number(activeMarkets) || 0, + totalVolume: (Number(totalPoolStroops) || 0) / 10_000_000, + totalBets: Number(totalBets) || 0, + }; + + await cache.set(cacheKey, stats, 60); + return stats; +} + +// --------------------------------------------------------------------------- +// Bulk operations +// --------------------------------------------------------------------------- + +export interface BulkResult { + succeeded: string[]; + failed: { id: string; reason: string }[]; +} + +/** + * Pauses (locks) up to 50 open markets in a single admin action. + * Each market is processed independently — failures do not abort others. + */ +export async function bulkPauseMarkets(marketIds: string[]): Promise { + const result: BulkResult = { succeeded: [], failed: [] }; + + for (const id of marketIds) { + try { + const { rows } = await pool.query( + `UPDATE markets SET status = 'locked', updated_at = NOW() + WHERE market_id = $1 AND status = 'open' + RETURNING market_id`, + [id], + ); + if (rows.length === 0) { + result.failed.push({ id, reason: 'Market not found or not in open status' }); + } else { + await invalidateMarketCache(id); + result.succeeded.push(id); + } + } catch (err) { + result.failed.push({ id, reason: err instanceof Error ? err.message : String(err) }); + } + } + + return result; +} + +/** + * Cancels up to 50 open/locked markets and enqueues notifications for all + * position holders of each successfully cancelled market. + * Each market is processed independently — failures do not abort others. + */ +export async function bulkCancelMarkets( + marketIds: string[], + reason: string, +): Promise { + const result: BulkResult = { succeeded: [], failed: [] }; + + for (const id of marketIds) { + try { + const { rows } = await pool.query( + `UPDATE markets SET status = 'cancelled', updated_at = NOW() + WHERE market_id = $1 AND status IN ('open', 'locked') + RETURNING market_id`, + [id], + ); + if (rows.length === 0) { + result.failed.push({ id, reason: 'Market not found or not cancellable' }); + continue; + } + + await invalidateMarketCache(id); + + // Enqueue notifications for all position holders + const bettors = await pool.query( + `SELECT DISTINCT bettor_address FROM bets WHERE market_id = $1`, + [id], + ); + if (bettors.rows.length > 0) { + const values = bettors.rows + .map((_: unknown, i: number) => `($${i * 4 + 1}, $${i * 4 + 2}, 'market_cancelled', 'pending', NOW())`) + .join(', '); + const params = bettors.rows.flatMap((r: { bettor_address: string }) => [r.bettor_address, id]); + await pool.query( + `INSERT INTO notification_jobs (bettor_address, market_id, job_type, status, created_at) VALUES ${values}`, + params, + ); + } + + result.succeeded.push(id); + } catch (err) { + result.failed.push({ id, reason: err instanceof Error ? err.message : String(err) }); + } + } + + return result; +} diff --git a/backend/src/services/StellarService.ts b/backend/src/services/StellarService.ts new file mode 100644 index 00000000..ef4a9a0a --- /dev/null +++ b/backend/src/services/StellarService.ts @@ -0,0 +1,434 @@ +// @ts-nocheck +// ============================================================ +// BOXMEOUT — Stellar Service +// Low-level Stellar SDK wrapper for contract interactions. +// Contributors: implement every function marked TODO. +// ============================================================ + +import { Account, Keypair, Networks, Operation, rpc, TransactionBuilder, xdr } from '@stellar/stellar-sdk'; + +/** + * Builds, simulates, signs, and submits a Soroban contract invocation. + * + * Steps: + * 1. Build a TransactionBuilder with source_keypair's account + * 2. Add InvokeContractHostFunction operation with method + args + * 3. Simulate via RPC to get resource fee estimates + * 4. Set transaction fee = base_fee + resource_fee + * 5. Sign with source_keypair + * 6. Submit via RPC sendTransaction + * 7. Poll getTransaction until status is SUCCESS or FAILED (max 30s) + * 8. On TIMEOUT: rebuild and resubmit with bumped fee (max 3 retries) + * + * Returns the transaction hash on SUCCESS. + * Throws StellarInvocationError on FAILED or max retries exceeded. + */ +export async function invokeContract( + contract_address: string, + method: string, + args: xdr.ScVal[], + source_keypair?: Keypair, +): Promise { + const horizonUrl = process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org'; + const rpcUrl = process.env.STELLAR_RPC_URL ?? 'https://soroban-testnet.stellar.org'; + const networkPassphrase = process.env.STELLAR_NETWORK === 'public' + ? Networks.PUBLIC + : Networks.TESTNET; + + if (!source_keypair) { + const oracleSecret = process.env.ORACLE_PRIVATE_KEY; + if (!oracleSecret) throw new Error('ORACLE_PRIVATE_KEY env var is required'); + source_keypair = Keypair.fromSecret(oracleSecret); + } + + const server = new rpc.Server(horizonUrl); + const sorobanServer = new rpc.Server(rpcUrl); + + const sourceAccount = await server.getAccount(source_keypair.publicKey()); + + const invokeContractHostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract( + new xdr.InvokeContractArgs({ + contractAddress: xdr.ScAddress.contractFromAddress(contract_address), + functionName: xdr.ScSymbol.fromString(method), + args, + }), + ); + + const baseFee = 100; // Base fee in stroops + let attempts = 0; + const maxRetries = 3; + + while (attempts < maxRetries) { + try { + // Step 1-2: Build transaction + const transaction = new TransactionBuilder(sourceAccount, { + fee: (baseFee * Math.pow(2, attempts)).toString(), + networkPassphrase, + }) + .addOperation(Operation.invokeHostFunction({ hostFunction: invokeContractHostFunction, auth: [] })) + .setTimeout(30) + .build(); + + // Step 3: Simulate to get resource fee + const simulation = await sorobanServer.simulateTransaction(transaction); + if ('error' in simulation && simulation.error) { + throw new Error(`Simulation error: ${JSON.stringify(simulation.error)}`); + } + + const simResult = simulation as { results?: Array<{ minResourceFee?: string }> }; + const minResourceFee = simResult.results?.[0]?.minResourceFee; + const resourceFee = minResourceFee ? parseInt(minResourceFee, 10) : 0; + + // Step 4: Set total fee + const totalFee = (baseFee * Math.pow(2, attempts)) + resourceFee; + transaction.fee = totalFee.toString(); + + // Step 5: Sign + transaction.sign(source_keypair); + + // Step 6: Submit + const submitResponse = await sorobanServer.sendTransaction(transaction); + + if (submitResponse.status !== 'PENDING') { + throw new Error(`Submit failed: ${submitResponse.status}`); + } + + const txHash = submitResponse.hash; + + // Step 7: Poll for result (max 30s) + const startTime = Date.now(); + const maxWait = 30_000; + + while (Date.now() - startTime < maxWait) { + const statusResponse = await sorobanServer.getTransaction(txHash); + + if (statusResponse.status === 'SUCCESS') { + return txHash; + } else if (statusResponse.status === 'FAILED') { + throw new Error(`Transaction failed: ${JSON.stringify(statusResponse.resultXdr)}`); + } + + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + // Step 8: Timeout — retry with bumped fee + throw new Error('Transaction polling timed out'); + + } catch (err) { + attempts++; + if (attempts >= maxRetries) { + throw err; + } + } + } + + throw new Error('Max retries exceeded'); +} + +/** + * Reads contract state using simulateTransaction (no fee, no state change). + * + * Steps: + * 1. Build a read-only InvokeContractHostFunction transaction + * 2. Call RPC simulateTransaction + * 3. Extract returnValue from simulation result + * 4. Call parseScVal(returnValue) and cast to type T + * + * Returns the typed result T. + * Throws if simulation fails. + */ +export async function readContractState( + contract_address: string, + method: string, + args: xdr.ScVal[], +): Promise { + const rpcUrl = process.env.STELLAR_RPC_URL; + if (!rpcUrl) throw new Error('STELLAR_RPC_URL env var is required'); + + const networkPassphrase = process.env.STELLAR_NETWORK === 'public' + ? Networks.PUBLIC + : Networks.TESTNET; + + const sorobanServer = new rpc.Server(rpcUrl); + const sourceAccount = new Account(Keypair.random().publicKey(), '0'); + + const invokeContractHostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract( + new xdr.InvokeContractArgs({ + contractAddress: xdr.ScAddress.contractFromAddress(contract_address), + functionName: xdr.ScSymbol.fromString(method), + args, + }), + ); + + const transaction = new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase, + }) + .addOperation(Operation.invokeHostFunction({ hostFunction: invokeContractHostFunction, auth: [] })) + .setTimeout(30) + .build(); + + const response = await sorobanServer.simulateTransaction(transaction); + if ('error' in response && response.error) { + throw new Error(`Simulation error: ${JSON.stringify(response.error)}`); + } + + const result = (response as Record).results?.[0] as Record; + if (!result || result.status !== 'SUCCESS') { + throw new Error( + `Simulation failed${result?.status ? `: ${result.status}` : ' without a result'}`, + ); + } + + const returnValue = result.returnValue as xdr.ScVal; + if (!returnValue) { + throw new Error('Simulation returned no returnValue'); + } + + return parseScVal(returnValue) as T; +} + +/** + * Subscribes to the Horizon event stream for a specific contract address. + * Uses Horizon's /contract_events endpoint with Server-Sent Events. + * + * Calls onEvent for every new event received. + * Automatically reconnects on connection drop (exponential backoff). + * + * Returns an unsubscribe function that stops the stream. + */ +export function subscribeToContractEvents( + contract_address: string, + onEvent: (event: unknown) => void, +): () => void { + const horizonUrl = process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org'; + let eventSource: EventSource | null = null; + let reconnectAttempts = 0; + const maxReconnectAttempts = 10; + let backoffMs = 1000; + let isUnsubscribed = false; + + const connect = () => { + if (isUnsubscribed) return; + + const url = `${horizonUrl}/contract_events?contract_id=${contract_address}`; + eventSource = new EventSource(url); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + onEvent(data); + reconnectAttempts = 0; + backoffMs = 1000; + } catch (err) { + console.error('[StellarService] Failed to parse event:', err); + } + }; + + eventSource.onerror = () => { + if (isUnsubscribed) return; + eventSource?.close(); + eventSource = null; + + if (reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++; + const delay = Math.min(backoffMs * Math.pow(2, reconnectAttempts - 1), 30000); + console.log(`[StellarService] Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`); + setTimeout(connect, delay); + } else { + console.error('[StellarService] Max reconnection attempts exceeded'); + } + }; + }; + + connect(); + + return () => { + isUnsubscribed = true; + if (eventSource) { + eventSource.close(); + eventSource = null; + } + }; +} + +/** + * Converts a raw XDR ScVal into a JavaScript-native value. + * + * Handles the following ScVal variants: + * ScvBool → boolean + * ScvU32 → number + * ScvI32 → number + * ScvU64 → bigint + * ScvI128 → bigint + * ScvString → string + * ScvAddress → string (G... format) + * ScvVec → unknown[] + * ScvMap → Record + * ScvSymbol → string + * + * Throws ParseError for unsupported variants. + */ +export function parseScVal(scval: xdr.ScVal): unknown { + const value = scval as Record; + const type = scval.switch(); + + if (type === xdr.ScValType.scvBool()) return (value.b as () => boolean)?.(); + if (type === xdr.ScValType.scvU32()) return (value.u32 as () => number)?.(); + if (type === xdr.ScValType.scvI32()) return (value.i32 as () => number)?.(); + if (type === xdr.ScValType.scvU64()) { + const u64 = (value.u64 as () => bigint)?.(); + return typeof u64 === 'bigint' ? u64 : u64?.toString(); + } + if (type === xdr.ScValType.scvI128()) { + const i128 = (value.i128 as () => bigint)?.(); + return typeof i128 === 'bigint' ? i128 : i128?.toString(); + } + if (type === xdr.ScValType.scvString()) return (value.str as () => string)?.(); + if (type === xdr.ScValType.scvAddress()) return (value.address as () => string)?.(); + if (type === xdr.ScValType.scvSymbol()) return (value.sym as () => string)?.(); + if (type === xdr.ScValType.scvVec()) { + return (value.vec as () => xdr.ScVal[])()?.map((item: xdr.ScVal) => parseScVal(item)); + } + if (type === xdr.ScValType.scvMap()) { + const mapEntries = (value.map as () => Array<{ key: () => xdr.ScVal; value: () => xdr.ScVal }>)?.() ?? []; + const output: Record = {}; + for (const entry of mapEntries) { + const key = parseScVal(entry.key()); + const mappedKey = typeof key === 'string' ? key : String(key); + output[mappedKey] = parseScVal(entry.value()); + } + return output; + } + + throw new Error(`Unsupported ScVal type: ${type}`); +} + +/** + * Returns the current recommended base fee in stroops from the Stellar network. + * Calls Horizon /fee_stats endpoint and returns the p70 fee. + * Used to set appropriate transaction fees to avoid rejection. + */ +export async function getCurrentBaseFee(): Promise { + const horizonUrl = process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org'; + const server = new Server(horizonUrl); + const feeStats = await server.feeStats(); + return parseInt(feeStats.p70_accepted_fee, 10); +} + +/** + * Fetches historical events from Horizon for a given ledger range. + * Paginates through all pages automatically. + * Returns events in chronological order. + * Handles rate limiting with automatic retry. + */ +export async function fetchHistoricalEvents( + fromLedger: number, + toLedger?: number, +): Promise> { + const horizonUrl = process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org'; + const factoryContract = process.env.FACTORY_CONTRACT_ADDRESS || ''; + const treasuryContract = process.env.TREASURY_CONTRACT_ADDRESS || ''; + + const server = new Server(horizonUrl); + const events: Array<{ + contract_address: string; + event_type: string; + topics: string[]; + data: string; + ledger_sequence: number; + ledger_close_time: string; + tx_hash: string; + }> = []; + let cursor = ''; + const limit = 200; + let retries = 0; + const maxRetries = 3; + + while (retries < maxRetries) { + try { + const params: Record = { + limit, + order: 'asc', + cursor, + }; + + if (toLedger) { + params.to_ledger = toLedger; + } + + const response = await (server as any).transactions() + .forLedger(fromLedger) + .call(params); + + if (!response.records || response.records.length === 0) { + break; + } + + for (const tx of response.records) { + if (!tx.operations_url) continue; + + try { + const opsResponse = await fetch(tx.operations_url); + const opsData = await opsResponse.json() as { records?: Array<{ type?: string; [key: string]: unknown }> }; + + if (!opsData.records) continue; + + for (const op of opsData.records) { + if (op.type !== 'invoke_host_function') continue; + + const event = { + contract_address: (op as any).contract_id || '', + event_type: (op as any).function || 'unknown', + topics: [], + data: JSON.stringify(op), + ledger_sequence: tx.ledger_attr || 0, + ledger_close_time: tx.created_at || new Date().toISOString(), + tx_hash: tx.hash || '', + }; + + if ([factoryContract, treasuryContract].includes(event.contract_address)) { + events.push(event); + } + } + } catch (err) { + console.error('[StellarService] Error fetching operations:', err); + } + } + + cursor = response.records[response.records.length - 1]?.paging_token || ''; + if (!cursor) break; + + retries = 0; + } catch (err) { + retries++; + if (retries >= maxRetries) { + console.error('[StellarService] Max retries exceeded fetching historical events:', err); + throw err; + } + const delay = Math.pow(2, retries) * 1000; + console.log(`[StellarService] Rate limited, retrying in ${delay}ms`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + return events; +} + +export interface RawStellarEvent { + contract_address: string; + event_type: string; + topics: string[]; + data: string; + ledger_sequence: number; + ledger_close_time: string; + tx_hash: string; +} diff --git a/backend/src/services/cache.service.ts b/backend/src/services/cache.service.ts new file mode 100644 index 00000000..37f2ac75 --- /dev/null +++ b/backend/src/services/cache.service.ts @@ -0,0 +1,110 @@ +// ============================================================ +// BOXMEOUT — Centralized Cache Service +// Provides Redis cache operations with automatic invalidation. +// Namespaced keys: market:{id}, leaderboard:global:*, user:{id}:balance +// ============================================================ + +import { redis } from '../config/redis'; +import { logger } from '../utils/logger'; + +export { redis }; + +/** + * Get a value from cache by key. + * Returns null if key doesn't exist or Redis is unavailable. + */ +export async function get(key: string): Promise { + try { + const data = await redis.get(key); + return data ? (JSON.parse(data) as T) : null; + } catch (err) { + logger.warn({ err, key }, 'cache.get: Redis unavailable, bypassing cache'); + return null; + } +} + +/** + * Set a value in cache with TTL in seconds. + */ +export async function set(key: string, value: unknown, ttl_seconds: number): Promise { + try { + await redis.set(key, JSON.stringify(value), 'EX', ttl_seconds); + } catch (err) { + logger.warn({ err, key }, 'cache.set: Redis unavailable, bypassing cache'); + } +} + +/** + * Delete a single cache key. + */ +export async function del(key: string): Promise { + try { + await redis.del(key); + } catch (err) { + logger.warn({ err, key }, 'cache.del: Redis unavailable, bypassing cache'); + } +} + +/** + * Delete all keys matching a pattern using Redis SCAN. + * Pattern examples: 'market:*', 'leaderboard:global:*', 'user:*:balance' + */ +export async function delPattern(pattern: string): Promise { + try { + const keys: string[] = []; + let cursor = '0'; + do { + const [nextCursor, batch] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + keys.push(...batch); + } while (cursor !== '0'); + + if (keys.length > 0) { + await redis.del(...keys); + logger.info({ pattern, count: keys.length }, 'cache.delPattern: Invalidated keys'); + } + } catch (err) { + logger.warn({ err, pattern }, 'cache.delPattern: Redis unavailable, bypassing cache'); + } +} + +/** + * Get a value from cache, or compute and cache it if missing. + * @param key - Cache key + * @param ttl_seconds - TTL for cached value + * @param compute - Function to compute value if cache miss + */ +export async function getOrSet( + key: string, + ttl_seconds: number, + compute: () => Promise, +): Promise { + try { + // Try to get from cache first + const cached = await get(key); + if (cached !== null) { + return cached; + } + + // Cache miss - compute value + const value = await compute(); + + // Store in cache + await set(key, value, ttl_seconds); + + return value; + } catch (err) { + logger.warn({ err, key }, 'cache.getOrSet: Error, computing without cache'); + // If cache operations fail, just compute and return + return compute(); + } +} + +// ============================================================ +// Legacy aliases for backward compatibility +// ============================================================ + +export const cacheGet = get; +export const cacheSet = set; +export const cacheDelete = del; +export const cacheDeletePattern = delPattern; diff --git a/backend/src/services/metrics.service.ts b/backend/src/services/metrics.service.ts new file mode 100644 index 00000000..7eecaeec --- /dev/null +++ b/backend/src/services/metrics.service.ts @@ -0,0 +1,40 @@ +import { Counter, Gauge, register } from 'prom-client'; + +register.setDefaultLabels({ app: 'boxmeout' }); + +export const cronSessionsDeleted = new Counter({ + name: 'cron_sessions_deleted_total', + help: 'Total expired user_sessions rows deleted by cleanup cron', +}); + +export const cronResetTokensDeleted = new Counter({ + name: 'cron_reset_tokens_deleted_total', + help: 'Total expired password_reset_tokens rows deleted by cleanup cron', +}); + +export const cronNotificationsSoftDeleted = new Counter({ + name: 'cron_notifications_soft_deleted_total', + help: 'Total notification_jobs rows soft-deleted by cleanup cron', +}); + +export const cronDistributionsArchived = new Counter({ + name: 'cron_distributions_archived_total', + help: 'Total failed distributions rows archived by cleanup cron', +}); + +export const wsConnectedClients = new Gauge({ + name: 'ws_connected_clients', + help: 'Number of currently connected WebSocket clients', +}); + +export const wsMessagesPublishedTotal = new Counter({ + name: 'ws_messages_published_total', + help: 'Total WebSocket activity events published to Redis', +}); + +export const wsMessagesDroppedTotal = new Counter({ + name: 'ws_messages_dropped_total', + help: 'Total WebSocket messages dropped due to client backpressure', +}); + +export { register }; diff --git a/backend/src/utils/AppError.ts b/backend/src/utils/AppError.ts new file mode 100644 index 00000000..909645b0 --- /dev/null +++ b/backend/src/utils/AppError.ts @@ -0,0 +1,36 @@ +export class AppError extends Error { + constructor( + public readonly statusCode: number, + message: string, + public readonly code?: string, + public readonly details?: unknown, + ) { + super(message); + this.name = 'AppError'; + Object.setPrototypeOf(this, AppError.prototype); + } + + static badRequest(message: string, code?: string, details?: unknown): AppError { + return new AppError(400, message, code, details); + } + + static unauthorized(message: string = 'Unauthorized', code?: string, details?: unknown): AppError { + return new AppError(401, message, code, details); + } + + static forbidden(message: string = 'Forbidden', code?: string, details?: unknown): AppError { + return new AppError(403, message, code, details); + } + + static notFound(message: string = 'Not found', code?: string, details?: unknown): AppError { + return new AppError(404, message, code, details); + } + + static conflict(message: string, code?: string, details?: unknown): AppError { + return new AppError(409, message, code, details); + } + + static internalError(message: string = 'Internal server error', code?: string, details?: unknown): AppError { + return new AppError(500, message, code, details); + } +} diff --git a/backend/src/utils/__mocks__/logger.ts b/backend/src/utils/__mocks__/logger.ts new file mode 100644 index 00000000..45b88a90 --- /dev/null +++ b/backend/src/utils/__mocks__/logger.ts @@ -0,0 +1,6 @@ +export const logger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +}; diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts new file mode 100644 index 00000000..37732302 --- /dev/null +++ b/backend/src/utils/logger.ts @@ -0,0 +1,10 @@ +import pino from 'pino'; + +const isDev = process.env.NODE_ENV !== 'production'; + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? 'info', + ...(isDev && { + transport: { target: 'pino-pretty', options: { colorize: true } }, + }), +}); diff --git a/backend/src/websocket/realtime.ts b/backend/src/websocket/realtime.ts new file mode 100644 index 00000000..2505236a --- /dev/null +++ b/backend/src/websocket/realtime.ts @@ -0,0 +1,260 @@ +import { EventEmitter } from 'events'; +import { WebSocketServer, WebSocket } from 'ws'; +import type { IncomingMessage } from 'http'; +import type { Server } from 'http'; +import { + connectRedisClients, + MARKET_EVENTS_PATTERN, + marketEventChannel, + parseMarketIdFromChannel, + redis, + redisSub, +} from '../config/redis'; +import { + wsConnectedClients, + wsMessagesDroppedTotal, + wsMessagesPublishedTotal, +} from '../services/metrics.service'; +import { logger } from '../utils/logger'; + +const BUFFER_THRESHOLD = Number(process.env.WS_BUFFER_THRESHOLD_BYTES ?? 16384); +const HEARTBEAT_INTERVAL_MS = 30_000; +const GOING_AWAY = 1001; + +// --------------------------------------------------------------------------- +// Internal event bus — used by BetService to notify RiskEngine of new bets +// without polling. Keeps the risk engine decoupled from the WS layer. +// --------------------------------------------------------------------------- +const _betPlacedBus = new EventEmitter(); +_betPlacedBus.setMaxListeners(20); + +export function emitBetPlaced(marketId: string): void { + _betPlacedBus.emit('BetPlaced', marketId); +} + +export function onBetPlaced(handler: (marketId: string) => void): void { + _betPlacedBus.on('BetPlaced', handler); +} + +export function offBetPlaced(handler: (marketId: string) => void): void { + _betPlacedBus.off('BetPlaced', handler); +} + +// --------------------------------------------------------------------------- +// Event types +// --------------------------------------------------------------------------- +export type ActivityEvent = + | { type: 'trade'; marketId: string; outcomeId: string; side: string; sharesAmount: number; priceBps: number; timestamp: string } + | { type: 'dispute'; marketId: string; proposedOutcomeId: string } + | { type: 'resolved'; marketId: string; winningOutcomeId: string }; + +type SubscribeMsg = { type: 'subscribe_activity'; marketId: string }; + +// --------------------------------------------------------------------------- +// Rate limiter — token bucket, max 20 events/sec per market +// --------------------------------------------------------------------------- +const RATE_LIMIT = 20; +const WINDOW_MS = 1_000; + +class MarketRateLimiter { + private counts = new Map(); + + allow(marketId: string): boolean { + const now = Date.now(); + let entry = this.counts.get(marketId); + if (!entry || now >= entry.resetAt) { + entry = { count: 0, resetAt: now + WINDOW_MS }; + this.counts.set(marketId, entry); + } + if (entry.count >= RATE_LIMIT) return false; + entry.count++; + return true; + } +} + +const _rateLimiter = new MarketRateLimiter(); +const _feeds = new Set(); +let _subscriberReady = false; + +/** Publish an activity event to Redis for all cluster instances to forward locally. */ +export function publishEvent(marketId: string, event: ActivityEvent): void { + if (!_rateLimiter.allow(marketId)) return; + + void redis.publish(marketEventChannel(marketId), JSON.stringify(event)).catch((err) => { + logger.error({ err, marketId }, 'Failed to publish WebSocket event to Redis'); + }); + wsMessagesPublishedTotal.inc(); +} + +async function ensureRedisSubscriber(): Promise { + if (_subscriberReady) return; + + await redisSub.psubscribe(MARKET_EVENTS_PATTERN); + redisSub.on('pmessage', (_pattern: string, channel: string, message: string) => { + const marketId = parseMarketIdFromChannel(channel); + if (!marketId) return; + + for (const feed of _feeds) { + feed.forwardToLocalClients(marketId, message); + } + }); + + _subscriberReady = true; + logger.info('Redis pub/sub subscriber listening on market:*:events'); +} + +// --------------------------------------------------------------------------- +// ActivityFeed +// --------------------------------------------------------------------------- +export class ActivityFeed { + private wss: WebSocketServer; + // marketId → set of subscribed sockets + private subscriptions = new Map>(); + private heartbeatTimer: ReturnType | null = null; + private acceptingConnections = true; + private shutDown = false; + + constructor(server: Server) { + // Path `/` keeps activity clients on the root URL and leaves `/graphql` + // for the GraphQL subscription WebSocket server. + this.wss = new WebSocketServer({ + server, + path: '/', + verifyClient: (_info, done) => done(this.acceptingConnections), + }); + this.wss.on('connection', (ws: WebSocket, _req: IncomingMessage) => { + wsConnectedClients.inc(); + (ws as WebSocket & { isAlive: boolean }).isAlive = true; + + ws.on('message', (raw) => this.handleMessage(ws, raw.toString())); + ws.on('pong', () => { + (ws as WebSocket & { isAlive: boolean }).isAlive = true; + }); + ws.on('close', () => { + wsConnectedClients.dec(); + this.removeSocket(ws); + }); + }); + + this.heartbeatTimer = setInterval(() => this.pingClients(), HEARTBEAT_INTERVAL_MS); + this.heartbeatTimer.unref(); + _feeds.add(this); + logger.info('ActivityFeed WebSocket server attached'); + } + + private pingClients(): void { + for (const client of this.wss.clients) { + const ws = client as WebSocket & { isAlive: boolean }; + if (!ws.isAlive) { + ws.terminate(); + continue; + } + ws.isAlive = false; + ws.ping(); + } + } + + private handleMessage(ws: WebSocket, raw: string): void { + let msg: unknown; + try { msg = JSON.parse(raw); } catch { return; } + + const { type, marketId } = msg as SubscribeMsg; + if (type !== 'subscribe_activity' || typeof marketId !== 'string') return; + + if (!this.subscriptions.has(marketId)) { + this.subscriptions.set(marketId, new Set()); + } + this.subscriptions.get(marketId)!.add(ws); + } + + private removeSocket(ws: WebSocket): void { + for (const sockets of this.subscriptions.values()) { + sockets.delete(ws); + } + } + + /** Forward a Redis pub/sub payload to locally connected subscribers of the market. */ + forwardToLocalClients(marketId: string, payload: string): void { + const sockets = this.subscriptions.get(marketId); + if (!sockets?.size) return; + + for (const ws of sockets) { + if (ws.readyState !== WebSocket.OPEN) continue; + + if (ws.bufferedAmount > BUFFER_THRESHOLD) { + wsMessagesDroppedTotal.inc(); + continue; + } + + ws.send(payload); + } + } + + /** Publish an activity event via Redis (backward-compatible wrapper). */ + publish(event: ActivityEvent): void { + const { marketId } = event as { marketId: string }; + publishEvent(marketId, event); + } + + async shutdown(): Promise { + if (this.shutDown) return; + this.shutDown = true; + this.acceptingConnections = false; + + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + + for (const ws of this.wss.clients) { + ws.close(GOING_AWAY, 'Going Away'); + } + + await new Promise((resolve) => { + this.wss.close(() => resolve()); + }); + + this.subscriptions.clear(); + _feeds.delete(this); + + if (_feeds.size === 0 && _subscriberReady) { + await redisSub.punsubscribe(MARKET_EVENTS_PATTERN); + _subscriberReady = false; + } + } + + close(): void { + void this.shutdown(); + } +} + +// Singleton — initialised once in src/index.ts +let _feed: ActivityFeed | null = null; + +export async function initActivityFeed(server: Server): Promise { + await connectRedisClients(); + await ensureRedisSubscriber(); + _feed = new ActivityFeed(server); + return _feed; +} + +export function getActivityFeed(): ActivityFeed { + if (!_feed) throw new Error('ActivityFeed not initialised'); + return _feed; +} + +export async function shutdownActivityFeed(): Promise { + if (!_feed) return; + + await _feed.shutdown(); + _feed = null; +} + +/** Test helper: wire Redis subscriber without creating the singleton feed. */ +export async function initRedisSubscriberForTest(feed: ActivityFeed): Promise { + await connectRedisClients(); + await ensureRedisSubscriber(); + _feeds.add(feed); +} + +export { redisSub, BUFFER_THRESHOLD }; diff --git a/backend/tests/graphql/complexity.test.ts b/backend/tests/graphql/complexity.test.ts new file mode 100644 index 00000000..c7cc8f43 --- /dev/null +++ b/backend/tests/graphql/complexity.test.ts @@ -0,0 +1,76 @@ +import { parse } from 'graphql'; +import { assertQueryComplexity, DEFAULT_MAX_COMPLEXITY } from '../../src/graphql/complexity'; +import { executeGraphQL } from '../../src/graphql/execute'; +import { schema } from '../../src/graphql/schema'; +import { createLoaders } from '../../src/graphql/dataloaders'; + +jest.mock('../../src/services/cache.service', () => ({ + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + redis: { + incr: jest.fn().mockResolvedValue(1), + expire: jest.fn().mockResolvedValue(1), + ttl: jest.fn().mockResolvedValue(60), + del: jest.fn().mockResolvedValue(1), + }, +})); + +describe('GraphQL query complexity limits', () => { + it('rejects queries exceeding max depth', () => { + const document = parse(` + query { + user(address: "G") { + bets { + market { + bets { + market { + bets { + market { + bets { + market { marketId } + } + } + } + } + } + } + } + } + } + `); + + expect(() => assertQueryComplexity(document, { maxDepth: 5 })).toThrow(/depth/i); + }); + + it('rejects queries exceeding max complexity via executeGraphQL', async () => { + await expect( + executeGraphQL({ + schema, + query: ` + query { + markets { + edges { + node { + bets { id market { bets { id market { bets { id } } } } } + positions { id market { positions { id } } } + trades { id market { trades { id } } } + } + } + } + } + `, + skipRateLimit: true, + maxComplexity: 50, + context: { loaders: createLoaders(), userId: null, clientIp: '127.0.0.1', identity: 'test' }, + }), + ).rejects.toMatchObject({ + extensions: { code: 'QUERY_COMPLEXITY_EXCEEDED' }, + }); + }); + + it('allows simple queries under the default budget', () => { + const document = parse(`{ schemaInfo { version } market(id: "x") { marketId } }`); + const { complexity } = assertQueryComplexity(document); + expect(complexity).toBeLessThan(DEFAULT_MAX_COMPLEXITY); + }); +}); diff --git a/backend/tests/graphql/dataloader.test.ts b/backend/tests/graphql/dataloader.test.ts new file mode 100644 index 00000000..3a730d21 --- /dev/null +++ b/backend/tests/graphql/dataloader.test.ts @@ -0,0 +1,107 @@ +import { setDbAdapter } from '../../src/services/MarketService'; +import type { Market } from '../../src/models/Market'; +import type { Bet } from '../../src/models/Bet'; +import { executeGraphQL } from '../../src/graphql/execute'; +import { schema } from '../../src/graphql/schema'; +import { createLoaders } from '../../src/graphql/dataloaders'; + +jest.mock('../../src/services/cache.service', () => ({ + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + del: jest.fn().mockResolvedValue(undefined), + delPattern: jest.fn().mockResolvedValue(undefined), + redis: { + incr: jest.fn().mockResolvedValue(1), + expire: jest.fn().mockResolvedValue(1), + ttl: jest.fn().mockResolvedValue(60), + del: jest.fn().mockResolvedValue(1), + }, +})); + +jest.mock('../../src/services/StellarService', () => ({ + readContractState: jest.fn(), + submitTransaction: jest.fn(), +})); + +function makeMarket(id: string, n: number): Market { + return { + id: n, + market_id: id, + contract_address: 'C...', + match_id: `fight-${n}`, + fighter_a: 'A', + fighter_b: 'B', + weight_class: 'heavyweight', + title_fight: false, + venue: 'Arena', + scheduled_at: new Date(`2026-0${n}-01T00:00:00Z`), + status: 'open', + outcome: null, + pool_a: '0', + pool_b: '0', + pool_draw: '0', + total_pool: '0', + fee_bps: 200, + lock_before_secs: 3600, + resolved_at: null, + oracle_used: null, + created_at: new Date(), + updated_at: new Date(), + ledger_sequence: 1000 + n, + }; +} + +describe('GraphQL DataLoader N+1 prevention', () => { + it('batches betsByMarket loads across nested market fields', async () => { + const markets = [makeMarket('mkt-1', 1), makeMarket('mkt-2', 2), makeMarket('mkt-3', 3)]; + const findBetsByMarket = jest.fn().mockImplementation(async (marketId: string) => { + const bet: Bet = { + id: Number(marketId.replace(/\D/g, '')) || 1, + market_id: marketId, + bettor_address: 'GABC', + side: 'fighter_a', + amount: '10000000', + amount_xlm: 1, + placed_at: new Date(), + claimed: false, + claimed_at: null, + payout: null, + tx_hash: 'tx', + ledger_sequence: 1, + }; + return [bet]; + }); + + setDbAdapter({ + findMarkets: jest.fn().mockResolvedValue(markets), + findMarketById: jest.fn().mockImplementation((id: string) => + Promise.resolve(markets.find((m) => m.market_id === id) ?? null), + ), + findBetsByAddress: jest.fn().mockResolvedValue([]), + findBetsByMarket, + updateMarketStatus: jest.fn(), + }); + + const result = await executeGraphQL({ + schema, + query: ` + query { + m1: market(id: "mkt-1") { bets { id } positions { id } trades { id } } + m2: market(id: "mkt-2") { bets { id } positions { id } trades { id } } + m3: market(id: "mkt-3") { bets { id } positions { id } trades { id } } + } + `, + skipRateLimit: true, + context: { loaders: createLoaders(), userId: null, clientIp: '127.0.0.1', identity: 'test' }, + }); + + expect(result.errors).toBeUndefined(); + // One batched load per market id (cached for bets/positions/trades on same parent) + expect(findBetsByMarket.mock.calls.length).toBe(3); + expect(findBetsByMarket.mock.calls.map((c) => c[0]).sort()).toEqual([ + 'mkt-1', + 'mkt-2', + 'mkt-3', + ]); + }); +}); diff --git a/backend/tests/graphql/query.test.ts b/backend/tests/graphql/query.test.ts new file mode 100644 index 00000000..70de44f0 --- /dev/null +++ b/backend/tests/graphql/query.test.ts @@ -0,0 +1,164 @@ +import { setDbAdapter } from '../../src/services/MarketService'; +import type { Market } from '../../src/models/Market'; +import type { Bet } from '../../src/models/Bet'; +import { executeGraphQL } from '../../src/graphql/execute'; +import { schema } from '../../src/graphql/schema'; +import { createLoaders } from '../../src/graphql/dataloaders'; + +jest.mock('../../src/services/cache.service', () => ({ + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + del: jest.fn().mockResolvedValue(undefined), + delPattern: jest.fn().mockResolvedValue(undefined), + redis: { + incr: jest.fn().mockResolvedValue(1), + expire: jest.fn().mockResolvedValue(1), + ttl: jest.fn().mockResolvedValue(60), + del: jest.fn().mockResolvedValue(1), + }, +})); + +jest.mock('../../src/services/StellarService', () => ({ + readContractState: jest.fn(), + submitTransaction: jest.fn(), +})); + +function makeMarket(overrides: Partial = {}): Market { + return { + id: 1, + market_id: 'mkt-1', + contract_address: 'C...', + match_id: 'fight-1', + fighter_a: 'Ali', + fighter_b: 'Frazier', + weight_class: 'heavyweight', + title_fight: true, + venue: 'MSG', + scheduled_at: new Date('2026-06-01T00:00:00Z'), + status: 'open', + outcome: null, + pool_a: '0', + pool_b: '0', + pool_draw: '0', + total_pool: '0', + fee_bps: 200, + lock_before_secs: 3600, + resolved_at: null, + oracle_used: null, + created_at: new Date('2026-01-01T00:00:00Z'), + updated_at: new Date(), + ledger_sequence: 1000, + ...overrides, + }; +} + +function makeBet(overrides: Partial = {}): Bet { + return { + id: 1, + market_id: 'mkt-1', + bettor_address: 'GABC', + side: 'fighter_a', + amount: '10000000', + amount_xlm: 1, + placed_at: new Date('2026-05-01T00:00:00Z'), + claimed: false, + claimed_at: null, + payout: null, + tx_hash: 'tx1', + ledger_sequence: 1001, + ...overrides, + }; +} + +const OPEN = makeMarket({ market_id: 'mkt-1', status: 'open' }); +const LOCKED = makeMarket({ + id: 2, + market_id: 'mkt-2', + status: 'locked', + weight_class: 'welterweight', + fighter_a: 'Leonard', + fighter_b: 'Hearns', + title_fight: false, + scheduled_at: new Date('2026-07-01T00:00:00Z'), +}); + +describe('GraphQL market queries', () => { + beforeEach(() => { + setDbAdapter({ + findMarkets: jest.fn().mockResolvedValue([OPEN, LOCKED]), + findMarketById: jest.fn().mockImplementation((id: string) => + Promise.resolve([OPEN, LOCKED].find((m) => m.market_id === id) ?? null), + ), + findBetsByAddress: jest.fn().mockResolvedValue([makeBet()]), + findBetsByMarket: jest.fn().mockImplementation((marketId: string) => + Promise.resolve( + [makeBet(), makeBet({ id: 2, bettor_address: 'GDEF', side: 'fighter_b' })].filter( + (b) => b.market_id === marketId, + ), + ), + ), + updateMarketStatus: jest.fn(), + }); + }); + + it('queries markets with combined filters', async () => { + const result = await executeGraphQL({ + schema, + query: ` + query { + markets(filter: { status: open, weightClass: "heavyweight", fighter: "Ali", titleFight: true }) { + pageInfo { totalCount } + edges { node { marketId fighterA status titleFight } } + } + } + `, + skipRateLimit: true, + context: { loaders: createLoaders(), userId: null, clientIp: '127.0.0.1', identity: 'test' }, + }); + + expect(result.errors).toBeUndefined(); + const data = result.data as { + markets: { pageInfo: { totalCount: number }; edges: { node: { marketId: string } }[] }; + }; + expect(data.markets.pageInfo.totalCount).toBe(1); + expect(data.markets.edges[0].node.marketId).toBe('mkt-1'); + }); + + it('resolves nested market → bets → user without errors', async () => { + const result = await executeGraphQL({ + schema, + query: ` + query { + market(id: "mkt-1") { + marketId + odds { oddsA odds_a } + bets { id bettorAddress user { address } } + positions { id side betCount } + } + } + `, + skipRateLimit: true, + context: { loaders: createLoaders(), userId: null, clientIp: '127.0.0.1', identity: 'test' }, + }); + + expect(result.errors).toBeUndefined(); + const market = (result.data as { market: { bets: unknown[]; positions: unknown[]; odds: { oddsA: number; odds_a: number } } }).market; + expect(market.bets).toHaveLength(2); + expect(market.positions.length).toBeGreaterThan(0); + expect(market.odds.oddsA).toBe(market.odds.odds_a); + }); + + it('returns schema versioning metadata', async () => { + const result = await executeGraphQL({ + schema, + query: `{ schemaInfo { version minCompatibleVersion deprecatedFields } }`, + skipRateLimit: true, + context: { loaders: createLoaders(), userId: null, clientIp: '127.0.0.1', identity: 'test' }, + }); + + expect(result.errors).toBeUndefined(); + const info = (result.data as { schemaInfo: { version: string; deprecatedFields: string[] } }).schemaInfo; + expect(info.version).toBe('1.0.0'); + expect(info.deprecatedFields).toContain('Market.market_id'); + }); +}); diff --git a/backend/tests/graphql/rate-limit.test.ts b/backend/tests/graphql/rate-limit.test.ts new file mode 100644 index 00000000..a6ab211a --- /dev/null +++ b/backend/tests/graphql/rate-limit.test.ts @@ -0,0 +1,47 @@ +import { assertGraphQLRateLimit, resetGraphQLRateLimit } from '../../src/graphql/rateLimit'; + +const incr = jest.fn(); +const expire = jest.fn(); +const ttl = jest.fn(); +const del = jest.fn(); + +jest.mock('../../src/config/redis', () => ({ + redis: { + incr: (...args: unknown[]) => incr(...args), + expire: (...args: unknown[]) => expire(...args), + ttl: (...args: unknown[]) => ttl(...args), + del: (...args: unknown[]) => del(...args), + }, +})); + +describe('GraphQL per-user rate limiting', () => { + beforeEach(() => { + incr.mockReset(); + expire.mockReset(); + ttl.mockReset(); + del.mockReset(); + }); + + it('allows requests under the max', async () => { + incr.mockResolvedValue(1); + expire.mockResolvedValue(1); + await expect(assertGraphQLRateLimit('user-1', { max: 5 })).resolves.toBeUndefined(); + expect(incr).toHaveBeenCalledWith('rl:graphql:user-1'); + expect(expire).toHaveBeenCalled(); + }); + + it('rejects when over the max with RATE_LIMITED', async () => { + incr.mockResolvedValue(6); + ttl.mockResolvedValue(42); + await expect(assertGraphQLRateLimit('user-1', { max: 5 })).rejects.toMatchObject({ + message: 'Too Many Requests', + extensions: { code: 'RATE_LIMITED', retryAfter: 42 }, + }); + }); + + it('resetGraphQLRateLimit deletes the key', async () => { + del.mockResolvedValue(1); + await resetGraphQLRateLimit('user-1'); + expect(del).toHaveBeenCalledWith('rl:graphql:user-1'); + }); +}); diff --git a/backend/tests/graphql/subscription.test.ts b/backend/tests/graphql/subscription.test.ts new file mode 100644 index 00000000..a3efd166 --- /dev/null +++ b/backend/tests/graphql/subscription.test.ts @@ -0,0 +1,38 @@ +import { graphqlPubSub } from '../../src/graphql/pubsub'; +import type { ActivityEvent } from '../../src/websocket/realtime'; + +describe('GraphQL subscription local fan-out', () => { + afterEach(async () => { + await graphqlPubSub.shutdown(); + }); + + it('delivers marketActivity via local emit in under 500ms', async () => { + const marketId = 'local-mkt-1'; + const iterator = graphqlPubSub.asyncIterator(`activity:${marketId}`); + + const event: ActivityEvent = { + type: 'trade', + marketId, + outcomeId: 'fighter_a', + side: 'fighter_a', + sharesAmount: 1_000_000, + priceBps: 5000, + timestamp: new Date().toISOString(), + }; + + const started = Date.now(); + const pending = iterator.next(); + graphqlPubSub.emitLocal(marketId, event); + + const result = await Promise.race([ + pending, + new Promise((_, reject) => + setTimeout(() => reject(new Error('subscription exceeded 500ms')), 500), + ), + ]); + + expect(Date.now() - started).toBeLessThan(500); + expect(result.value).toMatchObject({ type: 'trade', marketId }); + await iterator.return?.(); + }); +}); diff --git a/backend/tests/integration/graphql-subscription.integration.test.ts b/backend/tests/integration/graphql-subscription.integration.test.ts new file mode 100644 index 00000000..3eb039da --- /dev/null +++ b/backend/tests/integration/graphql-subscription.integration.test.ts @@ -0,0 +1,66 @@ +// Integration: GraphQL pub/sub delivers market activity in < 500ms via Redis + +import { connectRedisClients, closeRedisClients } from '../../src/config/redis'; +import { graphqlPubSub } from '../../src/graphql/pubsub'; +import type { ActivityEvent } from '../../src/websocket/realtime'; + +describe('GraphQL subscription pub/sub integration', () => { + let redisAvailable = false; + + beforeAll(async () => { + try { + await connectRedisClients(); + await graphqlPubSub.init(); + redisAvailable = true; + } catch { + redisAvailable = false; + } + }); + + afterAll(async () => { + await graphqlPubSub.shutdown(); + if (redisAvailable) { + await closeRedisClients(); + } + }); + + it('delivers marketActivity events within 500ms', async () => { + if (!redisAvailable) { + console.warn('Skipping: Redis unavailable'); + return; + } + const marketId = `gql-sub-${Date.now()}`; + const iterator = graphqlPubSub.asyncIterator(`activity:${marketId}`); + + const event: ActivityEvent = { + type: 'trade', + marketId, + outcomeId: 'fighter_a', + side: 'fighter_a', + sharesAmount: 1_000_000, + priceBps: 5000, + timestamp: new Date().toISOString(), + }; + + const started = Date.now(); + const pending = iterator.next(); + + // Allow subscriber registration to settle + await new Promise((r) => setImmediate(r)); + await graphqlPubSub.publishActivity(marketId, event); + + const result = await Promise.race([ + pending, + new Promise((_, reject) => + setTimeout(() => reject(new Error('subscription exceeded 500ms')), 500), + ), + ]); + + const elapsed = Date.now() - started; + expect(result.done).toBe(false); + expect(result.value).toMatchObject({ type: 'trade', marketId }); + expect(elapsed).toBeLessThan(500); + + await iterator.return?.(); + }); +});