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.

Service topology
┌──────────────┐   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 workerscore, polymarket, db; core depends on nothing.

Workspace
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

UnitFrameworkResponsibilities
webNext.js 14Marketing (SSG/ISR), SEO explorer, authenticated app. Talks tRPC to the API; consumes the SSE book stream for live pricing UI.
apiFastify + tRPCSIWE 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.
workersNode + BullMQAll 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.
StoreUsed for
Postgres (Prisma)Users, markets, quotes, policies, fills, ledger, deposits. Source of truth.
RedisBullMQ 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

TableWhat it holds
UserwalletAddr (unique - SIWE identity), depositWallet (the shared pooled address, intentionally not unique), payoutMode (in_app | external).
MarketRegistry 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.
QuoteA 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.
PolicyThe product - one per consumed quote. Carries FSM state, sharesFilled, premiumPaid, maxPayout, and three tx hashes (txOpen, txRedeem, txPayout).
FillEvery partial CLOB fill (price × size) accumulated by the order-executor.
LedgerEntryAppend-only: DEBIT/CREDIT with ref ∈ deposit · premium · payout · refund · resell · withdraw · withdraw_reversal. Corrections are compensating entries, never updates.
DepositOne 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
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.

Policy lifecycle
            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)  └──────┘
EdgeLedger effect
QUOTED → ACTIVEDEBIT premium (from premiumPaid)
QUOTED → FAILED_REFUNDEDDEBIT premium + CREDIT refund - a visible, net-zero trail
RESOLVED_PAYOUT_PENDING → PAIDCREDIT payout ($1 × sharesFilled)
ACTIVE → CANCELLED_RESOLDCREDIT 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)

  1. 1The web app asks auth.nonce - the API stores 16 random bytes at siwe:nonce:<nonce> in Redis with a 5-minute TTL.
  2. 2The user signs a Sign-In-With-Ethereum message (domain = web origin) in their wallet.
  3. 3auth.verify checks the signature and the domain - binding to WEB_ORIGIN so a SIWE message phished for another site can't be replayed here.
  4. 4The nonce is consumed with Redis DEL; a result of 0 means already used or expired - sign-in rejected. Single-use, replay-proof.
  5. 5A User row is upserted by wallet address; a signed session cookie is issued.

7.2 · Deposit USDC

  1. 1The wallet page requests a deposit address; the API returns the pooled wallet (cached on the user row on first request).
  2. 2The user sends USDC on Polygon from their sign-in wallet to that address.
  3. 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.
  4. 4Only transfers 20+ blocks deep are trusted, so a shallow reorg can't credit a deposit that later disappears.
  5. 5Attribution is by sender address. Matched → Deposit row + deposit CREDIT, idempotent on (txHash, logIndex). Unmatched (exchange withdrawals, unknown wallets) → alert for manual reconciliation, never guessed.

7.3 · Get a quote

  1. 1The client calls quotes.create with a marketId or a registryTag (flight EK585 + date → EK585-2026-08-12).
  2. 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. 3computeQuote() 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.
  4. 4The quote persists as OPEN with a priceHash of the exact book used, and expires in 60 seconds. The UI shows it on the barometer with a countdown.

7.4 · Purchase a policy

  1. 1Acquire lock:funds:<userId> (Redis SET NX EX 30). Held → 409 - which also excludes concurrent withdrawals.
  2. 2In one Prisma transaction: verify quote ownership and freshness (expired → marked EXPIRED, purchase fails cleanly).
  3. 3Funds gate (real mode): inAppBalance − committedPremiums ≥ premium. Pooled custody makes this mandatory - an unbacked purchase would gamble with other users' deposits.
  4. 4Consume the quote with a conditional updateMany(OPEN → CONSUMED); a race returns count 0 → conflict. A quote becomes a policy exactly once.
  5. 5Create the Policy in 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)

  1. 1Re-check the policy is still QUOTED; ensure the deposit wallet exists on the user row.
  2. 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.
  3. 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.
  4. 4Fill → one transaction: QUOTED → ACTIVE, Fill rows, sharesFilled/premiumPaid/txOpen, and the premium DEBIT.
  5. 5Total failure → FAILED_REFUNDED with a matched DEBIT+CREDIT pair - an explicit, net-zero refund trail rather than silence.

7.6 · Resolution & redemption

  1. 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.
  2. 2NO → ACTIVE → EXPIRED_NO_PAYOUT. Terminal; the premium was the cost of coverage.
  3. 3YES → mark the market resolved, enqueue redemption.
  4. 4The redeemer calls redeemPositions on the Conditional Tokens contract via the relayer - winning YES shares become USDC inside the pooled wallet - records txRedeem, flips to RESOLVED_PAYOUT_PENDING, enqueues payout.

7.7 · Payout

  1. 1Skip unless the policy is RESOLVED_PAYOUT_PENDING. Payout = $1 × sharesFilled.
  2. 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. 3payoutMode = external: sweep the freshly credited amount to the user's own wallet - DEBIT reservation before the transfer (mirroring withdraw), reversal CREDIT on clean failure, txPayout on success.
  4. 4Default (in_app): the payout sits in the ledger balance - spendable on the next premium, withdrawable any time.
  5. 5The user is notified; the policy page timeline shows quote → fill → resolution → payout.

7.8 · Withdraw

  1. 1Validate the amount (> 0, two-decimal string - never floats over the wire).
  2. 2Acquire the same lock:funds:<userId> used by purchase (EX 120 NX); busy → 409.
  3. 3withdrawable = 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.
  4. 4Reserve first: write the withdraw DEBIT before touching the chain.
  5. 5Transfer USDC from the pooled wallet to the user's sign-in wallet via the relayer.
  6. 6Clean failure → compensating withdraw_reversal CREDIT; balance unchanged, attempt visible in the trail. Ambiguous timeouts go to out-of-band reconciliation.
  7. 7Release the lock; return the tx hash.

7.9 · Live price streaming (SSE)

  1. 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 books for UIs. A 5-minute refresh picks up markets added after boot.
  2. 2The API exposes GET /stream/books?tokens=a,b,c as Server-Sent Events. One shared Redis subscriber fans out to all browsers - client count never multiplies Redis connections.
  3. 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.
  4. 4Deploys never hang on open streams: an onClose hook drops every client, then the subscriber.
  5. 5In mock mode a 2s tick wobbles the deterministic books so the UI visibly streams during local dev.

Section 08

Background jobs & safety nets

JobCadencePurpose
order-executorqueue-drivenFill CLOB buys; activate or refund policies
resolution-watcherpolling tickDetect finalized resolutions; respect UMA dispute window
redeemerqueue-drivenredeemPositions → USDC into the pooled wallet
payoutqueue-drivenCredit ledger; optional external sweep
deposit-indexer30s · leader-electedCredit on-chain deposits - 20 confirmations, span-scanned, idempotent
registry-synchourly · leader-electedKeep Market fresh from Gamma; feeds the gateway token list
invariantnightlySolvency: 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.

ConcernMock modeReal mode
Order booksDeterministic fixtures, 2s wobble tickCLOB market-data WS → Price Gateway → Redis, 30s staleness guard
Order executionDeterministic fillsCLOB GTD limit orders, builder code attached, partial-fill accumulation
Wallets / chainIn-memoryGasless relayer (POLY_1271), Polygon RPC, real USDC
Resolutiondev.resolveMarket mutation pushes outcomesGamma polling + UMA finalization
Funds gate on purchaseSkipped (no real funds)Enforced against the ledger
Env guardrailsNone neededRefuses 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

ServicePlatformNotes
webVercelNext.js; install at monorepo root, build scoped to @parasol/web
apiRailwayFastify + tRPC; prisma migrate deploy runs as a pre-deploy step
workersRailwayAll jobs + Price Gateway; no public domain
PostgresSupabase6543 pooled for runtime / 5432 direct for migrations
RedisRedis Cloudnoeviction policy, sized maxclients, paid tier (free tiers pause on idle)

Operational notes that have bitten before

  • Turbo's build depends on generate so prisma generate runs 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 maxclients is a hard cap.

Runbook

SymptomWhere to look
Quotes rejected "pricing unavailable"Price Gateway connection / stale books in Redis (book:<tokenId>)
Policies stuck in QUOTEDorder-executor + DLQ logs; deposit-wallet funding
Payout not landingresolution-watcher (dispute window?), redeemer + payout DLQ
Deposit not crediteddeposit-indexer logs - needs 20 confirmations; unattributed senders raise an alert
Ledger/wallet drift alertnightly 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.

  1. 01Ledger is truth. No user-facing balance is ever read from the chain.
  2. 02Balances never go negative in anything user-visible - max(0, …) everywhere.
  3. 03Purchases and withdrawals serialize per user on one shared Redis lock.
  4. 04Committed-but-undebited premiums are subtracted from both spendable and withdrawable.
  5. 05Quotes are single-use (conditional OPEN → CONSUMED) and expire in 60 seconds.
  6. 06Every FSM edge is explicit; illegal transitions throw; money edges name their ledger entry.
  7. 07CLOB orders are at-most-once per policy - Redis fill cache across job retries.
  8. 08Reserve before transfer for every outbound on-chain movement; reverse on clean failure only.
  9. 09Deposits need 20 confirmations, are keyed (txHash, logIndex), and unmatched senders page a human.
  10. 10No payout before UMA finalization.
  11. 11Nightly solvency check - the pooled wallet must cover Σ withdrawable; shortfalls alert, never absorb.
  12. 12SIWE nonces are single-use and domain-bound; sessions are signed cookies.
  13. 13Production refuses raw keys - managed signer (turnkey:) or nothing; a zero builder code won't boot.
  14. 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.