Live demo: bluechip-hn6y.onrender.com · Trading dashboard
Hosted on Render Free with MongoDB Atlas Free. The service sleeps when idle, so the first visit can take about a minute to wake it. Background reconciliation and snapshots pause while asleep.
A full-stack crypto trading platform built with the MERN stack (MongoDB, Express, React, Node.js). Anyone can sign up and trade Bitcoin, Ethereum, Solana and more — orders are placed for real against Gemini's sandbox exchange (real order matching, real fills, real order lifecycle) using a single shared sandbox account with test funds.
No real money is ever deposited, withdrawn, or at risk — every order settles on Gemini's sandbox (
api.sandbox.gemini.com), a separate test environment from Gemini's production exchange. "BlueChip" is a fictional brand, not affiliated with Gemini or any real broker. Every trader shares one sandbox account, so balances and holdings are shared across all users; local order history is scoped to its owner.
📖 New here? Read docs/PROJECT_GUIDE.md — a complete walkthrough of every technology choice (and why), the architecture, and what every file in the repo does.
Placing a limit order and watching the matching engine fill it against the live market:
| Trading dashboard | Real Gemini candles |
|---|---|
![]() |
![]() |
- Live Gemini market data — one shared backend feed (WebSocket streaming + REST fallback, no API key needed) serves every user: live watchlist, tickers, and real OHLC candlestick charts
- Streaming end to end — prices flow Gemini WebSocket → backend cache → Server-Sent Events → browser, with automatic fallback to polling
- Live order book depth — real bids and asks maintained from Gemini's l2 feed, top-of-book with spread on every market page
- Real order execution — market orders place as immediate-or-cancel limit orders crossed through Gemini's sandbox book; limit orders rest as real resting orders on the exchange. HMAC-SHA384-signed private API calls, a background sync loop reconciles local order status against Gemini's
- One shared sandbox account — every signed-in user trades against the same Gemini sandbox account; balances and holdings are shared; local orders are scoped to their owner
- Portfolio history — periodic + per-fill snapshots of the shared account's value power a real performance chart
- Authentication — signup & login with bcrypt-hashed passwords and JWT, gating who can place orders
- Public-ready hardening — rate limiting (auth, orders, global), body-size caps,
/healthz, always-visible sandbox disclaimer
This is a monorepo with three independent applications:
BlueChip/
├── frontend/ # Landing site (React + Vite) → http://localhost:3000
├── dashboard/ # Trading dashboard (React + Vite) → http://localhost:3001
└── backend/ # REST API + exchange engine (Express) → http://localhost:3002
| App | Tech | Port |
|---|---|---|
| frontend | React 19 + TypeScript, React Router, Bootstrap, Axios | 3000 |
| dashboard | React 19 + TypeScript, Material UI, Chart.js (+ financial charts), Axios | 3001 |
| backend | Express 5, Mongoose, JWT, bcrypt, ws (Gemini WebSocket) | 3002 |
How prices flow: the backend keeps one in-memory price cache. Gemini's public v2 market-data WebSocket streams trades into it (with heartbeat watchdog + exponential-backoff reconnect); a REST poller runs underneath as permanent fallback and supplies the 24h-change figure. The dashboard receives cached prices over SSE, with polling fallback — no user ever hits Gemini directly, so rate limits never scale with user count.
How trades settle: for real, on Gemini's sandbox exchange. Placing an order signs an HMAC-SHA384 request (X-GEMINI-APIKEY / X-GEMINI-PAYLOAD / X-GEMINI-SIGNATURE) against api.sandbox.gemini.com using one shared API key for the whole app. MARKET orders are emulated as immediate-or-cancel limit orders priced to cross the book; LIMIT orders rest as real resting orders. A background sync loop (services/orderSync.ts) polls Gemini's order status every ~5s and reconciles local MongoDB order records (OPEN → FILLED / PARTIALLY_FILLED / CANCELLED) — Gemini itself is the source of truth for fills, matching, and balances. The backend refuses to boot if the private API base URL isn't pointed at the sandbox.
Orders are recorded as PENDING before submission. A request ID identifies one set of instructions per user; reusing it with different instructions returns 409. Concurrent retries return the existing record. A lost or ambiguous exchange response becomes UNKNOWN (HTTP 202), and the background reconciler looks up the persisted exchange client ID. Unknown orders are never blindly resubmitted. Requests without a client ID are distinct new orders; clients should provide a stable ID for retries.
Pair increments/minimums come from the sandbox symbol metadata. Valid decimal instructions are preserved; incompatible precision is rejected. Cash balance includes held funds; availableBalance is spendable. Stale prices make portfolio valuation incomplete and prevent misleading snapshots. Use the Orders filters and Newer/Older controls for complete history.
After configuring backend/.env:
nvm use
npm run setup # npm ci in all four app directories
npm run setup:browsers # install Chromium for browser tests
npm run dev # start the three applications
npm run build # typecheck/build all three apps
npm test # unit and database integration suites
npm run test:e2e # compile backend and run browser journeys
npm run check # builds, all tests, and browser journeysEach app retains its own lockfile. The root runner adds no runtime dependencies. See operations for environment settings, startup indexes, unresolved orders, and rollout precautions.
git clone https://github.com/sogoyalz/BlueChip.git
cd BlueChipcd backend
npm install
cp .env.example .env # then fill in your real values (see below)
npm start # starts on http://localhost:3002cd frontend
npm install
npm start # starts on http://localhost:3000cd dashboard
npm install
npm start # starts on http://localhost:3001Run each in its own terminal tab. Start the backend first so the dashboard gets prices and data.
Each app uses a .env file (never committed — see .env.example for the template).
backend/.env
MONGO_URL=your_mongodb_connection_string
TOKEN_KEY=your_jwt_secret
PORT=3002
NODE_ENV=production
# Production only: comma-separated allowed browser origins
# CORS_ORIGINS=https://your-landing.netlify.app,https://your-dashboard.netlify.app
#
# NOTE: the auth cookie defaults to sameSite:"lax", which requires the backend
# and both frontends to share ONE registrable domain (e.g. api./app./www.
# yoursite.com). If you deploy to the default *.onrender.com / *.netlify.app
# domains instead, the cookie won't be sent cross-site and login will silently
# fail. Either set up custom subdomains on one domain, or set
# COOKIE_SAMESITE=none (which also needs NODE_ENV=production, since that is
# what sets Secure — the backend refuses to boot on the wrong combination).
# COOKIE_SAMESITE=
# Gemini SANDBOX trading credentials — register at
# https://exchange.sandbox.gemini.com/ and create an API key with Trading
# permission. One shared key for the whole app.
GEMINI_API_KEY=your_sandbox_api_key
GEMINI_API_SECRET=your_sandbox_api_secret
# Must stay pointed at the sandbox — the app refuses to boot otherwise.
# Defaults to https://api.sandbox.gemini.com if unset.
# GEMINI_PRIVATE_API_URL=frontend/.env
# Optional — override backend origins for deployed environments.
# The dev-server port lives in vite.config.ts, not here.
# VITE_API_URL=https://your-backend.example.com
# VITE_DASHBOARD_URL=https://your-dashboard.example.comdashboard/.env
# Optional — override backend/login origins for deployed environments.
# The dev-server port lives in vite.config.ts, not here.
# VITE_API_URL=https://your-backend.example.com
# VITE_LOGIN_URL=https://your-landing.example.com/loginBase URL: http://localhost:3002
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/signup |
— | Register a login; shared sandbox funds are unchanged |
POST |
/login |
— | Log in and receive a JWT |
POST |
/ |
— | Verify the current auth token |
GET |
/healthz |
— | Health: WebSocket + price freshness |
GET |
/api/symbols |
— | Supported Gemini pairs |
GET |
/api/prices |
— | Live price cache (all symbols) |
GET |
/api/stream |
— | Live prices over Server-Sent Events |
GET |
/api/book/:symbol |
— | Top 10 bids/asks from the live order book |
GET |
/api/candles/:symbol?timeframe=1hr |
— | OHLCV history (cached proxy) |
GET |
/api/holdings |
✅ | The shared account's holdings, enriched with live prices |
GET |
/api/account |
✅ | Shared cash balance + portfolio value |
POST |
/api/orders |
✅ | Place a MARKET or LIMIT order (real Gemini sandbox order) |
GET |
/api/orders |
✅ | Your orders (?status=open for resting) |
POST |
/api/orders/:id/cancel |
✅ | Cancel a resting order (cancels on Gemini first) |
GET |
/api/portfolio/history?range=1W |
✅ | Shared portfolio value snapshots |
✅ = requires a valid JWT, sent as the httpOnly token cookie or an
Authorization: Bearer header. Tokens are deliberately not accepted from the
query string or the request body — both leak into access logs, proxy logs, browser
history and error reports.
GET /api/orders accepts status=all|open|pending, an optional symbol, and a cursor. X-Next-Cursor carries the next page; X-Total-Count is the total for the selected filter. The body remains an array. /livez checks process liveness; /readyz returns 503 when the database is disconnected.
Orders now link to a details page with up to 50 recent local events and reconciliation diagnostics. Explicitly allowlisted operators can inspect the unresolved queue and schedule original-order status lookups; see operator setup. npm run validate:live runs read-only public sandbox/database checks; npm run rehearse:restore verifies fixture dump/restore on temporary databases. Recorded validation records the original inspection and later live sandbox verification; predeployment status records verified indexes, operator access, and remaining AWS deployment work.
Place an order (include X-Requested-With: XMLHttpRequest and authentication):
POST /api/orders
{ "symbol": "BTCUSD", "side": "BUY", "type": "LIMIT", "qty": 0.05, "limitPrice": 64000, "clientOrderId": "one-stable-id-per-order" }cd backend && npx jest # unit and integration tests; temporary MongoDB is downloaded on first run
cd dashboard && npm test # dashboard component tests
cd frontend && npm test # landing-site component testsBackend unit tests mock exchange/network boundaries. Persistence and recovery integration suites use a real temporary MongoDB, including concurrent submissions, response loss, restart recovery, and stable pagination. First use may download a MongoDB binary. The browser suite builds both apps and uses a local exchange stub; no live trades are submitted.
Deployment configuration is included; a live deployment is not verified by this repository. Read the operations runbook before deploying.
- MongoDB Atlas — create a free M0 cluster, get the connection string.
- Backend → Render (free tier): root directory
backend, buildnpm ci && npm run build, startnpm run serve. Env:MONGO_URL,TOKEN_KEY,CORS_ORIGINS(both Netlify URLs). - Frontends → Netlify (two sites):
frontend/netlify.tomlanddashboard/netlify.tomlare already in place. Set theVITE_*env vars per site (they're baked at build time; the oldREACT_APP_*names still work). - Keep-alive: point a free uptime pinger (UptimeRobot / cron-job.org) at
GET /healthzevery ~10 min — on Render's free tier the server sleeps when idle, which pauses order syncing and snapshots.
backend/
├── config/ # curated Gemini symbol list
├── controllers/ # request handlers (auth)
├── middlewares/ # JWT verification, rate limits
├── model/ # Mongoose models
├── schemas/ # Mongoose schemas (User, Order, Snapshot)
├── routes/ # auth, market data, orders, portfolio
├── services/ # the interesting bits:
│ ├── gemini.ts # public Gemini REST wrappers (market data)
│ ├── geminiWs.ts # WebSocket feed (watchdog + backoff reconnect)
│ ├── geminiPrivate.ts # HMAC-signed Gemini sandbox trading client
│ ├── priceFeed.ts # shared in-memory price cache
│ ├── orderEngine.ts # validation + real order placement/cancel
│ ├── orderSync.ts # reconciles local order status against Gemini
│ └── snapshots.ts # shared portfolio history
├── util/ # money rounding
└── index.ts # app entry point
frontend/src/landing_page/
├── home/ about/ products/ pricing/ support/
├── login/ signup/
└── Navbar.tsx Footer.tsx ...
dashboard/src/components/
├── Dashboard.tsx Holdings.tsx Orders.tsx
├── Funds.tsx WatchList.tsx Summary.tsx MarketDetail.tsx
├── PricesContext.tsx (SSE prices with polling fallback)
└── shared/ (BuySellModal, CandleChart, DataTable, ...)
This project is for educational purposes. Not for commercial use.
Deployment packaging and its opt-in local HTTPS browser check are documented in deploy/README.md. Run npm run test:deployment with Docker running to verify the /app/ dashboard and shared-origin authentication.



