Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

530 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Xelma Backend

TypeScript/Node.js backend for the Xelma decentralized XLM price prediction market, built on the Stellar blockchain (Soroban).


Table of Contents


Overview

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:

  1. UP_DOWN - Binary predictions (price goes up or down)
  2. LEGENDS - Range-based predictions (price lands in specific ranges)

Key Features

  • ✅ 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

Project Structure

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

Architecture

Data Sources

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.


Entrypoints

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.


Core Services

1. Price Oracle (oracle.ts)

  • 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

2. Soroban Service (soroban.service.ts)

  • 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

3. Round Service (round.service.ts)

  • 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

4. Prediction Service (prediction.service.ts)

  • 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

5. Resolution Service (resolution.service.ts)

  • Purpose: Resolves completed rounds and distributes winnings
  • Process:
    1. Fetch final price from oracle
    2. Update round status to RESOLVED
    3. Calculate payouts for winning predictions
    4. Update user stats (wins, earnings, streaks)
    5. Call Soroban contract to finalize round
    6. Send win/loss notifications
  • Payout Formula: Proportional to bet size and total pool ratio

6. Leaderboard Service (leaderboard.service.ts)

  • 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's totalEarnings as the score. Rank lookups become O(log N) instead of a full-table COUNT(*). The set is kept in sync after every updateUserStatsForRound call and invalidated whenever the leaderboard namespace is flushed. The DB path is always the fallback when Redis is unavailable.

7. WebSocket Service (websocket.service.ts)

  • Purpose: Broadcasts real-time events to connected clients
  • Events:
    • price_update - New XLM price every 5 seconds
    • round_update - Round status changes (created, locked, resolved)
    • user_balance_update - User balance changes
    • new_notification - New notifications
    • new_message - New chat messages
  • Authentication: JWT-based socket authentication

8. Scheduler Services

  • scheduler.service.ts: General-purpose cron job runner
  • round-scheduler.service.ts: Automated round management
    • Creates new rounds every 4 minutes (configurable)
    • Locks rounds after 30 seconds (configurable)
    • Controlled by ROUND_SCHEDULER_ENABLED environment variable

API-only mode: Set API_ONLY=true to 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/bets endpoints submit transactions on-chain or just record intent locally.

BET_STUB_MODE sorobanService.placeBet sorobanService.placePrecisionBet Use case
true (default) Skipped Skipped Local dev, demos, hackathon — no Soroban keypairs or deployed contract needed
false Called Called Production — bets are submitted to the Soroban smart contract

The active mode is logged at startup: Bet mode: STUB (no on-chain calls) or Bet mode: ON-CHAIN (Soroban).

8a. Outbox Service (outbox.service.ts) — Issue #18

  • Purpose: Guarantees at-least-once delivery of notification and WebSocket side-effects
  • How it works:
    1. Business transactions (payout, prediction) write OutboxEvent rows inside the same prisma.$transaction() call — atomically with the state change.
    2. A background poller (cron, every OUTBOX_POLL_INTERVAL_SECONDS) reads PENDING rows and dispatches them.
    3. On success the row is marked PROCESSED. On failure attempts is incremented; once OUTBOX_MAX_ATTEMPTS is reached the row is marked FAILED and escalated to the existing DLQ.
  • 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

9. Notification Service (notification.service.ts)

  • 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

10. Chat Service (chat.service.ts)

  • Purpose: Handles global chat message submission and retrieval
  • Features:
    • Message validation (max 500 characters)
    • Automatic user info attachment
    • WebSocket broadcasting
    • Pagination support

11. Education Tip Service (education-tip.service.ts)

  • Purpose: Provides educational content for users
  • Features:
    • Daily tip delivery
    • Random tip selection
    • Category-based filtering

Routes & Endpoints

Authentication (/api/auth)

  • POST /challenge - Request a wallet authentication challenge (returns challenge string)
  • POST /connect - Verify signed challenge and issue JWT token

