Architecture
How the client, the server, the venues, and the AI providers fit together, and the rules that keep them honest.
This page is the map. The three feature pages explain each idea on its own; this one shows how they sit in one system and which rules hold the boundaries in place.
The system at a glance
┌─────────────────────────────────────────────┐
│ your browser │
│ apps/client (React) │
│ │
live market data │ ┌─────────┐ port ┌──────────────────┐ │
+ order signing │ │ trading │──────────▶│ Adapter (venue) │ │──────▶ Venue
(your keys) │ └─────────┘ └──────────────────┘ │ (Hyperliquid,
│ │ │ mock-venue)
│ │ JWT (Privy DID) │
└────────┼──────────────────────────────────────┘
│ Authorization: Bearer <Privy JWT>
▼
┌─────────────────────────────────────────────┐
│ apps/server (NestJS) │
│ │
│ account suggestions agent- │
│ (identity) (AI engine) treasury │
│ │ (pays AI) │
│ ▼ │ │
│ providers ──────────┘ │
│ (mock, minara) x402 │
└──────────────────────┼────────────────────────┘
▼
AI provider (paid per call)The split is deliberate. Anything that touches your money stays in the browser, signed by your keys, talking straight to the venue. The server handles identity, the AI engine, and the one narrow wallet that pays AI providers. It never sits in the path of a trade.
The three ideas, in one sentence each
- Self-custody: your keys sign your trades, and the only thing the server can sign is a capped, expiring, single-recipient AI payment you authorized.
- DEX aggregation: every exchange implements one Adapter port, so the trading screen works the same on all of them and never knows which is live.
- AI suggestions: several providers answer one request, the engine merges them, and a freshness cache plus a validate-before-pay gate keep the per-call cost honest.
A request, end to end
Take the most involved path, asking for a paid AI suggestion, and follow it through both apps:
- You type a symbol in the suggestion sheet. The client calls
POST /api/suggestionswith a fresh Privy JWT attached by the shared HTTP transport. - The server's global guard verifies the JWT against Privy's keys and resolves the acting user from it.
- The suggestions engine validates the request: symbol listed, leverage under the cap, Agent Balance sufficient. A failure returns
422here, before any spending. - The engine hashes the request and checks the
suggestion_historytable. A fresh hit returns immediately, for free. - On a miss, the engine fans out to every provider. The paid provider settles its fee through the agent-treasury delegated signer, which asks Privy to sign a scoped USDC authorization from your Agent Wallet.
- The engine merges the answers into one outcome plus a breakdown, stores it with the exact cost paid, and returns it.
- The client renders the result. If it is directional and you choose to use it, the app prefills the order ticket and waits for you to confirm. Placing the order is a separate action your wallet signs, against the venue, with no server in the path.
The rules that hold it together
Both apps share a small set of hard rules, and most of them are enforced by lint or the type system rather than left to discipline.
| Rule | Where it applies | How it is enforced |
|---|---|---|
| Errors are values, not exceptions | client and server | neverthrow Result types; every error union is enumerated in the signature; a missed variant fails the build |
| One identity, the Privy DID | whole system | users.privyId is the primary key; no surrogate ids; the user is read from the verified JWT |
| The trading screen never imports a venue | client | import/no-restricted-paths lint rule |
| Modules talk only through their public surface | client and server | a mandatory index.ts per module; deep imports are lint-banned |
| A repository touches only its own tables | server | a fixed layer contract; cross-module reads go through the owning module's port |
| Every endpoint is authenticated by default | server | a global Privy auth guard; public routes need an explicit opt-out |
| One HTTP transport, one reconnect helper | client | feature code may not call fetch or open a raw socket |
The thread running through all of these is the same: make the boundary a thing the build checks, not a thing a code review hopes someone remembered.
Where to read more
The repository carries its own deeper documentation, written for the people and agents working in the code:
CONTEXT.mdat the repo root is the domain glossary. If a word in these docs felt precise, that is where its exact meaning is pinned.docs/adr/holds the architecture decision records. Each one explains why a choice was made and what was rejected. The feature pages here cite the relevant ADR numbers.- Each module carries a
MODULE.mddescribing its purpose, public surface, owned tables, and gotchas, kept in step with the code by an update rule.
These docs you are reading now are the introduction. The repo docs are the reference.