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 coversapi,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:
- api-gateway healthcheck: image used
wget … /health(404) → overridden to/api/v1/health - ws-gateway: env was missing
JWT_SECRET(caused crash loop); image healthcheck on:8080→ overridden to:3002usingnode -e(image has no wget/curl) - pms-service healthcheck: image used
:3007butPORTenv sets:3008→ overridden to:3008 - ledger-service: image was built without
@quantatrade/loggerworkspace package compiled — the Dockerfile builds 5 of 6 workspace packages but skipslogger. Bind-mounted the locally-builtdist/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):
- Footgun for future copy-paste:
MatchingEngine.balancesmap (line 238) is not the live source. That map is a DTO cache updated by an external monitoring feed and is not read bygetBalances. Anyone reaching into it directly gets a stale view. The live path isexchangeApi.processReport→SingleUserReportResult→calculateLockedAmounts. Stick to the report API. - IOC/FOK orders are trivially correct. They never rest, so post-execution
remainingis zero. The Σ(remaining × price) model needs no special case — locked contribution is naturally zero. - 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 inexchangeApi. 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-passwordwhich 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.productionis missingNEXT_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, thencd /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_URLset tohttps://grpc.quanta.emoment.tech, source patched (was reading unprefixedprocess.env.GRPC_WEB_URLwhich the browser bundle never receives). Admin now reaches the matching engine successfully. Remaining gap: admin uses@bufbuild/connect-webwithcreateConnectTransport(sendsContent-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 tocreateGrpcWebTransport(~5 line change insrc/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): - Cert renewal: certbot systemd timer runs daily;
certbot renew --dry-runpassed last on 2026-04-25. - Backup: DLM policy
policy-0066a67ecb6c3daa7runs 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 originali-0e78aff…(now terminated). Holds the original deployed.nextbuild 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)