User Management (/api/user)

  • GET /profile - [Auth] Get authenticated user's profile
  • GET /balance - [Auth] Get current virtual balance
  • GET /stats - [Auth] Get detailed user statistics
  • PATCH /profile - [Auth] Update user preferences (nickname, avatar, preferences)
  • GET /transactions - [Auth] Get paginated transaction history
  • GET /:address/stats - Get on-chain user stats from Soroban
  • GET /:address/history - Get paginated bet history for a wallet address
  • GET /:walletAddress/public-profile - Get any user's public profile

Round Management (/api/rounds)

  • POST /start - [Admin] Start a new round
  • GET /active - Get all active rounds
  • GET /:id - Get specific round details
  • POST /:id/resolve - [Oracle] Resolve a round with final price
Frontend round card contract

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 using source: "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"
      }
    ]
  }
}
Mapper responsibilities

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)

Predictions (/api/predictions)

  • POST /submit - [Auth] Submit a prediction for a round
  • GET /user/:userId - Get user's prediction history
  • GET /round/:roundId - Get all predictions for a round

Bets (/api/bets)

  • POST /up-down - [Auth] Submit an UP/DOWN bet (stub or on-chain)
  • POST /precision - [Auth] Submit a precision bet (stub or on-chain)

Tournaments (/api/tournaments)

  • 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 id
  • POST /:id/join - [Auth] Join a tournament

Leaderboard (/api/leaderboard)

  • GET / - Get global leaderboard (paginated, optional auth for user position)

Education (/api/education)

  • GET /guides - Get all educational guides grouped by category
  • GET /tip?roundId=<uuid> - Generate contextual educational tip for a resolved round

Chat (/api/chat)

  • POST /send - [Auth] Send a chat message
  • GET /history - Get recent chat messages (paginated, max 50)

Notifications (/api/notifications)

  • GET / - [Auth] Get paginated notifications
  • GET /unread-count - [Auth] Get unread notification count
  • GET /:id - [Auth] Get a specific notification
  • PATCH /:id/read - [Auth] Mark a notification as read
  • PATCH /read-all - [Auth] Mark all notifications as read
  • DELETE /:id - [Auth] Delete a notification
  • DELETE / - [Auth] Delete all read notifications

System Endpoints

  • GET / - Health check with timestamp
  • GET /health - Detailed health check (uptime, status)
  • GET /metrics - Prometheus metrics for HTTP, schedulers, oracle, predictions, WebSocket, rate limits, and DB pool settings
  • GET /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 documentation
  • GET /api-docs.json - OpenAPI specification

Price endpoints — pick the right path

