Engineering documentation
Architecture & workflows
Every Parasol policy is a sized YES position on a Polymarket binary market, bought through the CLOB with our builder code attached. Each YES share pays $1 on resolution, so shares = coverage in dollars. This page documents how the system is built and exactly how money moves through it.
- 3
- services
- 7
- tables
- 7
- workers
- 9
- workflows
- 14
- invariants
Section 01
System overview
A Next.js frontend talks tRPC to a Fastify API; a single workers process does everything that moves money or touches the chain. Postgres holds the ledger that is the source of truth for every balance; Redis carries queues, locks, and live order-book snapshots.
┌──────────────┐ tRPC + SSE ┌──────────────────┐
│ apps/web │ ─────────────► │ apps/api │
│ Next.js 14 │ │ Fastify + tRPC │
└──────────────┘ └───────┬──────────┘
│
┌────────────────────┼─────────────────┐
▼ ▼ │ enqueue
┌─────────────────┐ ┌──────────────────┐ ▼
│ Postgres │ │ Redis │ ◄─── BullMQ jobs
│ ledger = truth │ │ queues·locks·books│
└─────────────────┘ └────────┬─────────┘
▲ │ book snapshots
│ │
┌──────────┴───────────────────┴─────────┐
│ workers │
│ order-executor · resolution-watcher │
│ redeemer · payout · deposit-indexer │
│ invariant · registry-sync · gateway │
└──────┬──────────────┬──────────┬───────┘
▼ ▼ ▼
Polymarket CLOB Gamma API Polygon
orders + WS resolution USDC · CTF (relayer)Four decisions shape everything else
- The Postgres ledger is the source of truth for balances - never the chain. The chain holds one pooled wallet; per-user entitlement lives in
LedgerEntry. - All money math is integer micro-USD inside
@parasol/core, rounded house-favourable, so quotes and fills never accumulate float error. - Every money-moving state change is declared in a typed FSM that throws on illegal transitions and names the exact ledger entry each edge must write.
- All workers are idempotent - they re-check policy state before acting, retry with exponential backoff, and dead-letter with an alert on exhaustion.
Section 02
Monorepo layout
pnpm workspace + Turborepo. Packages are pure and layered; apps and workers compose them. Dependency direction: web → api (types only, via the tRPC AppRouter); api and workers → core, polymarket, db; core depends on nothing.
parasol/ ├── apps/ │ ├── web/ Next.js 14 App Router - marketing, explorer, app │ └── api/ Fastify + tRPC - auth, quotes, policies, wallet, SSE ├── packages/ │ ├── core/ PURE domain logic - money · quote engine · FSM │ ├── polymarket/ gamma · price-gateway · executor · relayer · mock │ ├── db/ Prisma schema + client + migrations + seed │ ├── ui/ "Barometer" design system │ └── config/ shared tsconfig · tailwind preset · tokens ├── workers/ BullMQ: all money-moving & chain-touching jobs └── docker-compose.yml local Postgres 16 + Redis 7
Section 03
Runtime services
| Unit | Framework | Responsibilities |
|---|---|---|
| web | Next.js 14 | Marketing (SSG/ISR), SEO explorer, authenticated app. Talks tRPC to the API; consumes the SSE book stream for live pricing UI. |
| api | Fastify + tRPC | SIWE auth + sessions, quote creation, atomic quote→policy purchase, wallet (balance / deposit address / withdraw), public stats, SSE book fan-out, B2B HMAC scaffold. Enqueues jobs; never talks to the CLOB directly. |
| workers | Node + BullMQ | All money-moving and chain-touching work: order execution, resolution watching, redemption, payout, deposit indexing, registry sync, nightly invariant. In real mode also hosts the Price Gateway. |
| Store | Used for |
|---|---|
| Postgres (Prisma) | Users, markets, quotes, policies, fills, ledger, deposits. Source of truth. |
| Redis | BullMQ queues + DLQs · per-user funds locks · SIWE nonces · rate limits · leader-election locks · order-fill retry cache · book:<tokenId> snapshots + books pub/sub · deposit-indexer cursor. |
Section 04
Data model
| Table | What it holds |
|---|---|
| User | walletAddr (unique - SIWE identity), depositWallet (the shared pooled address, intentionally not unique), payoutMode (in_app | external). |
| Market | Registry of insurable Polymarket markets: conditionId, tokenIdYes/No, category, expiry, resolution state, and a registryTag (e.g. EK585-2026-08-12) for direct product lookups. Kept fresh hourly by registry-sync. |
| Quote | A priced offer: coverage, premium, shares, limit price, fee bps, a priceHash of the exact book snapshot used (audit trail), 60-second expiry, status OPEN → CONSUMED | EXPIRED. |
| Policy | The product - one per consumed quote. Carries FSM state, sharesFilled, premiumPaid, maxPayout, and three tx hashes (txOpen, txRedeem, txPayout). |
| Fill | Every partial CLOB fill (price × size) accumulated by the order-executor. |
| LedgerEntry | Append-only: DEBIT/CREDIT with ref ∈ deposit · premium · payout · refund · resell · withdraw · withdraw_reversal. Corrections are compensating entries, never updates. |
| Deposit | One row per credited on-chain USDC transfer, unique on (txHash, logIndex) so replays and overlapping scans can't double-credit. |
Section 05
Custody & money model
This section explains why the code looks the way it does.
Funds are pooled
Every user deposits USDC to one operator-controlled Polymarket deposit wallet - derived from the operator signer via the relayer, the same address for everyone (which is why User.depositWallet is not unique). The on-chain balance of that wallet is the whole platform's float, never a single user's money.
The ledger is the entitlement
balance(user) = max(0, Σ CREDIT − Σ DEBIT) // inAppBalance() withdrawable(user) = balance − premiums committed to QUOTED policies
Premiums are only debited when the worker confirms fills (QUOTED→ACTIVE). Between purchase and fill the money is promised but not yet debited - so both the purchase gate and the withdraw gate subtract those committed premiums, or a user could spend or withdraw funds an in-flight buy is about to consume.
Concurrency serializes per user
Purchases and withdrawals share one Redis lock, lock:funds:<userId>, because both draw down the same balance. Without it, a concurrent buy and withdraw could each pass their own balance check before either records its debit - overdrawing the pool.
Reserve before transfer
Withdrawals (and the external payout sweep) DEBIT the ledger before the on-chain transfer, so a crash mid-transfer can never leave the same balance withdrawable twice. A clean transfer failure writes a compensating withdraw_reversal CREDIT; ambiguous timeouts are reconciled out of band, never guessed.
Section 06
The policy state machine
Defined in packages/core/src/fsm.ts as a typed transition map. transition(from, to) throws IllegalTransitionError on any edge not listed, and returns the ledger effect the caller is obligated to write.
purchase consumes quote
│
▼
┌────────┐ unfilled after retries ┌─────────────────┐
│ QUOTED │ ───────────────────────► │ FAILED_REFUNDED │
└───┬────┘ (net-zero refund) └─────────────────┘
filled │
(DEBIT premium) ▼
┌────────┐ resolved NO ┌───────────────────┐
│ ACTIVE │ ─────────────► │ EXPIRED_NO_PAYOUT │
└───┬────┘ └───────────────────┘
│ └────► CANCELLED_RESOLD (CREDIT resell)
resolved YES ▼
┌─────────────────────────┐ payout settled ┌──────┐
│ RESOLVED_PAYOUT_PENDING │ ───────────────► │ PAID │
└─────────────────────────┘ (CREDIT payout) └──────┘| Edge | Ledger effect |
|---|---|
| QUOTED → ACTIVE | DEBIT premium (from premiumPaid) |
| QUOTED → FAILED_REFUNDED | DEBIT premium + CREDIT refund - a visible, net-zero trail |
| RESOLVED_PAYOUT_PENDING → PAID | CREDIT payout ($1 × sharesFilled) |
| ACTIVE → CANCELLED_RESOLD | CREDIT resell (proceeds) |
Every worker calls policyInState(id, [...]) before acting, so a duplicate or late job on a policy that already moved on is a no-op, not a double-spend.
Section 07
Workflows, step by step
7.1 · Sign-in (SIWE)
- 1The web app asks
auth.nonce- the API stores 16 random bytes atsiwe:nonce:<nonce>in Redis with a 5-minute TTL. - 2The user signs a Sign-In-With-Ethereum message (domain = web origin) in their wallet.
- 3
auth.verifychecks the signature and the domain - binding toWEB_ORIGINso a SIWE message phished for another site can't be replayed here. - 4The nonce is consumed with Redis
DEL; a result of 0 means already used or expired - sign-in rejected. Single-use, replay-proof. - 5A
Userrow is upserted by wallet address; a signed session cookie is issued.
7.2 · Deposit USDC
- 1The wallet page requests a deposit address; the API returns the pooled wallet (cached on the user row on first request).
- 2The user sends USDC on Polygon from their sign-in wallet to that address.
- 3The deposit-indexer (leader-elected, 30s ticks) scans transfer logs in bounded spans (≤2,000 blocks - public-RPC-friendly), advancing a Redis cursor per span so a crash resumes without rescanning.
- 4Only transfers 20+ blocks deep are trusted, so a shallow reorg can't credit a deposit that later disappears.
- 5Attribution is by sender address. Matched →
Depositrow + deposit CREDIT, idempotent on(txHash, logIndex). Unmatched (exchange withdrawals, unknown wallets) → alert for manual reconciliation, never guessed.
7.3 · Get a quote
- 1The client calls
quotes.createwith amarketIdor aregistryTag(flight EK585 + date →EK585-2026-08-12). - 2The API rejects resolved/expired markets, then reads the YES token's ask-book snapshot from Redis - published by the Price Gateway, with a 30s staleness guard. Stale book ⇒ quote refused, never mispriced.
- 3
computeQuote()walks the ask book in integer micro-USD: shares = coverage; cost = ask levels consumed cheapest-first; premium = cost + fee (bps) + slippage buffer, rounded house-favourable; limit price capped by a buffered worst level. Thin books, sub-$5 minimums, and dust rounding return typed errors, not bad prices. - 4The quote persists as OPEN with a
priceHashof the exact book used, and expires in 60 seconds. The UI shows it on the barometer with a countdown.
7.4 · Purchase a policy
- 1Acquire
lock:funds:<userId>(RedisSET NX EX 30). Held → 409 - which also excludes concurrent withdrawals. - 2In one Prisma transaction: verify quote ownership and freshness (expired → marked EXPIRED, purchase fails cleanly).
- 3Funds gate (real mode):
inAppBalance − committedPremiums ≥ premium. Pooled custody makes this mandatory - an unbacked purchase would gamble with other users' deposits. - 4Consume the quote with a conditional
updateMany(OPEN → CONSUMED); a race returns count 0 → conflict. A quote becomes a policy exactly once. - 5Create the
Policyin QUOTED; enqueue a purchase job (jobId: purchase-<policyId>, 3 attempts, exponential backoff); release the lock. No money has moved yet.
7.5 · Order execution (worker)
- 1Re-check the policy is still QUOTED; ensure the deposit wallet exists on the user row.
- 2At-most-once ordering: the CLOB executor doesn't honour a client idempotency key, so the result is cached in Redis (
order-executor:fill:<policyId>, 24h) the moment it returns. Any BullMQ retry - say the DB write failed after the order filled - reuses the cached fill instead of re-submitting. - 3The executor places a GTD limit buy of YES tokens from the pooled wallet (POLY_1271 signature, gasless via the relayer) with the builder code inside every order struct. Partial fills accumulate; the remainder retries ≤3× within the limit price.
- 4Fill → one transaction: QUOTED → ACTIVE,
Fillrows,sharesFilled/premiumPaid/txOpen, and the premium DEBIT. - 5Total failure → FAILED_REFUNDED with a matched DEBIT+CREDIT pair - an explicit, net-zero refund trail rather than silence.
7.6 · Resolution & redemption
- 1The resolution-watcher ticks over active policies' markets near/past expiry and polls Gamma. It respects the UMA dispute window - nothing pays until the market is finalized; a proposed-then-disputed outcome must never trigger payouts.
- 2NO → ACTIVE → EXPIRED_NO_PAYOUT. Terminal; the premium was the cost of coverage.
- 3YES → mark the market resolved, enqueue redemption.
- 4The redeemer calls
redeemPositionson the Conditional Tokens contract via the relayer - winning YES shares become USDC inside the pooled wallet - recordstxRedeem, flips to RESOLVED_PAYOUT_PENDING, enqueues payout.
7.7 · Payout
- 1Skip unless the policy is RESOLVED_PAYOUT_PENDING. Payout = $1 ×
sharesFilled. - 2Claim exactly once: conditional
updateMany(RESOLVED_PAYOUT_PENDING → PAID)plus the payout CREDIT in one transaction. Zero rows matched means another run already paid - write nothing, pay nothing. No on-chain action happens before this claim commits, so a retry can never re-run the sweep. - 3
payoutMode = external: sweep the freshly credited amount to the user's own wallet - DEBIT reservation before the transfer (mirroring withdraw), reversal CREDIT on clean failure,txPayouton success. - 4Default (
in_app): the payout sits in the ledger balance - spendable on the next premium, withdrawable any time. - 5The user is notified; the policy page timeline shows quote → fill → resolution → payout.
7.8 · Withdraw
- 1Validate the amount (> 0, two-decimal string - never floats over the wire).
- 2Acquire the same
lock:funds:<userId>used by purchase (EX 120 NX); busy → 409. - 3
withdrawable = max(0, inAppBalance − committedPremiums); reject if exceeded. Entitlement is the ledger, never the pooled wallet's on-chain balance - that is what makes withdrawing another user's funds impossible. - 4Reserve first: write the withdraw DEBIT before touching the chain.
- 5Transfer USDC from the pooled wallet to the user's sign-in wallet via the relayer.
- 6Clean failure → compensating
withdraw_reversalCREDIT; balance unchanged, attempt visible in the trail. Ambiguous timeouts go to out-of-band reconciliation. - 7Release the lock; return the tx hash.
7.9 · Live price streaming (SSE)
- 1In real mode the Price Gateway (inside the workers process) subscribes to the CLOB market-data WebSocket for every unresolved registry token and publishes each snapshot twice:
SET book:<tokenId>for the quote engine,PUBLISH booksfor UIs. A 5-minute refresh picks up markets added after boot. - 2The API exposes
GET /stream/books?tokens=a,b,cas Server-Sent Events. One shared Redis subscriber fans out to all browsers - client count never multiplies Redis connections. - 3The unauthenticated endpoint is capped: ≤60 tokens per stream, ≤10 connections per IP, 15s heartbeats, 512KB write-buffer limit - a stalled client is cut off and EventSource reconnects fresh.
- 4Deploys never hang on open streams: an
onClosehook drops every client, then the subscriber. - 5In mock mode a 2s tick wobbles the deterministic books so the UI visibly streams during local dev.
Section 08
Background jobs & safety nets
| Job | Cadence | Purpose |
|---|---|---|
| order-executor | queue-driven | Fill CLOB buys; activate or refund policies |
| resolution-watcher | polling tick | Detect finalized resolutions; respect UMA dispute window |
| redeemer | queue-driven | redeemPositions → USDC into the pooled wallet |
| payout | queue-driven | Credit ledger; optional external sweep |
| deposit-indexer | 30s · leader-elected | Credit on-chain deposits - 20 confirmations, span-scanned, idempotent |
| registry-sync | hourly · leader-elected | Keep Market fresh from Gamma; feeds the gateway token list |
| invariant | nightly | Solvency: pooled wallet ≥ Σ user withdrawable balances |
Shared machinery
- Leader locks - singleton jobs take a Redis lock so multiple worker replicas don't double-run them.
- Idempotency - every job re-reads policy state first; queue job ids are deterministic (
purchase-<policyId>), so duplicate enqueues collapse. - Retries & DLQ - exponential backoff; exhausted jobs dead-letter and emit an alert.
- Alerts page, never self-heal - unattributed deposits, solvency shortfalls, and DLQ arrivals surface to a human. Money discrepancies are never absorbed.
Section 09
Mock mode vs real mode
POLYMARKET_MOCK=true (the default) swaps the entire external stack for a deterministic in-process mock via buildStack(). That's why the full lifecycle - quote → buy → resolve → payout - runs locally with zero Polymarket credentials, and why tests can prove the accounting nets out end-to-end.
| Concern | Mock mode | Real mode |
|---|---|---|
| Order books | Deterministic fixtures, 2s wobble tick | CLOB market-data WS → Price Gateway → Redis, 30s staleness guard |
| Order execution | Deterministic fills | CLOB GTD limit orders, builder code attached, partial-fill accumulation |
| Wallets / chain | In-memory | Gasless relayer (POLY_1271), Polygon RPC, real USDC |
| Resolution | dev.resolveMarket mutation pushes outcomes | Gamma polling + UMA finalization |
| Funds gate on purchase | Skipped (no real funds) | Enforced against the ledger |
| Env guardrails | None needed | Refuses to boot without a non-zero builder code, CLOB creds, and a managed signer (turnkey:/kms: - raw keys rejected in production) |
Section 10
Deployment topology
| Service | Platform | Notes |
|---|---|---|
| web | Vercel | Next.js; install at monorepo root, build scoped to @parasol/web |
| api | Railway | Fastify + tRPC; prisma migrate deploy runs as a pre-deploy step |
| workers | Railway | All jobs + Price Gateway; no public domain |
| Postgres | Supabase | 6543 pooled for runtime / 5432 direct for migrations |
| Redis | Redis Cloud | noeviction policy, sized maxclients, paid tier (free tiers pause on idle) |
Operational notes that have bitten before
- Turbo's
builddepends ongeneratesoprisma generateruns before any TypeScript compiles - do not remove that edge. - Prisma migrations need the direct (5432) connection; the runtime uses the pooled one (6543).
- Redis must be
noeviction- BullMQ job state must never be evicted under memory pressure. - Never point local dev at production Redis; hosted
maxclientsis a hard cap.
Runbook
| Symptom | Where to look |
|---|---|
| Quotes rejected "pricing unavailable" | Price Gateway connection / stale books in Redis (book:<tokenId>) |
| Policies stuck in QUOTED | order-executor + DLQ logs; deposit-wallet funding |
| Payout not landing | resolution-watcher (dispute window?), redeemer + payout DLQ |
| Deposit not credited | deposit-indexer logs - needs 20 confirmations; unattributed senders raise an alert |
| Ledger/wallet drift alert | nightly invariant log - books and chain disagree; do not absorb silently |
| "max number of clients reached" | Redis connection budget - see DEPLOY.md |
Section 11
Security & correctness invariants
The one-line versions of everything above. If a change violates one of these, it is a bug - regardless of whether a test catches it.
- 01Ledger is truth. No user-facing balance is ever read from the chain.
- 02Balances never go negative in anything user-visible - max(0, …) everywhere.
- 03Purchases and withdrawals serialize per user on one shared Redis lock.
- 04Committed-but-undebited premiums are subtracted from both spendable and withdrawable.
- 05Quotes are single-use (conditional OPEN → CONSUMED) and expire in 60 seconds.
- 06Every FSM edge is explicit; illegal transitions throw; money edges name their ledger entry.
- 07CLOB orders are at-most-once per policy - Redis fill cache across job retries.
- 08Reserve before transfer for every outbound on-chain movement; reverse on clean failure only.
- 09Deposits need 20 confirmations, are keyed (txHash, logIndex), and unmatched senders page a human.
- 10No payout before UMA finalization.
- 11Nightly solvency check - the pooled wallet must cover Σ withdrawable; shortfalls alert, never absorb.
- 12SIWE nonces are single-use and domain-bound; sessions are signed cookies.
- 13Production refuses raw keys - managed signer (turnkey:) or nothing; a zero builder code won't boot.
- 14The SSE endpoint is capped per IP and per buffer, so an anonymous client can't exhaust the API.
Markdown twin lives in the repo at docs/ARCHITECTURE.md · deployment runbook in DEPLOY.md.