Skip to content

Deployment state — us-east-1

Snapshot date: 2026-04-26 Audience: internal team + future Claude Code sessions Supersedes: nothing (this is new). Companion to forward-plan.md.


Topology

Single EC2 instance hosts everything. Cloudflare DNS-only (no proxy). TLS terminated by nginx on the instance via Let's Encrypt.

Resource Value
Instance i-077d5f14e17fb052c, t3.2xlarge, us-east-1b, Ubuntu 24.04, 200 GB gp3
Elastic IP 34.199.105.99 (alloc eipalloc-02511dc727ab251a9)
AWS profile quantatrade (account 094969483885) — default region now ap-southeast-2 (changed for unrelated reasons; pass --region us-east-1 for this instance)
SSH ssh -i ~/.ssh/quantatrade-key.pem ubuntu@34.199.105.99
Source repo on instance /home/ubuntu/qt/ (multi-service mono-repo)
Docs static bundle /var/www/docs.quanta.emoment.tech/ (rsync target from local)

Public surface

All seven *.emoment.tech subdomains resolve to the EIP.

Subdomain Backend (host:port) What it serves
quanta.emoment.tech nginx → pm2 quantatrade :3000 Main Next.js frontend (login/register/dashboard scaffold). Source missing locally — only the .next/ build is deployed.
docs.quanta.emoment.tech nginx static MkDocs build (this repo's docs/)
api.quanta.emoment.tech nginx → docker api-gateway :3001 NestJS API. Real routes: /api/v1/{health,markets,orders,staking/*,settings/*,api-keys,metrics}
ws.quanta.emoment.tech nginx → docker ws-gateway :3002 WebSocket gateway. /health returns 200.
presale.quanta.emoment.tech nginx → pm2 qt-presale :3010 Presale Next.js (/home/ubuntu/qt/presale-app/)
dashboard.quanta.emoment.tech nginx → pm2 qt-dashboard :3011 Investor Portal Next.js (/home/ubuntu/qt/investor-dashboard/)
admin.quanta.emoment.tech nginx → pm2 qt-admin :3012 Admin panel Next.js (/home/ubuntu/qt/admin-panel/)
grpc.quanta.emoment.tech nginx → docker grpc-web-proxy :8088 → matching-engine :9090 Public gRPC-Web endpoint for the matching engine. Direct curl test confirms end-to-end works (returns AdminService.GetExchangeStats data).
trade.quanta.emoment.tech nginx → pm2 qt-trade :3013 Spot trading UI — TradingView chart + watchlist + L2 order book + recent trades + balance header + tabbed orders panel + WebSocket live updates. Source at /home/ubuntu/qt/trade-ui/ on EC2 and https://github.com/QuantaTradeAI/trading-ui. Login → submit order → instant refresh via WS when matching engine emits order.created. Tier 1 + Tier 2 (#6 WS, #8 tabs) shipped 2026-04-27.

TLS certificates

Four Let's Encrypt certs, auto-renewal via certbot systemd timer:

  • quanta.emoment.tech (single SAN, expires 2026-07-24)
  • docs.quanta.emoment.tech (single SAN, expires 2026-07-24)
  • api.quanta.emoment.tech — multi-SAN covers api, ws, presale, dashboard, admin (5 hosts, expires 2026-07-24)
  • grpc.quanta.emoment.tech (single SAN, issued 2026-04-27, expires ~2026-07-26)

Backend services (Docker compose)

10 services running from /home/ubuntu/qt/infrastructure/docker-compose.yml + docker-compose.override.yml:

Service Port (loopback) Image Role
postgres 5432 postgres:16-alpine Primary DB
redis 6379 redis:7-alpine Cache, pub/sub
nats 4222 nats:2-alpine Event broker
api-gateway 3001 ghcr.io/quantatradeai/platform-api-gateway:latest NestJS HTTP API
ws-gateway 3002 ghcr.io/.../ws-gateway:latest WebSocket gateway
order-router 3006 ghcr.io/.../order-router:latest Order routing
ledger-service 3007 ghcr.io/.../ledger-service:latest Double-entry ledger
pms-service 3008 ghcr.io/.../pms-service:latest Position management
risk-service 3009 ghcr.io/.../risk-service:latest Risk checks
subscription-service 3060 ghcr.io/.../subscription-service:latest Subscription mgmt
matching-engine 8090 (HTTP) / 9090 (gRPC) quantatrade-matching-engine:local (built on EC2 from QuantaTradeAI/exchange-core) Spring Boot matching engine. Defines exchange.v1.{Order,Account,MarketData,Admin}Service gRPC services.
grpc-web-proxy 8088 / 9901 (admin) envoyproxy/envoy:v1.29-latest Envoy gRPC-Web ↔ gRPC translator. Listens on 127.0.0.1:8088, proxies to matching-engine:9090.

docker-compose.override.yml — patches applied 2026-04-26

Four image-baked defaults required override patches:

  1. api-gateway healthcheck: image used wget … /health (404) → overridden to /api/v1/health
  2. ws-gateway: env was missing JWT_SECRET (caused crash loop); image healthcheck on :8080 → overridden to :3002 using node -e (image has no wget/curl)
  3. pms-service healthcheck: image used :3007 but PORT env sets :3008 → overridden to :3008
  4. ledger-service: image was built without @quantatrade/logger workspace package compiled — the Dockerfile builds 5 of 6 workspace packages but skips logger. Bind-mounted the locally-built dist/ from /home/ubuntu/qt/platform/packages/logger/dist/ as a fallback. Image was rebuilt on 2026-04-26 from local source (CI image was lost from local Docker daemon). The override's bind-mount still serves as belt-and-braces.

The override file is committed at /home/ubuntu/qt/infrastructure/docker-compose.override.yml on the instance. Not yet committed to the qt repo (separate repo from this one).

End-to-end order pipeline — PROVEN 2026-04-27

A test order traversed the full stack:

POST https://api.quanta.emoment.tech/api/v1/orders
 → api-gateway (JWT auth ✓)
 → NATS "orders.submit"
 → order-router (consumed ✓)
 → gRPC PlaceOrder → matching-engine:9090 (received ✓; logs: "Place order: userId=cmohca... symbol=BTC-USDT side=BUY qty=100000 price=30000")
 → exchange-core2 RiskEngine → RISK_NSF (rejected — see "Internal accounting" below)
 → result back through router → api-gateway → 400 RISK_NSF
 → orders table: status=rejected ✓ persisted

That covers 5 services (api-gateway, NATS, order-router, matching-engine, postgres) plus Redis caching. The pipeline works. The rejection was at the matching engine's internal risk layer for funds reasons, not an integration failure.

Internal accounting gap (discovered 2026-04-27)

Two account ledgers exist in parallel and are NOT yet synced: - postgres accounts table: 100K USDT balance for the admin user (we seeded it). - matching-engine internal ledger: uid=1007 (mapped from postgres user) has 0 balance. exchange-core2 keeps its own in-memory user ledger that's funded by Deposit calls on AccountService.gRPC.

Until a deposit pipeline is built (custody → matching-engine fund), all orders are rejected by the matching engine with RISK_NSF even though postgres shows balance. This is an M2 work item (custody-service → BitGo → fund matching engine on deposit confirmation).

Workaround for the demo: directly call matching-engine's AccountService.Deposit gRPC to credit the user, then orders fill.

Issues found and fixed in the front-to-back test (2026-04-27)

Each one was concealed by Docker's process-level "healthy" status:

Component Issue Fix
Prisma migrations No init migration; only address_pool tables. User/Order/Trade etc. missing prisma db push from inside api-gateway container; 25 tables created
order-router logger.logError(...) called in main.ts and matching-engine-client.ts (10 sites); @quantatrade/logger only exports .error Sed-renamed all to .error, rebuilt image
order-router env MATCHING_ENGINE_URL and _WS_URL defaulted to localhost:8090 — unreachable from container Added to docker-compose.override.yml pointing at http://matching-engine:8090 and ws://matching-engine:8090/ws/events
Markets data api-gateway's MarketsService.marketConfig is hardcoded in source (not from postgres). Comment says "These should eventually come from database". Postgres markets table is empty. Worked around via BTC-USDT (symbol that exists in both api-gateway hardcoded list AND matching engine in-memory symbol set)
Account funding Admin DB user has 100K USDT, matching engine's internal ledger has 0 for that uid Documented as M2 follow-up

Order book — real data wired (2026-04-29)

Was mock. Is now real for any symbol with live depth on the matching engine.

Path: browser → /api/v1/markets/:sym/orderbook → api-gateway → matching-engine REST :8090/api/v1/marketdata/orderbook/:sym → live exchange-core2 book

The fix was config, not new code: - api-gateway's MarketsService.getOrderbook already had a fallback to matching-engine REST (under MATCHING_ENGINE_URL). - That env var was missing on the container; added it to docker-compose.override.yml (MATCHING_ENGINE_URL=http://matching-engine:8090 and MARKET_DATA_URL pointing at the same). - After recreate, GET /api/v1/markets/BTC-USDT/orderbook returns the test buy at $30 000.

Frontend (src/components/OrderBook.tsx): SWR fetch every 1.5s with graceful fallback to the mock generator when the book is empty. Pulsing teal dot when live, "mock" tag when generator-driven — visible signal in the UI of which one is showing.

Still mock (separate work): - Recent trades — matching-engine has no /trades REST endpoint; needs market-data-recorder - Watchlist tickers — /markets/tickers returns zeros without real trades flowing

Trading UI — IA refactor + e2e tests (2026-04-29)

After Richard-HFT's 2026-04-28 design feedback, the trade UI was restructured to a full app shell.

Sidebar (Richard's spec, codified in tests/e2e/sidebar.spec.ts): - Core: Home / Exchange / Portfolio - Premium: Intelligence / Alerts Centre / Rewards / Staking - Utilities: Tokenomics / Account / Billing / Docs

Routes: - //home - /home — greeting + portfolio summary - /exchange/{trade,markets,orders,history} — sub-tabbed - /portfolio/{total,manual,statements} — sub-tabbed (no "Automated") - /intelligence, /alerts, /rewards, /staking, /tokenomics, /account, /billing — coming-soon stubs - /trade — back-compat redirect to /exchange/trade

"Automated" removed in 3 places per Richard's directive: - Sidebar item "Automated Investing" — gone - Portfolio "Automated" tab — gone - "Automated Portfolio" panel + "manual + automated combined" copy — gone

End-to-end tests in repo (QuantaTradeAI/trading-ui/tests/e2e/)

26 Playwright tests, all passing against live trade.quanta.emoment.tech:

File Asserts
auth.spec.ts login → /home, sign-out, route guards
sidebar.spec.ts Richard's IA — items + groups + exact order; "Automated" forbidden
exchange.spec.ts Trade / Markets / Orders / History sub-tabs + content
portfolio.spec.ts Total / Manual / Statements only
order-pipeline.spec.ts UI form → 5-service pipeline → resting open order

Run:

cd /Users/pk/ws/quantatrade-trade-ui
TEST_EMAIL=admin@quanta.emoment.tech TEST_PASSWORD= \
TEST_HOST_IP=34.199.105.99 \
npm run test:e2e

The order-aware sidebar tests fail on any reordering — the IA is regression-proof.

CI — GitHub Actions e2e (2026-04-29)

The 26-spec suite runs on every push and PR to QuantaTradeAI/trading-ui main (.github/workflows/e2e.yml). Workflow runs against the live deployment, not a synthetic preview. All shared actions pinned at @v5 (Node 24-ready: actions/checkout, setup-node, cache, upload-artifact).

Repo secrets:

Secret Purpose
TEST_EMAIL / TEST_PASSWORD login credentials for admin@quanta.emoment.tech
SERVICE_API_KEY / SERVICE_API_SECRET matching-engine service-auth headers (read from infrastructure/.env on the host)
TEST_USER_ID internal user id (cmohcayzs0000n57cskqsutdc) for the deposit step

Self-heal balance top-up. order-pipeline.spec.ts places a real 0.001 BTC @ \$30 000 buy on every run. The matching engine's locked balance is dynamically derived from open orders (so the spec's afterEach cancel correctly releases the lock — see "Cancel-refund semantics" below), but to guard against any drift the workflow deposits 1 B USDT + 1 k BTC into the test user before each suite. Endpoint is POST https://matching.quanta.emoment.tech/api/v1/accounts/deposit.

matching.quanta.emoment.tech — whitelist subdomain. Created 2026-04-29 specifically for CI self-heal. Cloudflare A → EIP, Let's Encrypt cert (single SAN). nginx config at /etc/nginx/sites-available/matching.quanta.emoment.tech only proxies the deposit path; everything else returns 404 so the matching-engine HTTP surface stays internal:

location = /api/v1/accounts/deposit {
 limit_except POST OPTIONS { deny all; }
 proxy_pass http://127.0.0.1:8090;
}
location / { return 404; }

Service auth (x-api-key / x-api-secret / x-participant-type: SYSTEM) is still required — the subdomain only narrows what path is publicly reachable, not who can call it.

First green run after wiring: 2026-04-29 15:53 UTC, 1m12s. Both deposits returned HTTP 200, all 26 tests passed.

Cancel-refund semantics — verified correct (2026-04-29)

Investigated the matching engine source on the host (/home/ubuntu/qt/exchange-core/src/main/java/com/quantatrade/matching/) to confirm cancelling a resting limit order properly releases locked balance.

Model: there is no separate "locked" storage. Total balance is held once per (user, currency); locked is derived on every balance query as Σ(remaining_qty × price) over the user's open orders (AccountGrpcService.calculateLockedAmounts, lines 168–201). Available = total − locked.

Cancel flow (OrderService.cancelOrder → exchange-core2 ApiCancelOrder → disruptor → ReduceEvent in ExchangeEventHandler:169): 1. Order is removed from the book inside a single disruptor event (LMAX ordering guarantee — atomic with respect to other commands). 2. The remaining field of that order is no longer in the open-orders set. 3. Next balance query computes a smaller locked → user's available balance grows back by the cancelled portion.

Partial fills are handled correctly: only size − filled is counted toward locked, so cancelling a partly-filled order refunds only the unfilled portion (line 183 checks remaining > 0).

Implication for CI: the self-heal deposit is defensive insurance, not a workaround for a leak. Locked balance returns to the user the instant the cancel completes; the only ways the test user can actually drain are filled trades (impossible at \$30 k bid in a real-price BTC market) or fees on those fills.

Three follow-ups from a deeper read 2026-04-30 (these strengthen the verification but surface one footgun):

  1. Footgun for future copy-paste: MatchingEngine.balances map (line 238) is not the live source. That map is a DTO cache updated by an external monitoring feed and is not read by getBalances. Anyone reaching into it directly gets a stale view. The live path is exchangeApi.processReportSingleUserReportResultcalculateLockedAmounts. Stick to the report API.
  2. IOC/FOK orders are trivially correct. They never rest, so post-execution remaining is zero. The Σ(remaining × price) model needs no special case — locked contribution is naturally zero.
  3. Atomicity is structural, not best-effort. Both the cancel command (submitCommandAsync) and the post-cancel report query (processReport) route through the same LMAX disruptor ring buffer in exchangeApi. There is no window between "order removed from book" and "locked re-derived" where a stale read could occur.

Trading UI — Tier 1 + 2 features (2026-04-27)

The empty QuantaTradeAI/trading-ui repo now contains a working spot UI (trade.quanta.emoment.tech). What's wired:

Feature Source Data origin
TradingView chart (advanced widget) src/components/TradingViewChart.tsx Real BINANCE feed for major pairs; lightweight-charts mock for QTRA-USDC
Watchlist src/components/Watchlist.tsx Realistic mock with 2.5s ticks (matching engine has no fills yet)
L2 order book + cumulative depth src/components/OrderBook.tsx Realistic mock around base price
Recent trades feed src/components/RecentTrades.tsx Realistic mock
Balance header src/components/BalanceHeader.tsx Tries /api/v1/accounts/balances, falls back to seed values
Order entry form inline in trade/page.tsx Posts to real /api/v1/orders (proven E2E)
Tabbed orders panel (Open / History / Trades / Positions) src/components/OrdersPanel.tsx Open + History real; Trades + Positions are mock with "demo" badges (endpoints don't exist server-side)
WebSocket live updates src/lib/useWebSocket.ts, src/components/WSStatusBadge.tsx Connects to wss://ws.quanta.emoment.tech/?token=<JWT>; "Live" badge in header; auto-refresh of orders list when order.created/balance.updated events fire

WS gateway gap: /home/ubuntu/qt/platform/services/ws-gateway/src/main.ts only forwards order.created from NATS — not order.updated, order.cancelled, order.filled. The trade UI handles this with a hybrid: WS for instant new-order push + 15s safety poll for everything else. Proper fix is ~30 lines in ws-gateway/main.ts adding the missing subscriber.subscribe blocks for those subjects.

Tier 2 deferred (post-demo work): #7 advanced order types (stop-limit, OCO, post-only, TIF), #9 mobile-responsive layout, #10 position-sizing slider with real balance calc.

Known frontend issues (source-level, not infra)

  • quanta.emoment.tech/login: <Link href="/forgot-password"> in the page triggers an RSC prefetch to /forgot-password which 404s. Pre-existing, in the deployed .next/ build. Need source recovery to fix.
  • presale.quanta.emoment.tech/ 403/400 errors: /home/ubuntu/qt/presale-app/.env.production is missing NEXT_PUBLIC_WC_PROJECT_ID — the app falls back to 'demo' (the WalletConnect demo projectId) which is rate-limited and returns 403/400. Fix: create a WalletConnect Cloud project at https://cloud.walletconnect.com, add the ID to .env.production, then cd /home/ubuntu/qt/presale-app && npx next build && pm2 restart qt-presale. Same env file is also missing the contract addresses (NEXT_PUBLIC_SALE_MANAGER_ADDRESS, _VESTING_ADDRESS, _QTRA_ADDRESS) — those are M2 work, contracts don't exist yet.
  • admin.quanta.emoment.tech/ localhost:8088 calls: partly resolved 2026-04-27. NEXT_PUBLIC_GRPC_WEB_URL set to https://grpc.quanta.emoment.tech, source patched (was reading unprefixed process.env.GRPC_WEB_URL which the browser bundle never receives). Admin now reaches the matching engine successfully. Remaining gap: admin uses @bufbuild/connect-web with createConnectTransport (sends Content-Type: application/json) — but our Envoy proxy speaks gRPC-Web (application/grpc-web+proto), not Connect. So the request reaches Envoy but returns 415 "Content-Type 'application/json' is not supported". Two fix paths: (a) change admin to createGrpcWebTransport (~5 line change in src/lib/grpc-client.ts), or (b) replace Envoy with a Connect-aware proxy. Direct gRPC-Web curl tests confirm the backend itself is correct. |

Backups

DLM (Data Lifecycle Manager) policy set up 2026-04-26:

Resource Value
IAM role arn:aws:iam::094969483885:role/AWSDataLifecycleManagerDefaultRole (created with AWSDataLifecycleManagerServiceRole managed policy)
Policy policy-0066a67ecb6c3daa7 (ENABLED) — daily snapshots at 03:00 UTC, 7-day retention
Target EBS volumes tagged Backup=daily (currently vol-0ddc7e9d1de5a2b59 = root disk on i-077d5f14e17fb052c)
Baseline snap-07e001a69835f1973 (manual, pre-DLM, taken 2026-04-26)

Snapshots are tagged SnapshotType=DLM-Daily for filtering. To restore: create a new EBS volume from a snapshot, attach to instance, fsck, mount.

Operational

  • Daily docs redeploy (when this repo's docs/ changes):
    ~/Library/Python/3.9/bin/mkdocs build
    rsync -az --delete -e "ssh -i ~/.ssh/quantatrade-key.pem" \
    /Users/pk/ws/quantatrade/site/ \
    ubuntu@34.199.105.99:/var/www/docs.quanta.emoment.tech/
    
  • Cert renewal: certbot systemd timer runs daily; certbot renew --dry-run passed last on 2026-04-25.
  • Backup: DLM policy policy-0066a67ecb6c3daa7 runs daily at 03:00 UTC, 7-day retention. See "Backups" section above for details.
  • Source AMI snapshot: ami-0171f3162f1582195 (us-east-1) is the pre-Sydney-migration backup of the original i-0e78aff… (now terminated). Holds the original deployed .next build of the missing-source frontend — useful as the only artifact we have of that codebase.

Outstanding work — see forward-plan.md

Highest-impact items for next 1–2 weeks: 1. Recover or rebuild the source for the main frontend (quanta.emoment.tech) 2. Add NEXT_PUBLIC_WC_PROJECT_ID to presale .env.production (WalletConnect Cloud project, then rebuild + pm2 restart) 3. Bootstrap the matching engine + gRPC bridge (M1 work) — unblocks admin's AdminService calls 4. Fix the ledger-service Dockerfile to build @quantatrade/logger (so the bind-mount is no longer needed) 5. Resolve the M2 client blockers (tokenomics, BitGo creds, chain selection) — required to deploy the contracts that presale needs 6. Decide on quantatrade.tech domain migration vs. staying on emoment.tech (production currently on emoment.tech; some docs reference quantatrade.tech but the zone isn't on the same Cloudflare account as our token)