Path App Payload shape Use when
GET /api/price Production (npm run dev / src/index.ts) { asset: "XLM", price_usd, stale, provider, lastUpdatedAt, source, timestamp } You need the XLM oracle feed
GET /api/prices Production 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/price against the hackathon app (it is not mounted there). Unversioned production /api/* routes also send Deprecation / Sunset headers toward a future /api/v1 successor; that does not mean /api/price is deprecated in favor of /api/prices.


Middleware

Authentication Middleware (auth.middleware.ts)

  • authenticateUser: Verifies JWT token and attaches user to request
  • requireAdmin: Ensures user has ADMIN role
  • requireOracle: Ensures user has ORACLE role

Rate Limiter Middleware (rateLimiter.middleware.ts)

  • 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)

Route Authorization Registry (src/security/route-auth.registry.ts)

  • Canonical list of API routes and required auth levels (public, authenticated, admin, oracle)
  • src/tests/security.spec.ts and src/tests/route-auth.registry.spec.ts fail CI when the registry drifts from implemented routes
  • Role middleware (requireAdmin, requireOracle, authenticateUser) is built on a shared requireRole helper in auth.middleware.ts

Database Schema

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

Data Retention & Audit Logging

The backend implements automated data retention policies to control storage growth while maintaining security audit trails.

Audit Logging

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 AuditLog table 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)

Retention Policies

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.


Prerequisites

  • 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)

Installation

1. Clone the Repository

git clone https://github.com/TevaLabs/Xelma-Backend.git
cd Xelma-Backend

2. Install Dependencies

npm install
# or
pnpm install
# or
yarn install

This installs all dependencies including @tevalabs/xelma-bindings.

3. One-Command Local Infra (Docker Compose)

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 --build

To run the hackathon mode (no database required, mock data only):

docker compose --profile hackathon up

Troubleshooting 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

Environment Setup

1. Copy Environment Template

cp .env.example .env

For hackathon/demo mode (mock data, minimal config):

cp .env.hackathon.example .env

2. Configure Environment Variables

See .env.example for the full list of configurable variables. At minimum, set DATABASE_URL and JWT_SECRET before starting the server.

Price Oracle Tuning

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_MS must be greater than ORACLE_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.

Settlement staleness guard

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.

Bet Mode (BET_STUB_MODE)

Variable Description Default
BET_STUB_MODE true = stub mode (bets recorded locally, no on-chain calls); false = bets submitted to Soroban smart contract true

Database pool/timeout tuning

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 /metrics and look for db_pool_settings_info to see the effective values.
  • Validation: invalid values are rejected at startup via config validation.

Metrics contract

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

3. Set Up Database

# 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 seed

Migration story (two schemas, one command)

This 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 .env file. It contains sensitive credentials.


Running the Server

Development Mode (with hot-reload)

npm run dev

Starts 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

Production Mode

# Build TypeScript to JavaScript
npm run build

# Start production server (dist/index.js — matches the Render production profile)
npm start

To run the hackathon/demo server instead (dist/server.js — matches the Render hackathon profile), use npm run start:hackathon after building.

Render Parity Local Profile

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-parity

Required 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.

Verify Server is Running

curl http://localhost:3000/health

Expected response:

{
  "status": "healthy",
  "uptime": 42.123,
  "timestamp": "2026-02-23T12:00:00.000Z"
}

Dead-Letter Queue

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 a FailedDispatch row on NOTIFICATION_CREATE errors (the original error still rethrows so callers behave the same).
  • websocketService.emit*(...) records a FailedDispatch row whenever the socket layer is not initialized or the underlying emit throws. The emit itself is fire-and-forget — the caller's hot path is never broken by a DLQ persistence failure.
  • Rows have attempts, lastError, and status (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; sets RESOLVED on success, bumps attempts and moves to ABANDONED once the cap (default 5) is reached.
  • POST /api/admin/dead-letter/retry-all — replay every PENDING / RETRYING entry (capped, oldest first). Returns a counts summary.

API Versioning

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: true
  • Sunset: Sat, 01 Jan 2027 00:00:00 GMT
  • Link: </api/v1{path}>; rel="successor-version"

API Documentation

The backend provides auto-generated OpenAPI/Swagger documentation.

Authentication Endpoints

Request Challenge

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"
}

Connect (Verify Signature)

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
}

Round Management

Start a New Round (Admin Only)

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 Active Rounds

GET /api/rounds/active

Response:

{
  "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
    }
  ]
}

Prediction Endpoints

Submit a Prediction

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"
  }
}

Bet Endpoints

Submit an UP/DOWN Bet

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"
}

Submit a Precision Bet

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..."
}

Bet Creation Idempotency

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_HOURS environment 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 CONFLICT and error code IDEMPOTENCY_KEY_CONFLICT to 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.

Leaderboard & User Stats

Get Global Leaderboard

GET /api/leaderboard?limit=100&offset=0

Response:

{
  "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"
}

WebSocket Events

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.

Testing

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:load

Redis Socket.IO adapter integration test

src/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-adapter

Coverage 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.

Load test harness

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/submit requests with aggregate RPS and p95 latency assertions.
  • WebSocket fanout — M clients join the round room and must receive prediction:placed within 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

Migration Safety

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.

Scripts

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

Error Code Catalog

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.

Production-Readiness Scorecard

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.


Troubleshooting

Soroban Service Disabled on Startup

Error:

Soroban configuration or bindings missing. Soroban integration DISABLED.

Solution: Ensure your .env contains valid values for:

  • SOROBAN_CONTRACT_ID
  • SOROBAN_ADMIN_SECRET
  • SOROBAN_ORACLE_SECRET

Verify the contract is deployed and accessible at SOROBAN_RPC_URL.


Cannot Find Module '@tevalabs/xelma-bindings'

Error:

Cannot find module '@tevalabs/xelma-bindings'

Solution:

npm install @tevalabs/xelma-bindings
# or
npm install

Database Connection Errors

Error:

Can't reach database server at localhost:5432

Solution:

  1. Verify PostgreSQL is running: psql -U postgres
  2. Check DATABASE_URL in .env matches your database credentials
  3. Ensure database xelma_db exists or run migrations: npm run prisma:migrate

JWT Authentication Failures (401 Unauthorized)

Cause: Token is missing, expired, or invalid.

Solution:

  1. Ensure you're including the token in the Authorization header:
    Authorization: Bearer YOUR_JWT_TOKEN
    
  2. If expired, log in again to get a fresh token
  3. Verify JWT_SECRET in .env matches the one used to generate the token

Forbidden Errors (403) for Admin/Oracle Routes

Cause: Your account doesn't have the required role.

Solution:

  1. Check your user's role in the database (should be ADMIN or ORACLE)
  2. Verify SOROBAN_ADMIN_SECRET and SOROBAN_ORACLE_SECRET in .env match the keypairs registered in the smart contract
  3. Ensure you're using the correct JWT token for the intended role

Price Oracle Not Updating

Cause: CoinGecko API rate limits or network issues.

Solution:

  1. Check server logs for error messages from the oracle service
  2. Verify internet connectivity
  3. Consider using a CoinGecko API key if hitting rate limits (update oracle.ts)

Round Scheduler Not Running

Cause: Scheduler is disabled in configuration.

Solution: Set ROUND_SCHEDULER_ENABLED=true in .env and restart the server.


CI/CD Pipeline

This project uses GitHub Actions for continuous integration and deployment. CI and CD are cleanly separated into two workflow files.

Continuous Integration (CI)

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.

Deployment Workflow (CD)

File: .github/workflows/deploy.yml

The deployment workflow calls CI as a prerequisite (reusable workflow) and only proceeds if all checks pass.

Staging Deployment

  • Trigger: Automatic on push to dev or staging branches, or via manual workflow_dispatch
  • Environment: staging (configured in GitHub repository settings)
  • Process:
    1. CI suite runs and must pass
    2. Dependencies are installed and the project is built
    3. Database migrations run against the staging database
    4. Application is deployed to the staging environment

Production Deployment

  • Trigger: Push to main or manual workflow_dispatch with production selected
  • 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:
    1. CI suite runs and must pass
    2. A reviewer must approve the deployment in the GitHub Actions UI
    3. Dependencies are installed and the project is built
    4. Database migrations run against the production database
    5. Application is deployed to production

Manual Deployment

Both environments can be deployed manually via Actions > Deploy > Run workflow, selecting the target environment from the dropdown.

Environment Configuration

Each environment (staging, production) must have the following configured in GitHub Settings > Environments:

Required Secrets

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

Environment Variables (non-sensitive)

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

Setup Steps

  1. Go to your repository Settings > Environments
  2. Create staging and production environments
  3. For production, enable Required reviewers and add authorized approvers
  4. Add all secrets and variables listed above to each environment
  5. Ensure no secrets contain placeholder values

Rollback Procedure

If a deployment causes issues, use the following rollback process:

Quick Rollback (revert to previous deployment)

# 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

Manual Rollback (redeploy a specific commit)

  1. Go to Actions > Deploy > Run workflow
  2. Select the target environment
  3. Optionally, create a branch from the known-good commit and push it to trigger deployment

Database Rollback

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 commit

Important: Always test rollbacks in staging before applying to production. Database migrations are not automatically reversed; plan migrations to be backward-compatible when possible.


Render Deployment

The repository includes a render.yaml blueprint with two service profiles:

Profile 1: Hackathon Demo (xelma-backend-hackathon)

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)

Profile 2: Production Full Backend (xelma-backend)

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)

Choosing a Profile

  1. Go to Dashboard > New > Blueprint and connect your fork of this repo.
  2. Render reads render.yaml and lists both services. Uncheck the profile you do not want to deploy.
  3. For each selected service, fill in any sync: false env vars.
  4. Deploy. The service is reachable at https://<service-name>.onrender.com:<PORT>.

Port note: The server listens on the port defined by the PORT env var (default 3000). Render automatically sets PORT in the runtime environment.


Hackathon Quick-Start

This section is designed so a new developer can boot and test the API in minutes.

Setup

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 dev

The server starts on http://localhost:3001 (or the PORT in .env). See the API Documentation section above for endpoint examples.

Required Environment Variables

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.

3. Hackathon Endpoint Curl Examples

Health Check

curl http://localhost:3001/health

Get Multi-Asset Prices (hackathon)

curl http://localhost:3001/api/prices

Use /api/prices on the hackathon app. /api/price is the production-only XLM oracle endpoint and is not mounted on port 3001.

Get XLM Oracle Price (production)

curl http://localhost:3000/api/price

Auth: Request Challenge

curl -X POST http://localhost:3001/api/auth/challenge \
  -H "Content-Type: application/json" \
  -d '{"walletAddress": "GXXX...YOUR_STELLAR_ADDRESS"}'

Auth: Connect (verify signature, get JWT)

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"
  }'

Get Active Rounds

curl http://localhost:3001/api/rounds/active

Get Round by ID

curl http://localhost:3001/api/rounds/ROUND_ID

Submit Prediction (requires JWT)

curl -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"}'

Submit UP/DOWN Bet (requires JWT)

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"}'

Get User Profile (requires JWT)

curl http://localhost:3001/api/user/profile \
  -H "Authorization: Bearer YOUR_JWT"

Get User Balance (requires JWT)

curl http://localhost:3001/api/user/balance \
  -H "Authorization: Bearer YOUR_JWT"

Get User Stats (requires JWT)

curl http://localhost:3001/api/user/stats \
  -H "Authorization: Bearer YOUR_JWT"

Get Bet History by Address

curl "http://localhost:3001/api/user/GXXX.../history?limit=20&offset=0"

Get Public Profile

curl http://localhost:3001/api/user/GXXX.../public-profile

Get Wallet Stats (returns per-wallet stats from PostgreSQL, echoing the address param)

curl http://localhost:3001/api/user/GXXX.../stats

Note on Feature Flags: Chat (/api/chat/*) and Notification (/api/notifications/*) endpoints are feature-gated behind the ENABLE_MULTIPLAYER_SOCIAL configuration option. If this option is set to false, these endpoints will return a 404 Not Found JSON response.

Get Transactions (requires JWT)

curl "http://localhost:3001/api/user/transactions?page=1&limit=20" \
  -H "Authorization: Bearer YOUR_JWT"

Get Leaderboard

curl "http://localhost:3001/api/leaderboard?limit=10&offset=0"

List Tournaments

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:tournaments

The seed is idempotent and upserts three stable tournament IDs covering ACTIVE, UPCOMING, and COMPLETED statuses across both UP_DOWN and LEGENDS modes.

Get Tournament Detail

curl http://localhost:3001/api/tournaments/t-001

Get Education Guides

curl http://localhost:3001/api/education/guides

Send Chat Message (requires JWT)

curl -X POST http://localhost:3001/api/chat/send \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_JWT" \
  -d '{"content": "Hello everyone!"}'

Get Chat History

curl "http://localhost:3001/api/chat/history?limit=50"

Get Notifications (requires JWT)

curl "http://localhost:3001/api/notifications?limit=20&offset=0" \
  -H "Authorization: Bearer YOUR_JWT"

Swagger UI

Open http://localhost:3001/api-docs in a browser for interactive API documentation.


ORM Decision (ADR-style) — Issue #391

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:

  • MockLeaderboard gained balance and pendingWinnings fields so it can represent the full hackathon user record (it previously only backed leaderboard reads).
  • A new MockBet model replaces hackathonBets.
  • All db.select()/.insert()/.update() calls in hackathon.service.ts are now prisma.mockRound / prisma.mockLeaderboard / prisma.mockBet calls.
  • The public API of HackathonService (method signatures and return shapes) is unchanged, so PrismaRoundRepository.placeBet and src/routes/user.ts needed 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 once drizzle-orm / drizzle-kit are removed from package.json.
  • The Prisma migration for the new MockBet model and MockLeaderboard columns 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.

Hackathon API Rate Limits

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).


Related Repositories


Contributing

Contributions are welcome! Please:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

ISC


Built with �� by the TevaLabs team on Stellar

About

No description, website, or topics provided.

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages