Merlin System Architecture
System Overview
Merlin is a modular full-stack application composed of four independently deployable layers:
- Frontend PWA — Next.js 15 static export, runs entirely in the browser after load. No server-side rendering. Deployed to Firebase Hosting.
- FastAPI Backend — Python API handling AI chat, trade routing, price feeds, auth ceremonies, and Firestore reads/writes. Deployed to Cloud Run.
- TypeScript SDK (
src/) — Wallet, provider, privacy, and transaction modules. Compiled to ESM and consumed by the frontend. - Firestore — Document database for users, conversations, trades, social signals, and WebAuthn challenges.
Non-Custodial Guarantee
The backend never sees private keys or seed phrases. Key operations follow this principle:
- Seed phrases are generated client-side (BIP-39 via
@scure/bip39) - Seeds are encrypted with Scrypt + AES-256-GCM before storage in IndexedDB
- The encryption key is derived from the passkey assertion output — it never leaves the device
- The backend receives only the user's Ethereum address (derived public key) and a JWT session token
- Signing happens in the browser; the backend receives signed transactions, not raw keys
Module Map
merlin/
frontend/ Next.js 15 PWA (static export, Firebase Hosting)
app/ App Router pages
page.tsx / root (renders ChatPage)
chat/page.tsx /chat — primary AI chat interface
dashboard/page.tsx /dashboard — portfolio overview
assets/page.tsx /assets — asset list and token details
trades/page.tsx /trades — trade history
personas/page.tsx /personas — AI persona management
social/page.tsx /social — sentiment feed
settings/page.tsx /settings — preferences, seed backup/import
components/
ui/ shadcn/ui primitives (Button, Card, Badge, etc.)
providers/ ClientProviders, QueryProvider, AuthProvider
nav-sidebar.tsx Left navigation sidebar
auth-gate.tsx Blocks UI until authenticated
auth-guard.tsx Redirects to home if unauthenticated
system-status.tsx API health indicator
lib/
auth.ts AuthContext, WalletManager, seed encryption
api.ts ApiClient — authenticated HTTP client
constants.ts API_URL, chain ID, RP ID
crypto.ts Scrypt key derivation, AES-GCM encrypt/decrypt
wallet.ts Address derivation, balance fetching
backend/ FastAPI Python API (Cloud Run)
main.py App factory, CORS middleware, router registration
auth/
webauthn.py py-webauthn 2.1.0 — registration + authentication
session.py JWT creation/verification (HS256, 24h expiry)
models.py Pydantic request/response models
dependencies.py get_current_user FastAPI dependency
db/
firestore.py AsyncClient singleton, collection references
users.py User CRUD + WebAuthn credential storage
conversations.py Chat conversation read/write
trades.py Trade record persistence
signals.py Social signal read/write
challenges.py WebAuthn challenge store (5-min TTL)
services/
chat.py Claude streaming with tool use (Claude Haiku)
xstock.py 61+ xStock token registry + fuzzy matching
guardrails.py 8 pre-trade safety checks
uniswap.py Uniswap V3 quoting + swap calldata (raw ABI)
eip7702.py EIP-7702 delegation + UserOp construction
prices.py Price oracle (CoinMarketCap + Backed Finance, 60s cache)
balances.py On-chain ETH + ERC-20 balance fetching
social.py Grok sentiment analysis (grok-3-mini)
provider.py JSON-RPC client (eth_call, eth_getBalance, eth_sendRawTx)
routers/
auth.py 6 endpoints: register-options, register-verify, auth-options,
auth-verify, logout, me
chat.py 8 chat endpoints + 1 market data endpoint
portfolio.py 4 portfolio endpoints
trade.py 4 trade endpoints
personas.py 5 persona endpoints
social.py 1 social endpoint
src/ TypeScript SDK (ESM, consumed by frontend)
wallet/ BIP-44 account derivation, Kohaku TxSigner
provider/ JSON-RPC provider abstraction (ethers v6 / viem v2)
privacy/ Railgun + Privacy Pools integration
transaction/ Routes public vs shielded transaction requests
Data Flow Diagrams
Auth Flow
Browser Backend Firestore
| | |
| 1. User clicks "Create Account"| |
| startRegistration() | |
| → POST /auth/register-options | |
| | generate challenge |
| | → store challenge (5-min TTL) |→ challenges/{id}
| |← challenge, rp, user options |
| | |
| navigator.credentials.create()| |
| (WebAuthn ceremony — browser | |
| generates passkey on device) | |
| | |
| → POST /auth/register-verify | |
| { credential, address } | |
| | verify attestation |
| | verify challenge TTL |
| | store credential pubkey |→ users/{address}
| | issue JWT (24h, HS256) |
| |← { token, user } |
| | |
| Store JWT in memory | |
| Generate BIP-39 mnemonic | |
| (client-side, @scure/bip39) | |
| Derive Ethereum address | |
| (BIP-44 m/44'/60'/0'/0/0) | |
| Derive AES key from passkey | |
| assertion (Scrypt) | |
| Encrypt seed (AES-256-GCM) | |
| Store ciphertext in IndexedDB | |
| WalletManager.unlock() | |
Login Flow (returning user):
| | |
| startAuthentication() | |
| → POST /auth/auth-options | |
| | fetch credentials for address |←users/{address}
| | generate challenge → store |→challenges/{id}
| |← challenge, allowCredentials |
| | |
| navigator.credentials.get() | |
| (WebAuthn assertion) | |
| | |
| → POST /auth/auth-verify | |
| { assertion, address } | |
| | verify assertion signature |
| | verify challenge |
| | issue JWT |
| |← { token, user } |
| | |
| Derive AES key from assertion | |
| Decrypt seed from IndexedDB | |
| WalletManager.unlock() | |
| 15-minute auto-lock timer | |
Chat Flow
User types message
|
| → POST /chat/message (SSE)
| Authorization: Bearer {jwt}
| { conversation_id, message, wallet_address }
|
Backend:
| 1. get_current_user() — verify JWT
| 2. Load conversation history from Firestore
| 3. Build system prompt (persona config + wallet context)
| 4. Call Claude Haiku with tool definitions:
| execute_trade(asset, side, amount, currency, privacy_mode)
| get_portfolio()
| get_price(symbol)
| get_market_data(symbol)
|
| 5. Stream text tokens back via SSE as they arrive
| event: delta
| data: {"content": "Let me check..."}
|
| If Claude calls execute_trade tool:
| a. Resolve xStock token (fuzzy match via xstock.py)
| b. Run 8 guardrail checks (guardrails.py)
| - Amount within limits ($10–$10,000)
| - Token on allowlist
| - Not sanctioned jurisdiction
| - Slippage within tolerance
| - Not duplicate recent trade
| - US person check for xStocks
| - Sufficient balance
| - Gas estimate within limits
| c. Get Uniswap V3 quote
| d. Stream trade confirmation card via SSE:
| event: trade_confirmation
| data: { asset, side, amount, quote, gas_estimate, privacy_mode }
|
| User confirms trade in UI
| → POST /trade/execute
| { swap_calldata, signed_tx } (calldata built + signed on client)
|
| Backend broadcasts via JSON-RPC → Ethereum
| Persist trade record → Firestore trades/{id}
|
| SSE complete:
| event: done
| data: {}
|
Frontend:
| Parse SSE events
| Render text tokens incrementally
| On trade_confirmation: show TradeConfirmCard component
| On done: finalize message, persist to conversation
Trade Flow
1. QUOTE
Client: build swap params (tokenIn, tokenOut, amountIn, fee tier)
→ POST /trade/quote
Backend: call Uniswap V3 Quoter (eth_call, raw ABI encoding)
← { amountOut, priceImpact, route }
2. SIMULATE
Client: build swap calldata via Uniswap V3 Router ABI
→ POST /trade/simulate
Backend: eth_call the swap with caller = user address
Verify: non-zero output, no revert
← { success, simulatedAmountOut, gasEstimate }
3. POLICY (Guardrails)
Backend: run 8 checks against trade params + simulation result
Any check failure → reject with reason code
← { allowed: true } or { allowed: false, reason: "..." }
4. BUILD + SIGN (client-side)
WalletManager.unlock() (passkey re-auth if locked)
Build swap calldata (via SDK uniswap helpers)
Sign transaction with BIP-44 derived key
← { signedTx: "0x..." }
5. EXECUTE
→ POST /trade/execute
{ signedTx }
Backend: provider.eth_sendRawTransaction(signedTx)
← { txHash }
6. CONFIRM + PERSIST
Backend: poll eth_getTransactionReceipt (up to 3 min)
On receipt: persist trade record to Firestore
← { status: "confirmed", blockNumber, gasUsed }
Frontend: show success state, update portfolio via TanStack Query invalidation
Privacy Flow (Planned)
Shield (deposit to Railgun): Client: build RAILGUN deposit calldata Sign approval tx (ERC-20 allowance to RAILGUN contract) Sign shield tx Broadcast both via backend provider Wait for UTXO to appear in Railgun merkle tree Private Swap: Client: generate ZK proof of UTXO ownership (in-browser, Railgun WASM) Build private swap params (Railgun relayer + Uniswap route) Submit to Railgun relayer network (no on-chain caller identity) Unshield (withdraw from Railgun): Client: generate ZK proof Build unshield calldata Broadcast → funds arrive at public address Privacy Pools (Compliant Mode): Same as Railgun but uses Privacy Pools contracts Generates ASP (Association Set Provider) membership proof Allows regulatory disclosure while maintaining on-chain privacy
Key Design Decisions
Non-Custodial Architecture
Private keys are generated, stored, and used exclusively in the browser. The FastAPI backend only ever receives:
- The user's Ethereum address (derived public key — safe to share)
- Signed transactions (the signature proves authorization without revealing the key)
- A JWT session token (identifies the user for Firestore reads/writes)
The backend cannot reconstruct a private key. If the backend were compromised, no user funds would be at risk.
Claude Tool Use Over Regex Parsing
Trade intents are parsed by Claude Haiku using structured tool use, not regex. This handles:
- Natural language variations: "buy a hundred bucks of Apple" →
{asset: "AAPL", amount: 100, currency: "USD", side: "buy"} - Ambiguous references: "the stock we discussed earlier" resolved via conversation history
- Multi-step intents: "sell half my Tesla and buy Nvidia with the proceeds"
- Error recovery: model asks for clarification rather than silently misinterpreting
Raw ABI Encoding (No web3py)
The backend calls Uniswap V3 contracts (Quoter, SwapRouter) using manually constructed ABI-encoded hex strings and eth_call via the JSON-RPC provider. This avoids the web3py dependency (large, version-conflict-prone) and keeps the backend lean. The encoding logic lives in services/uniswap.py and services/provider.py.
SSE Streaming for Chat
Chat responses stream via Server-Sent Events. This gives the user immediate feedback as the AI generates text, rather than waiting for the full response. The frontend opens an EventSource connection; the backend uses FastAPI's StreamingResponse with text/event-stream content type. Trade confirmation cards are injected into the stream as structured JSON events, not as text, so the frontend can render them as interactive components.
Stateless JWT Sessions
Sessions are stateless HS256 JWTs with 24-hour expiry. The backend does not store sessions in Firestore — every request is validated by verifying the JWT signature against JWT_SECRET. This means no session table, no logout coordination needed server-side, and Cloud Run can scale horizontally without shared session state.
In-Memory Caching for Prices and Quotes
Price data (CoinMarketCap + Backed Finance) is cached in-process for 60 seconds. Uniswap quotes are cached for 5 minutes. This avoids redundant RPC calls during a conversation where the user asks "what's the price of AAPL?" multiple times. Cloud Run instances are ephemeral, so this cache is per-instance, per-deployment — acceptable for price data latency requirements.
Static Export (No SSR)
The frontend uses output: 'export' in next.config.ts, producing a fully static bundle deployed to Firebase Hosting. There is no Next.js server in production. All data fetching is client-side via TanStack Query hitting the FastAPI backend. This simplifies deployment, eliminates cold starts on the frontend, and allows full offline support via the PWA service worker.