TypeScript/Node.js backend for the Xelma decentralized XLM price prediction market, built on the Stellar blockchain (Soroban).
- Overview
- Key Features
- Project Structure
- Architecture
- Prerequisites
- Installation
- Environment Setup
- Running the Server
- API Documentation
- Testing
- Migration Safety
- Scripts
- Troubleshooting
Xelma Backend is the server-side component of a blockchain-based prediction market platform where users predict XLM (Stellar Lumens) price movements. The backend orchestrates:
- Real-time price data from CoinGecko
- Blockchain integration with Soroban smart contracts on Stellar
- WebSocket updates for live round status and price changes
- JWT-based authentication with wallet signature verification
- PostgreSQL database for user profiles, rounds, predictions, and stats
- Role-based access control (User, Admin, Oracle) for secure operations
- Automated scheduling for round creation, locking, and resolution
The platform supports two game modes:
- UP_DOWN - Binary predictions (price goes up or down)
- LEGENDS - Range-based predictions (price lands in specific ranges)
- ✅ Wallet-Based Authentication: Users authenticate with Stellar wallet signatures (no passwords)
- ✅ Two Game Modes: UP_DOWN (binary) and LEGENDS (range-based) prediction markets
- ✅ Real-Time Price Oracle: Polls CoinGecko every 10 seconds for XLM/USD prices
- ✅ Soroban Integration: Creates and resolves rounds on-chain via
@tevalabs/xelma-bindings - ✅ WebSocket Support: Live updates for prices, rounds, chat, and notifications
- ✅ Leaderboard System: Tracks wins, earnings, and streaks across game modes
- ✅ Automated Schedulers: Cron jobs for round creation, locking, and resolution
- ✅ Transactional Outbox: Notification and WebSocket side-effects are written atomically with DB commits — guaranteed at-least-once delivery even across process crashes
- ✅ Dead-Letter Queue: Failed dispatches are persisted and replayable via admin endpoints
- ✅ OpenAPI Documentation: Auto-generated Swagger UI at
/api-docs - ✅ Rate Limiting: Protects endpoints from abuse
- ✅ Comprehensive Logging: Winston-based logging for debugging and monitoring
Xelma-Backend/
├── src/
│ ├── index.ts # Application entry point
│ ├── socket.ts # Socket.IO initialization with JWT auth
│ │
│ ├── routes/ # Express route handlers
│ │ ├── auth.routes.ts # Authentication (login, verify)
│ │ ├── user.routes.ts # User profile management
│ │ ├── rounds.routes.ts # Round creation & resolution (admin/oracle)
│ │ ├── predictions.routes.ts # Submit & claim predictions
│ │ ├── leaderboard.routes.ts # Leaderboard & user stats
│ │ ├── education.routes.ts # Educational tips
│ │ ├── chat.routes.ts # Chat message submission
│ │ └── notifications.routes.ts # User notifications
│ │
│ ├── services/ # Business logic layer
│ │ ├── oracle.ts # Price fetching from CoinGecko
│ │ ├── soroban.service.ts # Soroban contract interaction
│ │ ├── round.service.ts # Round lifecycle management
│ │ ├── prediction.service.ts # Prediction submission & validation
│ │ ├── resolution.service.ts # Round resolution & payout calculation
│ │ ├── leaderboard.service.ts # Leaderboard data aggregation
│ │ ├── websocket.service.ts # WebSocket event emissions
│ │ ├── notification.service.ts # Notification creation & delivery
│ │ ├── education-tip.service.ts# Educational content management
│ │ ├── chat.service.ts # Chat message handling
│ │ ├── scheduler.service.ts # General cron job scheduler
│ │ └── round-scheduler.service.ts # Round creation/locking scheduler
│ │
│ ├── middleware/ # Express middleware
│ │ ├── auth.middleware.ts # JWT verification & role checking
│ │ └── rateLimiter.middleware.ts # Rate limiting configuration
│ │
│ ├── utils/ # Utility functions
│ │ ├── logger.ts # Winston logger setup
│ │ ├── jwt.util.ts # JWT generation & verification
│ │ └── challenge.util.ts # Wallet challenge generation
│ │
│ ├── types/ # TypeScript type definitions
│ │ ├── auth.types.ts # Authentication types
│ │ ├── round.types.ts # Round & game mode types
│ │ ├── leaderboard.types.ts # Leaderboard types
│ │ ├── education.types.ts # Education tip types
│ │ ├── chat.types.ts # Chat message types
│ │ ├── prisma.types.ts # Prisma client extensions
│ │ └── xelma-bindings.d.ts # Xelma bindings type stubs
│ │
│ ├── lib/
│ │ └── prisma.ts # Prisma client instance
│ │
│ ├── docs/
│ │ └── openapi.ts # OpenAPI/Swagger configuration
│ │
│ ├── scripts/
│ │ ├── generate-openapi.ts # Generate OpenAPI JSON
│ │ └── export-postman.ts # Export Postman collection
│ │
│ └── tests/ # Jest test suites
│ ├── education-tip.service.spec.ts
│ ├── education-tip.route.spec.ts
│ └── round.spec.ts
│
├── prisma/
│ ├── schema.prisma # Prisma database schema
│ ├── migrations/ # Database migrations
│ └── seed.ts # Database seeding script
│
├── dist/ # Compiled JavaScript output
├── docs/ # Additional documentation
├── .env.example # Environment variables template
├── package.json # Project dependencies & scripts
├── tsconfig.json # TypeScript configuration
├── jest.config.ts # Jest testing configuration
└── README.md # This file
The hackathon app and the production app share the same services, but the data backend can be switched per-endpoint via environment flags.
| Endpoint | DATA_MODE=live (default) |
DATA_MODE=mock |
|---|---|---|
GET /api/prices |
CoinGecko API (30 s cache) | Static in-memory array (mockData.prices in src/data/mockData.ts) |
GET /api/price |
Production XLM oracle providers | Same oracle path (production app only; not mounted on hackathon) |
GET /api/rounds |
Drizzle / Postgres (hackathon_rounds table) |
Same — Drizzle is always used for rounds |
GET /api/leaderboard |
Drizzle / Postgres leaderboard table | In-memory seed (mockLeaderboard in src/data/mockData.ts) when DATA_STORE=memory |
GET /api/stats |
Prisma / Postgres aggregation | MOCK_PLATFORM_STATS constants (zero-value defaults) |
GET /api/health → soroban |
Live soroban.isReady() flag |
Same — no extra network call; reflects initialization state only |
Controlling flags (set in .env or as environment variables):
| Variable | Values | Effect |
|---|---|---|
DATA_MODE |
live (default), mock |
Switches price source and stats fallback |
DATA_STORE |
postgres (default), memory |
Switches repository adapter for rounds, leaderboard, bets |
SOROBAN_CONTRACT_ID |
contract address or unset | When unset, Soroban service disables and health shows unavailable |
See src/data/mockData.ts for the full in-memory seed data and fallback constants.
Runtime modes reference: For the complete flag matrix (DATA_MODE, BET_STUB_MODE, ROUNDS_MOCK_MODE), recommended combinations, and interaction diagrams, see docs/runtime-modes.md.
The repo has two Express applications. New contributors should always use npm run dev.
| Script | File | Use when |
|---|---|---|
npm run dev |
src/index.ts |
Everyday development — full backend, real DB, WebSocket, Soroban |
npm run dev:hackathon |
src/server.ts |
Demo without a database — mock data only |
npm start / npm run start:full |
dist/index.js (compiled src/index.ts) |
Production Render start command — full backend (compiled) |
npm run start:hackathon |
dist/server.js (compiled src/server.ts) |
Hackathon Render start command — demo server (compiled) |
See docs/architecture.md for the full architecture decision, file map, migration plan, and a checklist for adding new routes.
- Purpose: Fetches real-time XLM/USD price from CoinGecko
- Polling Interval: Every 10 seconds
- Singleton Pattern: Single instance across the application
- Used By: Round service, WebSocket service for price updates
- Purpose: Interfaces with Soroban smart contracts on Stellar blockchain
- Capabilities:
- Create new rounds on-chain
- Lock rounds for betting
- Resolve rounds with final prices
- Mint initial tokens for users
- Place bets and claim winnings
- Configuration: Requires
SOROBAN_CONTRACT_ID, admin & oracle keypairs - Failsafe: Gracefully disables if configuration is missing
- Purpose: Manages the complete lifecycle of prediction rounds
- Responsibilities:
- Start new rounds (UP_DOWN or LEGENDS mode)
- Lock rounds when betting period ends
- Fetch active, locked, and upcoming rounds
- Calculate pool sizes (UP vs DOWN pools)
- Integrations: Soroban service, WebSocket service, notification service
- Purpose: Handles user bet submissions
- Validations:
- Round is active and not locked
- User has sufficient balance
- No duplicate predictions per round
- Correct prediction format (side for UP_DOWN, range for LEGENDS)
- Actions:
- Deducts user balance
- Calls Soroban contract to place bet
- Updates round pool sizes
- Emits WebSocket events
- Purpose: Resolves completed rounds and distributes winnings
- Process:
- Fetch final price from oracle
- Update round status to RESOLVED
- Calculate payouts for winning predictions
- Update user stats (wins, earnings, streaks)
- Call Soroban contract to finalize round
- Send win/loss notifications
- Payout Formula: Proportional to bet size and total pool ratio
- Purpose: Aggregates and ranks user performance data
- Metrics:
- Total earnings
- Win/loss counts per game mode
- Current win streak
- Accuracy percentage
- Queries: Optimized database queries with pagination support
- Materialized sorted set: When Redis is available, a Redis sorted set
(
ZSET) stores every user'stotalEarningsas the score. Rank lookups become O(log N) instead of a full-tableCOUNT(*). The set is kept in sync after everyupdateUserStatsForRoundcall and invalidated whenever the leaderboard namespace is flushed. The DB path is always the fallback when Redis is unavailable.
- Purpose: Broadcasts real-time events to connected clients
- Events:
price_update- New XLM price every 5 secondsround_update- Round status changes (created, locked, resolved)user_balance_update- User balance changesnew_notification- New notificationsnew_message- New chat messages
- Authentication: JWT-based socket authentication
scheduler.service.ts: General-purpose cron job runnerround-scheduler.service.ts: Automated round management- Creates new rounds every 4 minutes (configurable)
- Locks rounds after 30 seconds (configurable)
- Controlled by
ROUND_SCHEDULER_ENABLEDenvironment variable
API-only mode: Set
API_ONLY=trueto start the HTTP server with all schedulers, oracle polling, and the WebSocket price ticker disabled. This is the recommended setup for split deployments — one dedicated worker process runs background jobs while one or more stateless processes serve HTTP — and for safer local debugging.
Bet mode (
BET_STUB_MODE): Controls whether/api/betsendpoints submit transactions on-chain or just record intent locally.
BET_STUB_MODEsorobanService.placeBetsorobanService.placePrecisionBetUse case true(default)Skipped Skipped Local dev, demos, hackathon — no Soroban keypairs or deployed contract needed falseCalled Called Production — bets are submitted to the Soroban smart contract The active mode is logged at startup:
Bet mode: STUB (no on-chain calls)orBet mode: ON-CHAIN (Soroban).
- Purpose: Guarantees at-least-once delivery of notification and WebSocket side-effects
- How it works:
- Business transactions (payout, prediction) write
OutboxEventrows inside the sameprisma.$transaction()call — atomically with the state change. - A background poller (cron, every
OUTBOX_POLL_INTERVAL_SECONDS) readsPENDINGrows and dispatches them. - On success the row is marked
PROCESSED. On failureattemptsis incremented; onceOUTBOX_MAX_ATTEMPTSis reached the row is markedFAILEDand escalated to the existing DLQ.
- Business transactions (payout, prediction) write
- Why this matters: Before this change, notifications fired after the transaction committed. A process crash between commit and notification call silently dropped the event. Now the event is durable from the moment the transaction commits.
- Env vars:
OUTBOX_POLL_INTERVAL_SECONDS,OUTBOX_BATCH_SIZE,OUTBOX_MAX_ATTEMPTS,OUTBOX_RETENTION_DAYS
- Purpose: Creates and delivers notifications to users
- Types: WIN, LOSS, ROUND_START, BONUS_AVAILABLE, ANNOUNCEMENT
- Channels: Database storage + WebSocket emission
- Filtering: Respects user notification preferences
- Purpose: Handles global chat message submission and retrieval
- Features:
- Message validation (max 500 characters)
- Automatic user info attachment
- WebSocket broadcasting
- Pagination support
- Purpose: Provides educational content for users
- Features:
- Daily tip delivery
- Random tip selection
- Category-based filtering
POST /challenge- Request a wallet authentication challenge (returns challenge string)POST /connect- Verify signed challenge and issue JWT token
GET /profile- [Auth] Get authenticated user's profileGET /balance- [Auth] Get current virtual balanceGET /stats- [Auth] Get detailed user statisticsPATCH /profile- [Auth] Update user preferences (nickname, avatar, preferences)GET /transactions- [Auth] Get paginated transaction historyGET /:address/stats- Get on-chain user stats from SorobanGET /:address/history- Get paginated bet history for a wallet addressGET /:walletAddress/public-profile- Get any user's public profile
POST /start- [Admin] Start a new roundGET /active- Get all active roundsGET /:id- Get specific round detailsPOST /:id/resolve- [Oracle] Resolve a round with final price
The rounds endpoint now returns a unified array of frontend cards that preserves the existing hackathon card layout while allowing a live Soroban round to be surfaced alongside mock assets.
- When Soroban data is available, the mapper emits one card with
source: "live"for the live XLM round and fills the remaining slots with mock cards for BTC and ETH usingsource: "mock". - When no live chain round exists, the endpoint returns only mock cards so the frontend continues rendering the same multi-asset layout without changes.
Example response:
{
"success": true,
"data": {
"source": "soroban",
"rounds": [
{
"id": "soroban-99",
"asset": "XLM",
"mode": "updown",
"status": "live",
"startPrice": 120,
"poolUp": 2,
"poolDown": 1,
"totalPool": 3,
"predictionCount": 1,
"closesAt": "2026-07-25T00:00:00.000Z",
"source": "live",
"roundStatus": "ACTIVE",
"roundTiming": { "startsAt": "...", "endsAt": "..." },
"priceData": { "startPrice": 120, "currentPrice": 121.2 },
"poolValues": { "upPool": 2, "downPool": 1, "totalPool": 3 },
"predictionMetadata": { "predictionCount": 1, "canPredict": true }
},
{
"id": "btc-round-1",
"asset": "BTC",
"source": "mock"
}
]
}
}The mapper in src/utils/soroban-round.mapper.ts is the single place that converts live Soroban data into the frontend contract. It keeps the mapping concern isolated from the route layer and provides:
- live-to-frontend mapping for the active Soroban round
- mock fallback cards for unsupported assets so the multi-card UI remains intact
- source metadata (
"live"vs"mock") on every returned card - the same core round fields the frontend already expects (
id,asset,mode,status,startPrice,pool*,closesAt)
POST /submit- [Auth] Submit a prediction for a roundGET /user/:userId- Get user's prediction historyGET /round/:roundId- Get all predictions for a round
POST /up-down- [Auth] Submit an UP/DOWN bet (stub or on-chain)POST /precision- [Auth] Submit a precision bet (stub or on-chain)
GET /- List tournaments. Query:?mode=UP_DOWN|LEGENDS,?status=UPCOMING|ACTIVE|COMPLETED|CANCELLED,limit,offset(mode and status may be combined). Response:{ success, data, pagination: { limit, offset, total } }GET /:id- Get tournament detail by idPOST /:id/join- [Auth] Join a tournament
GET /- Get global leaderboard (paginated, optional auth for user position)
GET /guides- Get all educational guides grouped by categoryGET /tip?roundId=<uuid>- Generate contextual educational tip for a resolved round
POST /send- [Auth] Send a chat messageGET /history- Get recent chat messages (paginated, max 50)
GET /- [Auth] Get paginated notificationsGET /unread-count- [Auth] Get unread notification countGET /:id- [Auth] Get a specific notificationPATCH /:id/read- [Auth] Mark a notification as readPATCH /read-all- [Auth] Mark all notifications as readDELETE /:id- [Auth] Delete a notificationDELETE /- [Auth] Delete all read notifications
GET /- Health check with timestampGET /health- Detailed health check (uptime, status)GET /metrics- Prometheus metrics for HTTP, schedulers, oracle, predictions, WebSocket, rate limits, and DB pool settingsGET /api/price- Production only. Current XLM/USD oracle price as a decimal string (price_usd) with staleness / provider info. Not an alias of/api/prices.GET /api/prices- Multi-asset BTC / ETH / XLM ticker (CoinGecko, 30 s cache). Production returns the raw object; the hackathon app wraps it in{ success, data }. Not an alias of/api/price.GET /api-docs- Swagger UI documentationGET /api-docs.json- OpenAPI specification
Price endpoints — pick the right path
Path App Payload shape Use when GET /api/priceProduction ( npm run dev/src/index.ts){ asset: "XLM", price_usd, stale, provider, lastUpdatedAt, source, timestamp }You need the XLM oracle feed GET /api/pricesProduction and hackathon ( npm run dev:hackathon/src/app.ts){ BTC, ETH, XLM, stale, lastUpdatedAt }(hackathon: under{ success, data })You need a multi-asset price widget Keeping both is intentional: they are different contracts, not duplicates. Do not call
/api/priceagainst the hackathon app (it is not mounted there). Unversioned production/api/*routes also sendDeprecation/Sunsetheaders toward a future/api/v1successor; that does not mean/api/priceis deprecated in favor of/api/prices.
authenticateUser: Verifies JWT token and attaches user to requestrequireAdmin: Ensures user has ADMIN rolerequireOracle: Ensures user has ORACLE role
- Prevents API abuse with per-IP and per-user limits
- Single prediction submit: 10 requests/minute per user
- Batch prediction submit: 3 requests/minute per user (stricter; each batch may include up to 50 predictions)
- Batch leaderboard lookup: 10 requests/minute per user
- Auth, chat, admin round creation, and oracle resolve endpoints have tailored policies
- Rate-limit hits are recorded for the admin metrics dashboard (
GET /api/admin/metrics/rate-limits)
- Canonical list of API routes and required auth levels (
public,authenticated,admin,oracle) src/tests/security.spec.tsandsrc/tests/route-auth.registry.spec.tsfail CI when the registry drifts from implemented routes- Role middleware (
requireAdmin,requireOracle,authenticateUser) is built on a sharedrequireRolehelper inauth.middleware.ts
The application uses PostgreSQL via Prisma ORM. Key models:
- User: Wallet address, virtual balance, wins, streaks, roles
- Round: Game mode, status, prices, pools, timestamps
- Prediction: User bets with side/range, amounts, payouts
- Notification: User notifications with types and read status
- Message: Global chat messages
- UserStats: Aggregated performance metrics per game mode
- Transaction: Balance change history (bonus, win, loss, etc.)
- AuthChallenge: Wallet signature challenges for authentication
- AuditLog: Security audit trail for authentication and authorization events
The backend implements automated data retention policies to control storage growth while maintaining security audit trails.
All authentication and authorization events are logged for security monitoring and compliance:
- Events Logged: Challenge lifecycle (issued, verified, failed, expired, invalidated), authentication success/failure, user creation/login
- Storage: Audit events are persisted to the
AuditLogtable in the database - Configuration: Controlled by
AUDIT_LOG_DATABASE_ENABLED(default:true) - Fallback: When database persistence is disabled, events are only logged to Winston (files/console)
The retention service automatically cleans up old data based on configurable time-to-live (TTL) policies:
| Entity | Environment Variable | Default TTL | Purpose |
|---|---|---|---|
| Auth Challenges | RETENTION_AUTH_CHALLENGES_TTL_DAYS |
7 days | Remove expired and old authentication challenges |
| Chat Messages | RETENTION_CHAT_MESSAGES_TTL_DAYS |
90 days | Archive old chat messages |
| Audit Logs | RETENTION_AUDIT_LOGS_TTL_DAYS |
90 days | Maintain security audit trail for compliance |
Configuration:
- Enable/disable each policy via
RETENTION_*_ENABLED(default:true) - Batch size for deletion operations:
RETENTION_BATCH_SIZE(default: 1000) - Retention service can be run on-demand or via cron scheduler
Implementation: See src/services/retention.service.ts
See prisma/schema.prisma for full schema.
- Node.js 22.x or higher
- npm, pnpm, or yarn
- PostgreSQL database (local or cloud-hosted)
- Stellar account with testnet/mainnet keypairs (for admin & oracle roles)
- @tevalabs/xelma-bindings package (installed automatically)
git clone https://github.com/TevaLabs/Xelma-Backend.git
cd Xelma-Backendnpm install
# or
pnpm install
# or
yarn installThis installs all dependencies including @tevalabs/xelma-bindings.
For contributors running full backend mode with PostgreSQL (and optional Redis), use Docker Compose:
cp .env.docker.example .env
# Edit .env and set JWT_SECRET at minimum
docker compose up --build| Service | Port | Health check |
|---|---|---|
| API | 3000 |
GET http://localhost:3000/health |
| PostgreSQL | 5432 |
pg_isready -U xelma -d xelma |
| Redis (optional) | 6379 |
redis-cli ping |
The API container runs prisma migrate deploy on startup before booting the server.
To include Redis (for Socket.IO adapter / distributed locks):
docker compose --profile full up --buildTo run the hackathon mode (no database required, mock data only):
docker compose --profile hackathon upTroubleshooting Docker setup
| Symptom | Fix |
|---|---|
api exits immediately |
Ensure .env exists and JWT_SECRET is set |
Can't reach database server |
Wait for postgres health check to pass; confirm DATABASE_URL uses host postgres inside Compose |
Port 3000 already in use |
Change PORT in .env and map 3001:3001 (or similar) in docker-compose.yml |
| Migrations fail on first boot | Run docker compose logs api; verify Postgres is healthy with docker compose ps |
| Redis connection warnings | Start with --profile full or unset REDIS_URL for API-only local mode |
cp .env.example .envFor hackathon/demo mode (mock data, minimal config):
cp .env.hackathon.example .envSee .env.example for the full list of configurable variables. At minimum, set DATABASE_URL and JWT_SECRET before starting the server.
Operators can tune the oracle's behavior via environment variables to balance price freshness against API rate limits and network reliability:
| Variable | Description | Default |
|---|---|---|
ORACLE_POLLING_INTERVAL_MS |
How often to fetch the price from CoinGecko. | 10000 (10s) |
ORACLE_REQUEST_TIMEOUT_MS |
Network timeout for the API request. | 5000 (5s) |
ORACLE_MAX_RETRIES |
Number of retry attempts on failure. | 3 |
ORACLE_STALENESS_THRESHOLD_MS |
When to consider the local price data stale. | 60000 (60s) |
ORACLE_STALENESS_THRESHOLD_MSmust be greater thanORACLE_POLLING_INTERVAL_MS, otherwise a freshly-fetched price would be classified as stale immediately after every poll. This invariant is enforced at startup by config validation.
Round resolution must never settle against a frozen or broken price feed. When a
process is actively polling the oracle, resolutionService.resolveRound refuses to
settle while the price is stale — this protects both the automated resolve loop
(oracle.service.ts) and the manual oracle/admin POST /api/rounds/:id/resolve
route, which then returns 503 EXTERNAL_SERVICE_ERROR. Blocked attempts increment
oracle_resolve_blocked_total and are logged. Processes that do not poll the oracle
(e.g. API_ONLY=true HTTP nodes, or the test environment) cannot assess freshness
and defer the guard to the background worker that owns polling. Live oracle freshness
is observable at GET /health (services.oracle) and via the oracle_* metrics.
| Variable | Description | Default |
|---|---|---|
BET_STUB_MODE |
true = stub mode (bets recorded locally, no on-chain calls); false = bets submitted to Soroban smart contract |
true |
Prisma’s Postgres connector reads pool/timeouts via connection string query params. This backend exposes operational knobs as env vars and merges them into DATABASE_URL at startup (env vars win over existing query params):
| Variable | Purpose | Default |
|---|---|---|
DB_CONNECTION_LIMIT |
Max Prisma DB connections | 10 |
DB_POOL_TIMEOUT_SECONDS |
Wait for a pooled connection | 10 |
DB_CONNECT_TIMEOUT_SECONDS |
Timeout establishing a new connection | 10 |
DB_STATEMENT_TIMEOUT_MS |
Server-side statement timeout (0 disables) |
0 |
DB_PGBOUNCER |
Enable PgBouncer transaction-pooling mode | false |
Notes
- PgBouncer: if your stack uses PgBouncer in transaction pooling mode, set
DB_PGBOUNCER=true. - Visibility: scrape
/metricsand look fordb_pool_settings_infoto see the effective values. - Validation: invalid values are rejected at startup via config validation.
GET /metrics exposes Prometheus text-format metrics with only
low-cardinality labels. Labels intentionally avoid user IDs, wallet addresses,
round IDs, socket IDs, request bodies, and secrets.
Core application metrics include:
| Metric | Labels | Meaning |
|---|---|---|
http_requests_total |
method, route, status_code |
HTTP request volume by normalized Express route |
http_request_duration_seconds |
method, route, status_code |
HTTP latency histogram |
http_errors_total |
method, route, status_code |
HTTP 4xx/5xx responses |
predictions_placed_total |
none | Successful prediction submissions |
rounds_started_total |
mode |
Rounds created by game mode |
rounds_resolved_total |
mode |
Rounds resolved by game mode |
price_oracle_updates_total |
provider |
Successful oracle price refreshes |
price_oracle_fetch_failures_total |
reason, provider |
Oracle refresh failures |
oracle_up |
none | 1 when the oracle is polling and holds a fresh price, else 0 |
oracle_last_update_timestamp_seconds |
none | Unix time of the last successful price update (0 if never) |
oracle_price_staleness_seconds |
none | Age of the current price in seconds (-1 if no price yet) |
oracle_resolve_blocked_total |
reason |
Resolve attempts blocked by oracle safety guards (stale_price, invalid_price) |
scheduler_runs_total |
job, outcome |
Scheduler executions |
scheduler_items_processed_total |
job, outcome |
Items processed by scheduler jobs |
socket_connections_active |
none | Current Socket.IO connections |
websocket_emits_total |
event, outcome |
WebSocket dispatch attempts |
websocket_connection_events_total |
event, authenticated |
Socket connect/disconnect events |
# Generate the Prisma client and apply ALL committed migrations
npm run db:prepare
# Create a new development migration when changing prisma/schema.prisma
npm run prisma:migrate
# (Optional) Seed database with sample data
npx prisma db seedThis project uses two migration tools against the same PostgreSQL database:
| Tool | Owns | Migrations live in | Applied by |
|---|---|---|---|
| Prisma | Core schema — users, rounds, predictions, tournaments, etc. | prisma/migrations/ |
prisma migrate deploy |
| Drizzle | Hackathon/demo schema — hackathon_users, hackathon_rounds, hackathon_bets (see src/db/schema.ts) |
drizzle/ |
drizzle-kit migrate |
You never run those two commands by hand. npm run db:migrate applies both, in order (Prisma first, then Drizzle), and npm run db:prepare is prisma generate followed by db:migrate. This one command is exactly what CI (.github/workflows/ci.yml) and the deploy workflow run, so local, CI, and production stay identical. When you change prisma/schema.prisma use npm run prisma:migrate; when you change src/db/schema.ts generate a Drizzle migration with npx drizzle-kit generate and commit the new file under drizzle/.
Note: Never commit your
.envfile. It contains sensitive credentials.
npm run devStarts the production app (src/index.ts) on http://localhost:3001 with auto-reload. This is the right server for all feature work and bug fixes. Requires .env with at least DATABASE_URL and JWT_SECRET (copy .env.example to get started).
# Demo server — no database required, mock data only
npm run dev:hackathon# Build TypeScript to JavaScript
npm run build
# Start production server (dist/index.js — matches the Render production profile)
npm startTo run the hackathon/demo server instead (dist/server.js — matches the
Render hackathon profile), use npm run start:hackathon after building.
To reproduce the runtime behavior of the Render deployment on your machine,
use the start:render-parity script. This sets NODE_ENV=production
before launching the built server so the same code paths Render hits
fire locally — CORS is strict (CLIENT_URL must be set, no wildcard
origin), error responses match production, and logging runs at
production verbosity.
# 1) Build first (start:render-parity expects dist/)
npm run build
# 2) Run with production-shaped environment
CLIENT_URL=http://localhost:5173 \
JWT_SECRET="$(openssl rand -base64 32)" \
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/xelma_local" \
npm run start:render-parityRequired env vars for parity (matches what Render's environment supplies):
| Variable | Why it matters in render-parity mode |
|---|---|
NODE_ENV=production |
Set by the script. Enables strict CORS and production logging. |
CLIENT_URL |
Required. Strict CORS will reject all origins if unset. |
ALLOWED_ORIGINS |
Optional comma-separated extra origins. |
JWT_SECRET |
Required for startup. Use a cryptographically strong value. |
DATABASE_URL |
Required. Point at a local Postgres. |
SOROBAN_CONTRACT_ID / SOROBAN_ADMIN_SECRET / SOROBAN_ORACLE_SECRET |
Optional; only needed if you want on-chain calls. |
If you hit a CORS error from your frontend in this mode, hit
GET /api/admin/cors-diagnostics?origin=<your-origin> with an admin
token to see exactly which origins this process accepts.
curl http://localhost:3000/healthExpected response:
{
"status": "healthy",
"uptime": 42.123,
"timestamp": "2026-02-23T12:00:00.000Z"
}Notification creation and WebSocket emits go through a dead-letter queue
(DLQ) so a transient DB blip, a not-yet-initialized socket layer, or a
runtime exception in emit does not silently drop a user-facing event.
How it works:
notificationService.createNotification(...)records aFailedDispatchrow onNOTIFICATION_CREATEerrors (the original error still rethrows so callers behave the same).websocketService.emit*(...)records aFailedDispatchrow whenever the socket layer is not initialized or the underlyingemitthrows. The emit itself is fire-and-forget — the caller's hot path is never broken by a DLQ persistence failure.- Rows have
attempts,lastError, andstatus(PENDING,RETRYING,RESOLVED,ABANDONED) so an operator can triage stuck dispatches.
Operator endpoints (admin-only, gated by requireAdmin):
GET /api/admin/dead-letter— list entries, newest first. Query params:status,channel,limit,offset.POST /api/admin/dead-letter/:id/retry— replay a single entry; setsRESOLVEDon success, bumpsattemptsand moves toABANDONEDonce the cap (default 5) is reached.POST /api/admin/dead-letter/retry-all— replay everyPENDING/RETRYINGentry (capped, oldest first). Returns a counts summary.
The current versioned base URL is /api/v1.
All endpoints are accessible under both /api/v1/* (versioned) and /api/* (legacy alias). The legacy paths (/api/*) are deprecated and will be removed on 2027-01-01.
Clients should migrate to /api/v1/* before that date.
Responses from the deprecated legacy paths include the following headers:
Deprecation: trueSunset: Sat, 01 Jan 2027 00:00:00 GMTLink: </api/v1{path}>; rel="successor-version"
The backend provides auto-generated OpenAPI/Swagger documentation.
- Swagger UI: http://localhost:3000/api-docs
- OpenAPI JSON: http://localhost:3000/api-docs.json
POST /api/auth/challenge
Content-Type: application/json
{
"walletAddress": "GXXX...YOUR_STELLAR_ADDRESS"
}Response:
{
"challenge": "random-challenge-string",
"expiresAt": "2026-02-23T00:05:00.000Z"
}POST /api/auth/connect
Content-Type: application/json
{
"walletAddress": "GXXX...YOUR_STELLAR_ADDRESS",
"challenge": "random-challenge-string",
"signature": "BASE64_SIGNATURE_OF_CHALLENGE"
}Response:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "user-uuid",
"walletAddress": "GXXX...",
"createdAt": "2026-01-01T00:00:00.000Z",
"lastLoginAt": "2026-02-23T12:00:00.000Z"
},
"bonus": 100,
"streak": 1
}POST /api/rounds/start
Authorization: Bearer YOUR_JWT_TOKEN
Content-Type: application/json
{
"mode": 0, # 0 = UP_DOWN, 1 = LEGENDS
"startPrice": 0.1234,
"duration": 300 # Duration in seconds
}Response:
{
"success": true,
"round": {
"id": "round-uuid",
"mode": "UP_DOWN",
"status": "ACTIVE",
"startPrice": 0.1234,
"startTime": "2026-02-23T12:00:00Z",
"endTime": "2026-02-23T12:05:00Z",
"sorobanRoundId": "1",
"poolUp": 0,
"poolDown": 0
}
}GET /api/rounds/activeResponse:
{
"rounds": [
{
"id": "round-uuid",
"mode": "UP_DOWN",
"status": "ACTIVE",
"startPrice": 0.1234,
"startTime": "2026-02-23T12:00:00Z",
"endTime": "2026-02-23T12:05:00Z",
"poolUp": 150,
"poolDown": 200
}
]
}POST /api/predictions/submit
Authorization: Bearer YOUR_JWT_TOKEN
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
# For UP_DOWN mode:
{
"roundId": "round-uuid",
"amount": 10,
"side": "UP"
}
# For LEGENDS mode:
{
"roundId": "round-uuid",
"amount": 10,
"priceRange": {
"min": 0.12,
"max": 0.13
}
}Idempotency-Key is optional but recommended for clients that may retry a
submit request after network failure. The same authenticated user can retry the
same request body with the same key for 10 minutes and receive the cached
response. Reusing the same key with a different request body returns 409 with
code IDEMPOTENCY_KEY_CONFLICT; generate a fresh key for a new prediction
attempt.
Response:
{
"success": true,
"prediction": {
"id": "prediction-uuid",
"roundId": "round-uuid",
"amount": 10,
"side": "UP",
"priceRange": null,
"createdAt": "2026-02-23T12:01:00Z"
}
}POST /api/bets/up-down
Authorization: Bearer YOUR_JWT_TOKEN
Content-Type: application/json
Idempotency-Key: a5b7-c9d8-e2f4-77a8-33b2
{
"address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
"amount": 10,
"side": "UP"
}Response:
{
"success": true,
"message": "Bet recorded (stub)",
"state": "stub"
}POST /api/bets/precision
Authorization: Bearer YOUR_JWT_TOKEN
Content-Type: application/json
Idempotency-Key: a5b7-c9d8-e2f4-77a8-33b2
{
"address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
"amount": 5,
"predictedPrice": 0.12
}Response:
{
"success": true,
"message": "Bet placed on-chain",
"state": "on-chain-success",
"txHash": "0x123..."
}Both /api/bets/up-down and /api/bets/precision endpoints support safe client retries using the optional Idempotency-Key header.
- Idempotency-Key Header: Optional. Standard string format (alphanumeric with hyphens/underscores, 8-255 characters).
- TTL (Time-To-Live): 24 hours. Stored idempotency records are kept for 24 hours (or as configured via
BET_IDEMPOTENCY_TTL_HOURSenvironment variable) and then pruned by the daily scheduler. - Retry Semantics:
- First Successful Request: Performs the bet operation (either stub or submits on-chain) and caches the response.
- Duplicate Request (Same Key & Body): Returns the original cached response with HTTP 200 without creating a duplicate bet or executing on-chain transactions again.
- Mutation Check (Same Key, Different Body): Returns HTTP 409 Conflict with code
CONFLICTand error codeIDEMPOTENCY_KEY_CONFLICTto protect against unintentional reuse of keys across different operations. - Concurrency Protection: Simultaneous concurrent requests with the identical key are coordinated using database-level locks. Only one request will execute the operation, while other concurrent retries safely block/wait for the result and receive the same response, preventing double-betting under high latency or race conditions.
- Failures/Retries: If the initial operation fails (e.g., Soroban network error or database timeout), the temporary lock is automatically released, allowing subsequent retries to execute the bet again instead of caching a failed state.
GET /api/leaderboard?limit=100&offset=0Response:
{
"leaderboard": [
{
"rank": 1,
"userId": "user-uuid",
"walletAddress": "GXXX...XXXX",
"totalEarnings": 5432.10,
"totalPredictions": 60,
"accuracy": 75.0,
"modeStats": {
"upDown": { "wins": 30, "losses": 15, "earnings": 3000.0, "accuracy": 66.67 },
"legends": { "wins": 15, "losses": 0, "earnings": 2432.10, "accuracy": 100.0 }
}
}
],
"userPosition": null,
"totalUsers": 150,
"lastUpdated": "2026-02-23T12:00:00.000Z"
}Connect to the WebSocket server with JWT authentication:
import io from 'socket.io-client';
const socket = io('http://localhost:3000', {
auth: {
token: 'YOUR_JWT_TOKEN'
}
});
// Listen for price updates
socket.on('price_update', (data) => {
console.log('New price:', data);
// { asset: 'XLM', price: 0.1234, timestamp: '...' }
});
// Listen for round updates
socket.on('round_update', (data) => {
console.log('Round update:', data);
// { type: 'created'|'locked'|'resolved', round: {...} }
});
// Listen for balance updates
socket.on('user_balance_update', (data) => {
console.log('Balance update:', data);
// { userId: '...', balance: 1050 }
});
// Listen for notifications
socket.on('new_notification', (notification) => {
console.log('Notification:', notification);
});
// Listen for chat messages
socket.on('new_message', (message) => {
console.log('Chat:', message);
});
// Listen for accepted bets (stub or on-chain) — join the `round` room first
socket.on('bet:accepted', (data) => {
console.log('Bet accepted:', data);
// {
// roundId?: string,
// address: string,
// amount: number,
// side?: 'UP' | 'DOWN',
// mode: 'UP_DOWN' | 'PRECISION',
// state: 'stub' | 'on-chain-success',
// txHash?: string
// }
});See also src/docs/websocket.md for the Socket.IO client contract.
Run the test suite with Jest:
# Run all tests (unit + integration)
npm test
# Run unit tests only
npm run test:unit
# Run unit tests with coverage thresholds
npm run test:unit:coverage
# Run integration tests only (requires PostgreSQL — see DATABASE_URL in .env)
npm run test:integration
# Run all tests with coverage
npm run test:coverage
# Run tests in watch mode (development)
npm run test:watch
# Run the full local CI check (lint + build + unit coverage + integration)
npm run ci
# Run the legacy hackathon node:test suite
npm run test:hackathon
# Run load/performance baselines
npm run test:loadsrc/tests/redis-adapter.spec.ts proves that Socket.IO room broadcasts fan out across two independent server instances via the Redis adapter (simulating a multi-instance deployment). It is skipped automatically when REDIS_URL is not set, so it never blocks the default unit test run.
To run it locally:
docker compose --profile full up -d redis
REDIS_URL=redis://localhost:6379 npx jest --testPathPattern=redis-adapterCoverage thresholds are enforced in jest.config.ts. The current floors are:
- Branches: 70%
- Functions: 50%
- Lines: 35%
- Statements: 35%
CI runs npm run test:unit:coverage (unit tests with coverage upload) and npm run test:integration (integration tests against a PostgreSQL service container) as separate parallel jobs.
npm run test:load runs src/tests/performance.spec.ts, which exercises:
- Single-request latency baselines for auth, active rounds, and prediction submit (#152).
- Concurrent prediction throughput — N parallel
POST /api/predictions/submitrequests with aggregate RPS and p95 latency assertions. - WebSocket fanout — M clients join the
roundroom and must receiveprediction:placedwithin the configured p95 budget.
The harness lives in src/tests/load-test.harness.ts and uses mocked Prisma/Soroban so it stays repeatable in CI without a live database. Tune thresholds via env vars (see .env.example → “Load / performance test harness�):
| Variable | Default | Purpose |
|---|---|---|
LOAD_TEST_PREDICTION_CONCURRENCY |
10 |
Max in-flight prediction requests |
LOAD_TEST_PREDICTION_ITERATIONS |
30 |
Total prediction requests per run |
LOAD_TEST_PREDICTION_MIN_RPS |
5 |
Minimum acceptable throughput |
LOAD_TEST_PREDICTION_P95_MS |
500 |
Max p95 latency for predictions |
LOAD_TEST_WS_CLIENTS |
20 |
Connected sockets for fanout test |
LOAD_TEST_WS_MIN_DELIVERY_RATE |
1 |
Minimum delivery ratio (0–1) |
LOAD_TEST_WS_P95_MS |
250 |
Max p95 fanout delivery time |
Each run prints [LOAD] summary lines to stdout for before/after comparisons in PRs.
Coverage thresholds are enforced in jest.config.ts for lines, branches, functions, and statements. The current floor is intentionally conservative and excludes tests, mocks, generated files, scripts, and vendored bindings so the gate tracks application code. CI runs npm run test:unit:coverage, prints the Jest coverage summary, uploads coverage/, and fails when the thresholds are not met.
Current test coverage includes:
- Education tip service tests
- Education tip route tests
- Round service tests
Schema changes should follow the migration checklist in docs/migration-safety.md. Use it before opening PRs that edit prisma/schema.prisma, add files under prisma/migrations/, or require production backfills.
At minimum, migration PRs should include:
- A before/after behavior summary.
- Risk notes for locks, backfills, and compatibility with the previous application version.
- Verification output for Prisma generation, migration, and targeted tests.
- A rollback plan that preserves production data.
| Script | Description |
|---|---|
npm start |
Run production full backend (dist/index.js — Prisma, Soroban, schedulers, WebSocket); this is the default Render start command for the xelma-backend profile (requires build). Alias for npm run start:full |
npm run start:full |
Explicit alias for npm start — run the production full backend (dist/index.js) |
npm run start:hackathon |
Run the hackathon/demo server (dist/server.js); this is the Render start command for the xelma-backend-hackathon profile (requires build) |
npm run dev |
Start the production development server (src/index.ts) with hot-reload — use this for all feature work |
npm run dev:hackathon |
Start the hackathon demo server (src/server.ts) — mock data only, no database required |
npm run build |
Compile TypeScript to JavaScript |
npm test |
Run Jest test suite |
npm run test:coverage |
Run Jest with coverage reporting and thresholds |
npm run test:unit:coverage |
Run unit tests with coverage reporting and thresholds |
npm run test:watch |
Run tests in watch mode |
npm run test:load |
Run repeatable load baselines for prediction throughput and websocket fanout |
npm run ci |
Run lint, build, unit coverage, and integration tests |
npm run prisma:generate |
Generate Prisma client |
npm run prisma:migrate |
Run database migrations |
npm run db:seed:mock |
Seed database with mock data |
node dist/index.js |
Run production full backend (Prisma, Soroban, schedulers, WebSocket); use this command in production Render profile |
npm run prisma:migrate |
Create/apply a Prisma dev migration for the core schema |
npm run prisma:migrate:deploy |
Apply committed Prisma migrations without creating new ones |
npm run db:migrate:hackathon |
Apply committed Drizzle migrations for the hackathon schema |
npm run db:migrate |
Apply all committed migrations — Prisma core schema then Drizzle hackathon schema |
npm run db:prepare |
Generate the Prisma client, then run db:migrate (the one-command DB setup used by CI and deploys) |
npm run docs:openapi |
Generate OpenAPI JSON spec to docs/openapi.json |
npm run docs:verify |
Regenerate OpenAPI and verify required paths are documented (CI gate) |
npm run scorecard |
Run the production-readiness scorecard |
Every error response from the API carries a stable machine-readable
code (in addition to the HTTP status) so clients can branch on the
specific failure without parsing prose. The canonical list lives in
src/utils/errors.ts as ERROR_CATALOG and is
also exposed as JSON at GET /api/errors for client codegen.
A drift test (src/tests/error-catalog.spec.ts) pins the catalog to
the ErrorCode enum, so adding a new code without a catalog entry
fails CI.
| HTTP | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
Body / query / params failed schema validation. See error.details. |
| 401 | AUTHENTICATION_ERROR |
Missing / invalid credentials. Re-authenticate. |
| 401 | INVALID_CHALLENGE |
Signed challenge does not match a known issued challenge. |
| 401 | CHALLENGE_EXPIRED |
Challenge TTL elapsed. Request a new one. |
| 401 | CHALLENGE_USED |
Challenge already consumed (one-shot). |
| 401 | INVALID_SIGNATURE |
Signature does not verify against wallet + challenge. |
| 403 | AUTHORIZATION_ERROR |
Authenticated, not permitted. |
| 404 | NOT_FOUND |
Resource does not exist. |
| 409 | CONFLICT |
Generic state conflict. |
| 409 | ROUND_ALREADY_RESOLVED |
Round outcome already final. |
| 409 | DUPLICATE_PREDICTION |
User already predicted on this round. |
| 409 | ACTIVE_ROUND_EXISTS |
A round of the requested mode is already active. |
| 422 | BUSINESS_RULE_VIOLATION |
Generic domain rule violation. |
| 422 | INSUFFICIENT_FUNDS |
Not enough balance. |
| 422 | ROUND_NOT_ACTIVE |
Round is not in ACTIVE status. |
| 422 | ROUND_LOCKED |
Round is locked before resolution. |
| 500 | CONFIGURATION_ERROR |
Server misconfiguration. Operator action required. |
| 500 | INTERNAL_SERVER_ERROR |
Unexpected. Retry; include requestId if reporting. |
| 503 | EXTERNAL_SERVICE_ERROR |
Upstream (DB, RPC, oracle) failure. Retry with backoff. |
npm run scorecard runs a small, zero-dependency set of "is this repo
ready to deploy?" heuristics and prints a green / yellow / red
breakdown. CI runs the same script in its own job
(.github/workflows/ci.yml) and fails the
build only when a required check fails — soft "nice to have"
checks emit warnings without blocking merges. New checks live in
scripts/production-readiness-scorecard.js.
Error:
Soroban configuration or bindings missing. Soroban integration DISABLED.
Solution:
Ensure your .env contains valid values for:
SOROBAN_CONTRACT_IDSOROBAN_ADMIN_SECRETSOROBAN_ORACLE_SECRET
Verify the contract is deployed and accessible at SOROBAN_RPC_URL.
Error:
Cannot find module '@tevalabs/xelma-bindings'
Solution:
npm install @tevalabs/xelma-bindings
# or
npm installError:
Can't reach database server at localhost:5432
Solution:
- Verify PostgreSQL is running:
psql -U postgres - Check
DATABASE_URLin.envmatches your database credentials - Ensure database
xelma_dbexists or run migrations:npm run prisma:migrate
Cause: Token is missing, expired, or invalid.
Solution:
- Ensure you're including the token in the
Authorizationheader:Authorization: Bearer YOUR_JWT_TOKEN - If expired, log in again to get a fresh token
- Verify
JWT_SECRETin.envmatches the one used to generate the token
Cause: Your account doesn't have the required role.
Solution:
- Check your user's role in the database (should be
ADMINorORACLE) - Verify
SOROBAN_ADMIN_SECRETandSOROBAN_ORACLE_SECRETin.envmatch the keypairs registered in the smart contract - Ensure you're using the correct JWT token for the intended role
Cause: CoinGecko API rate limits or network issues.
Solution:
- Check server logs for error messages from the oracle service
- Verify internet connectivity
- Consider using a CoinGecko API key if hitting rate limits (update
oracle.ts)
Cause: Scheduler is disabled in configuration.
Solution:
Set ROUND_SCHEDULER_ENABLED=true in .env and restart the server.
This project uses GitHub Actions for continuous integration and deployment. CI and CD are cleanly separated into two workflow files.
File: .github/workflows/ci.yml
CI runs automatically on every pull request and on pushes to main. It executes three independent jobs in parallel:
| Job | What it does |
|---|---|
| lint | Runs tsc --noEmit to check for type errors |
| build | Compiles TypeScript to dist/ via tsc |
| test | Spins up a PostgreSQL 16 service container, runs migrations, and executes the full test suite |
CI is fast, deterministic, and has no side effects. It is also used as a gate by the deployment workflow.
File: .github/workflows/deploy.yml
The deployment workflow calls CI as a prerequisite (reusable workflow) and only proceeds if all checks pass.
- Trigger: Automatic on push to
devorstagingbranches, or via manualworkflow_dispatch - Environment:
staging(configured in GitHub repository settings) - Process:
- CI suite runs and must pass
- Dependencies are installed and the project is built
- Database migrations run against the staging database
- Application is deployed to the staging environment
- Trigger: Push to
mainor manualworkflow_dispatchwithproductionselected - Environment:
production(configured in GitHub repository settings with required reviewers) - Approval Gate: Production deployments require manual approval through GitHub's environment protection rules. Configure this in Settings > Environments > production > Required reviewers.
- Process:
- CI suite runs and must pass
- A reviewer must approve the deployment in the GitHub Actions UI
- Dependencies are installed and the project is built
- Database migrations run against the production database
- Application is deployed to production
Both environments can be deployed manually via Actions > Deploy > Run workflow, selecting the target environment from the dropdown.
Each environment (staging, production) must have the following configured in GitHub Settings > Environments:
| Secret | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string for the target environment |
JWT_SECRET |
Strong random secret for JWT signing (must not be a placeholder) |
SOROBAN_CONTRACT_ID |
Deployed Soroban prediction market contract address |
SOROBAN_ADMIN_SECRET |
Stellar secret key for contract admin operations |
SOROBAN_ORACLE_SECRET |
Stellar secret key for oracle price settlement |
| Variable | Description | Example |
|---|---|---|
PORT |
Server listen port | 3000 |
CLIENT_URL |
CORS-allowed frontend origin | https://app.xelma.io |
SOROBAN_NETWORK |
Stellar network target | testnet or mainnet |
SOROBAN_RPC_URL |
Soroban RPC endpoint | https://soroban-testnet.stellar.org |
STAGING_URL |
Staging environment URL (display only) | https://staging.xelma.io |
PRODUCTION_URL |
Production environment URL (display only) | https://xelma.io |
- Go to your repository Settings > Environments
- Create
stagingandproductionenvironments - For
production, enable Required reviewers and add authorized approvers - Add all secrets and variables listed above to each environment
- Ensure no secrets contain placeholder values
If a deployment causes issues, use the following rollback process:
# 1. Identify the last known good commit
git log --oneline -10
# 2. Revert the problematic commit(s)
git revert <bad-commit-sha>
# 3. Push the revert (this triggers a new deployment)
git push origin main # for production
git push origin dev # for staging- Go to Actions > Deploy > Run workflow
- Select the target environment
- Optionally, create a branch from the known-good commit and push it to trigger deployment
If a migration caused the issue:
# Check migration status
npx prisma migrate status
# If needed, manually revert the migration in the target database
# Then redeploy the previous commitImportant: Always test rollbacks in staging before applying to production. Database migrations are not automatically reversed; plan migrations to be backward-compatible when possible.
The repository includes a render.yaml blueprint with two service profiles:
| Setting | Value |
|---|---|
| Start command | npm run start:hackathon (runs dist/server.js) |
| Health check | GET /api/health |
| Database | Not required — set DATA_MODE=mock for in-process data |
| Plan | Free tier sufficient |
Minimal env vars needed (all others use sensible defaults):
| Variable | Example | Purpose |
|---|---|---|
JWT_SECRET |
(sync on Render) | Signs JWT tokens |
DATA_MODE |
mock |
Use mock in-process data (no DB) |
ENABLE_MULTIPLAYER_SOCIAL |
true |
Enable chat / notifications |
CLIENT_URL |
https://your-app.onrender.com |
CORS origin |
SOROBAN_CONTRACT_ID |
(sync on Render) | Soroban contract address (optional for demo; alias: CONTRACT_ID) |
SOROBAN_RPC_URL |
https://soroban-testnet.stellar.org |
Soroban RPC (alias: STELLAR_RPC_URL) |
| Setting | Value |
|---|---|
| Start command | npm start (runs dist/index.js) |
| Health check | GET /health |
| Database | PostgreSQL required — migrations run automatically in build phase |
| Plan | Starter or higher recommended |
Required env vars:
| Variable | Example / Purpose |
|---|---|
DATABASE_URL |
PostgreSQL connection string (sync on Render) |
JWT_SECRET |
Strong random secret (sync on Render) |
CLIENT_URL |
Frontend origin for CORS |
SOROBAN_CONTRACT_ID |
Deployed prediction market contract (sync on Render) |
SOROBAN_ADMIN_SECRET |
Stellar secret key for admin ops (sync on Render) |
SOROBAN_ORACLE_SECRET |
Stellar secret key for oracle settlement (sync on Render) |
- Go to Dashboard > New > Blueprint and connect your fork of this repo.
- Render reads
render.yamland lists both services. Uncheck the profile you do not want to deploy. - For each selected service, fill in any
sync: falseenv vars. - Deploy. The service is reachable at
https://<service-name>.onrender.com:<PORT>.
Port note: The server listens on the port defined by the
PORTenv var (default3000). Render automatically setsPORTin the runtime environment.
This section is designed so a new developer can boot and test the API in minutes.
git clone https://github.com/TevaLabs/Xelma-Backend.git
cd Xelma-Backend
npm install
# 1. Start PostgreSQL (if not running a local instance)
docker compose up -d postgres
# 2. Copy and customize environment variables
cp .env.hackathon.example .env
# Edit .env → set DATABASE_URL and JWT_SECRET
# 3. Apply all database migrations (Prisma core schema + Drizzle hackathon schema)
npm run db:prepare
# 4. Seed initial mock rounds and user data to Postgres
npx ts-node src/db/seed.ts
# Optional: seed joinable demo tournaments for /api/tournaments
npm run db:seed:tournaments
# 5. Start the server
npm run devThe server starts on http://localhost:3001 (or the PORT in .env). See the API Documentation section above for endpoint examples.
| Variable | Example | Purpose |
|---|---|---|
PORT |
3001 |
Server listen port |
DATABASE_URL |
postgresql://xelma:xelma@localhost:5432/xelma |
PostgreSQL connection |
JWT_SECRET |
my-secret-key |
Signs JWT tokens (app refuses to start without it) |
DATA_MODE |
mock |
Hackathon service data mode (set to mock to query Drizzle schema tables) |
ENABLE_MULTIPLAYER_SOCIAL |
true |
Feature flag to enable/disable chat and notifications routes |
COINGECKO_API_URL |
https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd |
Price oracle source |
SOROBAN_RPC_URL |
https://soroban-testnet.stellar.org |
Soroban RPC (alias: STELLAR_RPC_URL) |
SOROBAN_CONTRACT_ID |
(your deployed contract) | Soroban prediction market contract (alias: CONTRACT_ID) |
Note: For the Hackathon MVP, the backend is fully migrated from in-memory arrays to PostgreSQL via Drizzle ORM for durable persistence of users, rounds, and bets. No in-memory stores are used.
curl http://localhost:3001/healthcurl http://localhost:3001/api/pricesUse
/api/priceson the hackathon app./api/priceis the production-only XLM oracle endpoint and is not mounted on port 3001.
curl http://localhost:3000/api/pricecurl -X POST http://localhost:3001/api/auth/challenge \
-H "Content-Type: application/json" \
-d '{"walletAddress": "GXXX...YOUR_STELLAR_ADDRESS"}'curl -X POST http://localhost:3001/api/auth/connect \
-H "Content-Type: application/json" \
-d '{
"walletAddress": "GXXX...YOUR_STELLAR_ADDRESS",
"challenge": "CHALLENGE_FROM_ABOVE",
"signature": "BASE64_SIGNATURE"
}'curl http://localhost:3001/api/rounds/activecurl http://localhost:3001/api/rounds/ROUND_IDcurl -X POST http://localhost:3001/api/predictions/submit \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT" \
-d '{"roundId": "ROUND_ID", "amount": 10, "side": "UP"}'Wallet authentication uses the challenge/connect flow above. Bets are bound to the JWT wallet; unauthenticated attempts return 401.
curl -X POST http://localhost:3000/api/bets/up-down \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT" \
-d '{"amount": 10, "side": "UP"}'# Unauthenticated — rejected
curl -X POST http://localhost:3000/api/bets/up-down \
-H "Content-Type: application/json" \
-d '{"amount": 10, "side": "UP"}'curl http://localhost:3001/api/user/profile \
-H "Authorization: Bearer YOUR_JWT"curl http://localhost:3001/api/user/balance \
-H "Authorization: Bearer YOUR_JWT"curl http://localhost:3001/api/user/stats \
-H "Authorization: Bearer YOUR_JWT"curl "http://localhost:3001/api/user/GXXX.../history?limit=20&offset=0"curl http://localhost:3001/api/user/GXXX.../public-profilecurl http://localhost:3001/api/user/GXXX.../statsNote on Feature Flags: Chat (
/api/chat/*) and Notification (/api/notifications/*) endpoints are feature-gated behind theENABLE_MULTIPLAYER_SOCIALconfiguration option. If this option is set tofalse, these endpoints will return a404 Not FoundJSON response.
curl "http://localhost:3001/api/user/transactions?page=1&limit=20" \
-H "Authorization: Bearer YOUR_JWT"curl "http://localhost:3001/api/leaderboard?limit=10&offset=0"curl "http://localhost:3001/api/tournaments?limit=10&offset=0"
curl "http://localhost:3001/api/tournaments?mode=UP_DOWN"
curl "http://localhost:3001/api/tournaments?status=ACTIVE&mode=LEGENDS&limit=20&offset=0"For a fresh local database with joinable demo tournaments, run:
npm run db:seed:tournamentsThe seed is idempotent and upserts three stable tournament IDs covering ACTIVE, UPCOMING, and COMPLETED statuses across both UP_DOWN and LEGENDS modes.
curl http://localhost:3001/api/tournaments/t-001curl http://localhost:3001/api/education/guidescurl -X POST http://localhost:3001/api/chat/send \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT" \
-d '{"content": "Hello everyone!"}'curl "http://localhost:3001/api/chat/history?limit=50"curl "http://localhost:3001/api/notifications?limit=20&offset=0" \
-H "Authorization: Bearer YOUR_JWT"Open http://localhost:3001/api-docs in a browser for interactive API documentation.
Status: Accepted, step 1 implemented.
Context
The hackathon read/write paths used two ORMs against the same Postgres database:
Drizzle (src/db/*) for hackathon.service.ts (bet placement, user stats, round
pools), and Prisma (prisma/schema.prisma) for everything else, including the
Mock* models (MockRound, MockLeaderboard, MockPlatformStat) that already
back the hackathon read endpoints (/api/rounds, /api/leaderboard, /api/stats)
via the repository layer. Running two migration/seed toolchains against one
database is exactly the "dual migrations, dual seeds, dual contributor setup"
problem described in #391 — a contributor could migrate one ORM's schema and
silently leave the other out of sync.
Decision
Standardize on Prisma as the single ORM for hackathon data going forward.
Prisma is already the ORM for every non-hackathon table and already has the
Mock* models the hackathon read paths use — Drizzle was the odd one out here,
not the other way around.
Step 1 (this PR)
hackathon.service.ts — the one hackathon service still on Drizzle — has been
migrated to Prisma:
MockLeaderboardgainedbalanceandpendingWinningsfields so it can represent the full hackathon user record (it previously only backed leaderboard reads).- A new
MockBetmodel replaceshackathonBets. - All
db.select()/.insert()/.update()calls inhackathon.service.tsare nowprisma.mockRound/prisma.mockLeaderboard/prisma.mockBetcalls. - The public API of
HackathonService(method signatures and return shapes) is unchanged, soPrismaRoundRepository.placeBetandsrc/routes/user.tsneeded no changes.
Remaining work (follow-up, not in this PR)
src/db/*(Drizzle client, schema, migrate script, seed script) is now unused by application code and can be deleted oncedrizzle-orm/drizzle-kitare removed frompackage.json.- The Prisma migration for the new
MockBetmodel andMockLeaderboardcolumns still needs to be generated and applied against a real database (npx prisma migrate dev) — not run here to avoid touching any live/shared database from this change.
Why isolate-and-migrate over isolate-only
The alternative (marking Drizzle "demo-only" and leaving hackathon.service.ts
on it) would have kept two live schemas against one database indefinitely.
Since Prisma already owned the adjacent hackathon read models, migrating the
one remaining Drizzle consumer was less total work than maintaining the split.
The lightweight hackathon server (default port 3001) applies per-IP throttling with express-rate-limit.
| Limiter | Scope | Window | Max requests |
|---|---|---|---|
apiRateLimiter |
All /api/* routes |
1 minute | 100 |
writeRateLimiter |
POST, PUT, PATCH, DELETE |
1 minute | 20 |
betRateLimiter |
POST /api/rounds/:id/bet |
1 minute | 5 |
When a client exceeds a limit, the API returns 429 with retry guidance:
{
"error": "Too Many Requests",
"message": "Too many bet submissions from this IP. Please wait before placing another bet.",
"retryAfter": 60
}The RateLimit-* and Retry-After response headers are also set (standardHeaders: true).
- Smart Contract: TevaLabs/Xelma-Blockchain
- TypeScript Bindings: @tevalabs/xelma-bindings
- Frontend: Coming soon
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
ISC
Built with �� by the TevaLabs team on Stellar