Archived 2026-08-03 — all 4 scope tasks (+ CLOB rev 2) are ✅ complete; see the status table below for per-item evidence. This file is frozen as the jul-28 record. The rolling plan continues in current-plan.md.
Design doc for
jul-28-verex.md. Reference UI:docs/images/verex-ui/create-market.png. Written 2026-07-28 for jay’s review — no implementation until approved. One branch for everything:claude/jul-28-features.
| # | Task | One-line design | Status |
|---|---|---|---|
| A | Multi Outcomes | N-outcome market = N binary CTF conditions + a DB MarketGroup, with group-wide price renormalization (Σ = 1) |
✅ Done — one sub-item open (A.4 balanceOfBatch, below) |
| B | Create Market | /create form → POST /market-groups returns 202 + jobId; a background job runs the on-chain batch, funded by operator USDC |
✅ Done |
| C | Faster Trading / Resolution / Redeem | DB-backed ChainJob queue + in-process worker; API answers from the DB immediately, chain settles asynchronously |
✅ Done |
| D | Top menu for usage | “How to use” page (trade / resolve / portfolio / redeem / create) linked from SiteNav, with real screenshots |
✅ Done |
| — | Execution model — CLOB (rev 2) | Order table + price-time-priority matching engine + operator MM ladders |
✅ Done |
Status verified 2026-08-03 against the code on
main@33252ff(file-level check, not a functional re-test). Evidence:
- A —
MarketGroupmodel + migration20260728044451_market_groups; A.3 renormalization atpackages/api/src/mm.ts:110-122;GroupCard/GroupChart/GroupView/GroupResolvePanel; routeapp/group/[slug];GET /market-groups/:slug.- B —
app/create/{page,CreateClient}.tsx(default 100 / cap 1,000 USDC per outcome, pollsGET /jobs/:id);POST /market-groups→202 + jobId; pre-flight solvency + MockUSDC shortfall mint inpackages/api/src/group-create.ts;CREATE_GROUPjob type.- C —
ChainJobmodel + migration20260728044926_chain_jobs;packages/api/src/worker.tshas the atomicupdateManyclaim (:96), 5→25→125 s backoff (:123), and stuck-RUNNINGrecovery (:83);SettlementChip+ job polling wired intoTradePanel,ResolvePanel,PortfolioClient.- D —
app/how-to/page.tsx, linked fromSiteNav.tsx:57, with 7 real screenshots inpackages/web/public/how-to/.- CLOB (rev 2) —
Ordermodel + migration20260728045110_clob_orders;book.tsmatching engine;mm.tsladders + renormalizing re-quotes;POST /orders/DELETE /orders/:id; read-only depth widgetBookPanel(i.e. open question 1 landed on the recommended answer — casual Buy/Sell panel + depth display, no user-facing limit-order form).Not done — A.4
walletSummarybatching. The design called for replacing the sequential per-outcomebalanceOfloop with onebalanceOfBatchmulticall; the loop is still there atpackages/api/src/trade.ts:79. Functionally correct, but it gets slower as grouped markets multiply the outcome count — the exact case the design flagged.
Proposed implementation order: A → C → B → D (not the task-file order). Reason: the Create-Market screenshot explicitly shows a “batch processor will create the markets … asynchronously” — i.e. Task B rides on Task C’s job infrastructure. Building C first avoids building a throwaway mini-queue inside B. Each step is still serial with coherent commits.
Concepts were reviewed from /Users/jay/work/nostra-server; no code is copied. Deliberate design changes:
fillOrder flowChainJob worker claims jobs with an atomic updateMany guard and adds exponential backoff.Adopted from jay’s review comment; replaces the “keep
fillOrdermaker/taker” plan everywhere below. Binary and grouped markets both trade through the book.
Order table (Prisma): marketId, outcomeId, maker (wallet address), side BUY|SELL,
price Decimal(10,6) (USDC per share, 0.01–0.99), size, sizeFilled,
status OPEN|PARTIALLY_FILLED|FILLED|CANCELLED|EXPIRED, expiresAt, signedOrder Json
(EIP-712 — signed server-side with the demo wallet key, exactly like today’s flow), unique
order hash.POST /orders: validate funds (BUY: price × size USDC; SELL: token balance),
insert, and run the matching engine inside the same DB transaction. Cancel via
DELETE /orders/:id (also cancels on-chain-invalid orders lazily).Trade
(settlement: PENDING) and enqueues a ChainJob that settles via CTFExchange.matchOrders
(multicall-batched when several pairs settle together). SELECT … FOR UPDATE row locks make
concurrent placements race-free.PricePoint
on every trade and re-quote — charts and cards keep working unchanged.Impact on the rest of this doc: A.3’s formula now drives MM quote centers (not direct DB price
writes); Task C’s job types gain SETTLE_MATCH + a re-quote step; Task B’s “provision liquidity”
becomes “mint inventory + post the initial ladders”.
Option (a) — N grouped binary conditions ✅ recommended
Each outcome (“Brazil wins WC”) is its own binary CTF condition with its own Yes/No token pair,
registered on CTFExchange exactly like today. A DB-only MarketGroup stitches them together.
Jay’s Comment: I’d like to choose this option.
Option (b) — one N-slot condition ❌ rejected. ConditionalTokens.prepareCondition happily
takes outcomeSlotCount = N, but our exchange can’t trade it:
ctf-exchange/src/exchange/mixins/Registry.sol:41-51 stores exactly one complement per token
and validateTokenId (enforced on every fill, Trading.sol:76) requires it — the registry
is structurally binary. Polymarket itself solves this with grouped binaries + a NegRisk adapter.
Consequences we accept (same trade-off Polymarket/nostra accept):
L × N USDC per group instead of L.reportPayouts txs (winner [1,0], losers [0,1]) — handled by Task C’s queue.docs/features/negative-risk-markets.md).model MarketGroup {
id String @id @default(cuid())
slug String @unique // "world-series-champion-2026"
title String // "Who will win the 2026 World Series?"
description String?
category String
imageUrl String?
status MarketStatus @default(OPEN) // reuse existing enum
closesAt DateTime?
resolvedMarketId String? // winning member market once RESOLVED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
markets Market[]
@@index([category, status])
}
model Market {
// ... existing fields unchanged (slug, questionId, conditionId, yesTokenId, noTokenId, ...)
groupId String? // null = standalone binary market (today's markets)
group MarketGroup? @relation(fields: [groupId], references: [id])
groupLabel String? // outcome display name inside the group: "Brazil"
sortOrder Int @default(0) // ordering within the group
}
Why this shape: every existing binary market keeps working untouched (groupId = null); a group
member is a full market (own condition, own trade panel, own PricePoint history — so the
multi-series group chart falls out for free from existing per-market PricePoint rows). The
on-chain question stays the full legal sentence (“Will Brazil win the 2026 World Cup?”), the DB
groupLabel is the short chip text — same split nostra uses, which read well in its UI.
No change to Outcome, Trade, PricePoint. (PricePoint already hangs off the member market,
which is the per-outcome series.)
Rev 2 (CLOB): this formula no longer writes outcome prices directly — it computes the operator MM’s new quote centers after a fill (the book then produces the displayed mid). The math is unchanged.
Standalone markets keep a single-market version (linear impact k = usdc/2000 shifts the
quote center; No = 1 − Yes).
For a market inside a group, after computing the traded member’s new center p'ᵢ:
scale = (1 − p'ᵢ) / Σⱼ≠ᵢ pⱼ // proportionally rescale the others
p'ⱼ = clamp(pⱼ × scale) for j ≠ i
each member's No price = 1 − member's Yes price (unchanged invariant)
$transaction updates all member outcomes + the traded market’s volume + one
PricePoint per moved member (so the group chart shows the cross-impact).PRICE_MIN = 0.02 before rescaling the remainder, so a 20-outcome
group can’t push anyone negative.resolve.ts:52-61).API (packages/api):
GET /markets grows a grouped shape: groups are returned as one item with member summaries
(label, yesPrice) — new GET /market-groups/:slug for the detail page.POST /trade unchanged externally (still targets a member market slug + Yes/No) — only the
price-update internals branch on groupId.resolveMarket gains a group path: resolving via the group page reports payouts for all N
members (Task C makes this one queued job).walletSummary (trade.ts:241-294) — replace the sequential per-outcome balanceOf loop with
one balanceOfBatch multicall; with N-outcome groups the current loop gets too slow anyway.Web (packages/web) — the known Yes/No-hardcoded surfaces (from the audit):
MarketCard → new GroupCard: title + top-3 outcomes with % + a “N outcomes” badge
(Polymarket-style rows), standalone markets keep the current card.app/market/[slug]/page.tsx → group detail page app/group/[slug]/page.tsx: outcome rows
(label, Yes price, Buy Yes/No buttons), multi-series ProbChart (one line per member, top 5),
trade panel targets the row you click.TradePanel: gets outcomeLabel context but keeps its Yes/No pair semantics (you always trade
the binary member) — smallest possible change.ResolvePanel (group variant): pick the winning outcome from a list instead of two buttons.ProbChart: accept N series + a small legend; per-outcome line colors from a fixed palette
added in globals.css (extends the --yes/--no vars).Add ~3 groups alongside the existing 10 binary markets (refreshed vs. Polymarket’s current style):
Seed refactor: extract the per-market on-chain block (seed.ts:422-461) into a shared
createBinaryMarketOnChain() in packages/api/src/market-create.ts so the seed, and later Task
B’s runtime endpoint, call the same function (prepare → register → split). Group seeding = loop
over outcomes + one MarketGroup row; initial prices normalized to sum 1.
Presented before B because B depends on it.
ChainJob queueToday every endpoint blocks on 1–3 waitForTransactionReceipt calls (Sepolia ≈ 12s blocks — the
pain jay describes). The jul-22 doc stopped at optimistic UI; the jul-28 task explicitly asks
for server-side async (“timer or thread”), which supersedes that doc’s “job system would be
over-engineering” stance.
New model — the DB (order book) is the UX source of truth; the chain settles behind it:
POST /orders validates, runs the matching engine, writes any matched Trade rows with
settlement: PENDING (no txHash yet), enqueues a SETTLE_MATCH job, and returns in
~100 ms — with the order’s resting/filled state.CTFExchange.matchOrders, multicalled), then stamps
txHash + settlement: CONFIRMED and triggers the operator MM re-quote step.settlement: FAILED + compensation — the fill is
reversed (order sizes un-filled, Trade voided, book restored) and the UI surfaces a
“trade reverted” notice. (Failures are rare: wallets are pre-warmed and orders are validated
at placement; compensation is the safety net, not the common path.)Same pattern for resolve (N reportPayouts for a group = one job; market flips to RESOLVED in
the DB immediately) and redeem (job computes redeemable from chain, executes, then writes the
REDEEM trade rows on confirmation — redeem alone stays pessimistic about balances since payout
math must come from the chain).
enum ChainJobType { SETTLE_MATCH RESOLVE REDEEM CREATE_GROUP }
enum ChainJobStatus { PENDING RUNNING CONFIRMED FAILED }
model ChainJob {
id String @id @default(cuid())
type ChainJobType
status ChainJobStatus @default(PENDING)
payload Json // e.g. {marketId, outcomeId, side, usdcAmount, wallet}
result Json? // {txHashes[]} / error detail
tradeId String? // backlink for settlement stamping
attempts Int @default(0)
maxAttempts Int @default(3)
runAfter DateTime @default(now()) // backoff scheduling
claimedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, runAfter])
}
Also: Trade.settlement SettlementStatus @default(CONFIRMED) (new enum PENDING/CONFIRMED/FAILED —
default CONFIRMED keeps all historical rows valid).
Worker rules:
updateMany({where: {id, status: PENDING}, data: {status: RUNNING, claimedAt: now}})
and only proceed if count === 1. Safe even if a second instance ever appears.runAfter (5s → 25s → 125s), error text into result.RUNNING job older than 2 min is reset to PENDING (crash mid-tx).POST /orders response includes the fill result + jobId + settlement: "PENDING";
TradePanel keeps its optimistic snapshot but the server is now also instant, so the snapshot
shows the real fill price from the book immediately.GET /jobs/:id for polling; TradePanel/ResolvePanel poll every 2 s only while a
job of theirs is pending, showing a small “settling on-chain… ⧗ / ✓ txHash / ✗ reverted” chip.
(SSE/WebSocket is unnecessary complexity at this scale — polling one row is fine. This also
resolves jul-22 leftover #3, ResolvePanel optimism, and makes leftover #4/SSE moot.)settlement status on activity rows.The current verex-api Cloud Run service only gets CPU during requests — a background worker
starves between requests. Options:
--no-cpu-throttling (+ min-instances 1) on verex-api ✅ recommended — one gcloud
flag, keeps one process; adds always-on cost (roughly the cost of one small always-on instance).
Jay’s Comment: I would choose this.setImmediate after reply) — free but jobs die if
the instance is reclaimed mid-tx; retries would cover it, ugly.Local dev (anvil) is unaffected — worker just runs in-process.
New page packages/web/src/app/create/page.tsx with the screenshot’s fields:
question, category (existing category list), image URL (text field — file upload needs storage
we don’t have; can add later), initial liquidity per outcome (default 100 USDC), outcomes
(min 2 rows; exactly 2 labeled “Yes/No” → creates a standalone binary market; otherwise a group),
resolution date+time → closesAt.
POST /market-groups
body: {title, category, imageUrl?, outcomes: [{label, description?}], liquidityPerOutcome, closesAt, creatorWallet}
→ validation + pre-flight solvency check → 202 {jobId}
< L × N, on local/staging mint the shortfall (MockUSDC), on prod
reject with {required, available}.ChainJob {type: CREATE_GROUP} + a MarketGroup row with a new
status CREATING (markets appear on the homepage only once OPEN). Everything on-chain happens
in the worker: per outcome prepareCondition(2) → registerToken → splitPosition(L), then
member Market + Outcome rows, initial quote centers 1/N, post the operator’s initial
bid/ask ladders (rev 2 — this is the “provision liquidity automatically” from the screenshot),
flip group to OPEN.CREATE_GROUP job’s result carries {done, total, stage}; the create page
polls GET /jobs/:id and renders the progress bar (like the screenshot’s batch note).getCondition → already prepared? registered? split?). If
terminally failed, group flips to CANCELLED with the error visible.Who can create: any demo wallet (there’s no real auth — same trust level as trading).
Market/MarketGroup.creator stores the wallet address for display (“Created by wallet #3”).
No moderation queue (the reference had none either); can be added when real auth lands (S7).
Jay comment: Anyone can create markets.
L capped at 1,000 USDC to stop a demo user draining the operator.No fees for now (creation fee / redemption fee are a separate product decision — the reference charged 2% of redemption profit; flagging as a future option, not building it). Jay comment: I will set the fee policy later.
Market.liquidity note: the split gives the operator L Yes + L No inventory per member —
that’s what backs the MM’s ask ladders (rev 2), so low-L markets simply have thinner books.packages/web/src/app/how-to/page.tsx, linked from SiteNav (left of Portfolio):
How to use. Server component, static content — sections:
packages/web/public/how-to/*.png, rendered with next/image.claude/jul-28-features)docs: jul-28 design doc (this file; also fix the broken ./create-market.png link in the task file)feat(db): MarketGroup schema + migration (A.2)feat(db+api): Order table + CLOB matching engine + POST /orders (rev 2)feat(api): operator MM ladders + renormalizing re-quotes (group Σ=1) (A.3, rev 2)feat(api): seed grouped markets + initial books via shared createBinaryMarketOnChain (A.5)feat(web): group card, group detail page, multi-series chart, group resolve, depth widget (A.4)feat(api): ChainJob queue + async settle/resolve/redeem via matchOrders (C)feat(web): settlement status chips + polling (C.3)feat: create-market page + POST /market-groups batch creation (B)feat(web): how-to guide page + nav link, with screenshots (D)docs(history): 2026-07-28 technical summary (per the task’s “After the implementation”)Each lands only after local verification (anvil run-through: seed → place orders against the MM book → check group renormalized re-quotes → resolve group → redeem → create market end-to-end).
| Topic | jay’s call |
|---|---|
| Execution model | CLOB (“other platforms use CLOB”) — design revised throughout |
| Multi-outcome on-chain model | Option (a), N grouped binary conditions ✅ |
| Cloud Run worker | Option 1: --no-cpu-throttling + min-instances 1 ✅ |
| Market creation access | Anyone can create ✅ |
| Fees | jay sets fee policy later — none built now ✅ |