← Index
Source: docs/history/2026-08-04-verex-history.md (auto-generated by scripts/generate-docs-html.mjs — edit the .md, not this file)

2026-08-04 — verex

Source docs this day implements:


Web chrome: dark mode + Korean/English switching

Cause: jay asked for two icon menus in the top nav — one to change language, one to switch light/dark — alongside the Docs restructure.

Reasoning: Hand-rolled both rather than adding next-themes / next-intl: tailwind was already on darkMode: ["class"], and the whole theme contract is one class plus a localStorage key. For locale the decisive constraint was that the home page, market pages and the docs shell are server componentslocalStorage is invisible to them, so a localStorage-only locale would translate the nav and leave the market list in English. The locale therefore lives in a cookie (mirrored to localStorage as a backup), read server-side via cookies() and passed into LocaleProvider so the first paint already matches — no hydration mismatch and no English-before-Korean flash. Switching calls router.refresh() because server components cannot re-read a cookie on their own.

Change: added .dark variable block + color-scheme to globals.css (Yes/No hues lightened — emerald at 39% and magenta at 49% read muddy on a near-black card); ThemeProvider with an inline pre-paint THEME_INIT_SCRIPT in layout.tsx to kill the light flash; lib/i18n.ts (flat dictionary, {var} interpolation, English fallback) + lib/i18n-server.ts (kept separate so next/headers never leaks into a client component); LocaleProvider, ThemeToggle, LocaleToggle; translated the nav, welcome overlay, footer and home page.

Result: tsc --noEmit clean. Both toggles live in the nav; theme persists across reloads with no flash, and locale now switches server-rendered content too. Long-form doc bodies are handled separately (next entry) — they carry their own per-locale text.


Docs section: Guide → Docs, plus a technical-background whitepaper

Cause: jay asked to rename the top-menu Guide to Docs and add whitepaper-style technical background (Hybrid AMM + CLOB, UMA), with a right-hand panel listing the titles so a reader can pick a sub-document.

Reasoning: Docs are stored as data blocks, not JSX. Every document exists in English and Korean; duplicating JSX trees would duplicate the markup as well as the prose, so a small block model (p / ul / ol / code / note / table / img) keeps only the text duplicated and leaves one renderer deciding how a paragraph looks. Inline markup is a deliberately tiny regex for **bold** / `code` / *em* rather than a markdown dependency — the text only ever comes from this repo, so a parser would add a dependency and a sanitisation surface for no gain. Dropped generateStaticParams from the [slug] route: the locale is a cookie, so the page is request-rendered and prerendered slugs would be discarded.

Change: new /docs index (cards grouped Guide / Technical background) and /docs/[slug] reader, both with a sticky DocsSidebar on the right listing every document plus the current document's section anchors. Five documents authored bilingually: how-to (ported from the old page), overview (the three problems — price discovery, custody, truth), hybrid-amm-clob (cold-start, quote-driven vs order-driven vs hybrid, why CPMM quotes above $1.00 at the tails, LMSR's three properties, b · ln 2 bounded loss), resolution-uma (optimistic flow, bonds and the WETH whitelist constraint, malicious-voter risk classes), settlement (CTF split/merge, conditionId derivation, the settlement queue's idempotency). Old /how-to became a permanentRedirect so existing links do not 404.

Result: next build clean; all six routes verified 200 at runtime, /docs/nope → 404, /how-to → 308 to /docs/how-to. Cookie-driven locale confirmed server-side: the same URL renders "Liquidity: Hybrid AMM + CLOB" in English and "유동성: Hybrid AMM + CLOB" with <html lang="ko"> under verex-locale=ko.


LMSR pricing module — wave 1 Phase A, math landed and verified (not yet wired)

Cause: jay said to go ahead with current-plan.md. Re-checked the §0 gates first: G5 still fails — the operator holds 0.0496 ETH, unchanged from 2026-08-03, so wave 2's three contract deploys cannot start. G3 (WIF) is also still jay's. That leaves wave 1 (LMSR Phase A) as the only unblocked piece, since it is off-chain and needs no gas.

Reasoning: Two design points worth recording. First, seeding — raw LMSR with q = 0 quotes a uniform 1/n, but markets open at a chosen probability (0.63, not 0.50). Rather than storing a synthetic starting inventory, the opening probability is folded into the formula using the softmax's shift-invariance: price_i = p_i⁰·e^(q_i/b) / Σⱼ p_j⁰·e^(qⱼ/b), which returns exactly p⁰ before any trading and reduces to plain LMSR when every p⁰ is 1/n. Second, the softmax is computed in log space shifted by the max logit — exp() of a few hundred overflows to Infinity and NaNs the whole price vector, and the shift is exact because softmax is shift-invariant. Clamping to [0.02, 0.98] breaks sum-to-1 at the tails, so a residual pass redistributes the difference across outcomes that still have headroom.

Change: new packages/api/src/lmsr.ts (pure functions: lmsrPrices, lmsrMaxLoss) plus packages/api/scripts/sim-lmsr.ts, a property harness in the same spirit as sim-amm-slippage.ts. Nothing calls the module yet — mm.ts is untouched, so this commit carries zero behavioural risk.

Result: all 8 property groups pass. The decisive one: over a 100k one-sided run the maximum Yes quote is 0.98 — the CPMM failure mode that ruled out constant product (quoting above $1.00 at the tails) is structurally impossible here. Also confirmed: no-trade returns p⁰ exactly, sums stay at 1.000000 for binary and for n = 3/5/12 groups, price is monotone in quantity sold, larger b flattens the curve, ±1e6 inputs stay finite, and binary max loss is exactly b·ln2 (173.29 USDC at b = 250).

Still open — needs jay before wiring into mm.ts: the wiring requires a Prisma migration (Market.openingCenter + Market.lmsrB, backfilled from quoteCenter) and changes how group prices move — sibling candidates would be repriced by n-way softmax instead of today's proportional rescaling in requoteAfterFill. That is a visible behaviour change on a live staging book, so it is left for a focused pass rather than tacked onto this one.


WIF setup script — gate G2 prepared (authoring only, not run)

Cause: jay asked for a script rather than a command list for the Workload Identity Federation setup. Checked the project first: no WIF pool, no deployer service account, and no .github/workflows/ directory at all in verex-499205 — G2 is genuinely greenfield.

Reasoning: WIF exists so no service-account JSON key is ever created or stored in repo secrets — a leaked key is permanent, a leaked OIDC token expires in minutes. The security hinge is the attribute condition (assertion.repository == 'linked0/verex'): without it any GitHub repository could present a valid GitHub OIDC token and impersonate the service account, which is exactly the misconfiguration that makes WIF setups dangerous rather than safe. Roles are deliberately narrow — no owner/editor anywhere. Written idempotent so a partial run can simply be re-run.

Change: scripts/setup-wif.sh, sourcing scripts/deploy.env so project/region cannot drift from deploy.sh. Supports DRY_RUN=1. Prints the two values to add as GitHub Actions variables (identifiers, not secrets) at the end.

Result: bash -n clean; DRY_RUN=1 resolves the real project number (496608424746) and prints the full plan. Not executed — running it is jay's call, per gate ownership.


LMSR wired into the market maker — G3 closed, wave 1 Phase A complete

Cause: jay closed G3 with "full LMSR, group included", which is the decision the previous entry was waiting on.

Reasoning: The important choice was how to obtain q (net quantity the operator has sold). A running counter column would have been cheaper to read but drifts the moment anything goes wrong — a crashed re-quote, a manual DB fix, a replayed settlement job. Instead q is derived from settled book fills, so it is reconstructible and cannot silently diverge. Only fills whose maker was the operator (Order.makerIndex = 0) count: a trade between two demo wallets moves no operator inventory and must not move the operator's quote. REDEEM rows are excluded explicitly — they are redemptions, not trades, and would otherwise be signed as sells.

The migration backfill needed care. openingCenter is LMSR's p⁰ and must be the market's original opening probability, not its current quoteCenter: since q sums the entire trade history, seeding p⁰ from an already-traded centre would count the same trading twice and skew every live market. The backfill therefore reads each market's earliest PricePoint, falling back to quoteCenter for markets that never charted one.

Group repricing changed shape rather than gaining a step: sibling centres now come from one n-way softmax, so they sum to 1 by construction instead of being proportionally rescaled afterwards. requoteAfterFill's lastPrice argument is now deliberately unused — under LMSR the centre tracks the operator's exposure, not the last print.

Change: Market.openingCenter + Market.lmsrB (migration 20260804043305_lmsr_quote_params with the PricePoint backfill); mm.ts gained operatorNetSold(), binaryCenter(), groupCenters() and lost the proportional-rescale block; new read-only scripts/check-lmsr-centers.ts.

Result: migration applied to the local DB; backfill verified distinct from quoteCenter (e.g. eth-above-10k-2026 → stored 0.440, opening 0.2226). tsc --noEmit clean. All three group previews total exactly 1.000. End-to-end behaviour proven with synthetic trades inside a rolled-back transaction on eth-above-10k-2026 (p⁰ = 0.2226):

step q(yes) centre
baseline 0 0.2226 (= p⁰)
user BUY 400 400 0.5865 (rises)
user SELL 150 250 0.4377 (falls back)
user-to-user BUY 900 250 0.4377 (unchanged)

The last row is the new behaviour worth remembering: a fill that never touched the operator's ladder leaves the quote alone, where the old last-price rule would have moved it.

Caveat: the local DB has 0 trades, so the group previews above are all at q = 0 — they prove the softmax and the sum, not the inventory path. The inventory path is proven only by the synthetic-trade table. Staging has real fills and should be re-checked with check-lmsr-centers.ts after deploy; expect a one-time price jump on already-traded markets as centres re-derive from inventory instead of last print.


G1 closed — Phase B dropped, and G5 stopped being a blocker

Cause: jay decided "Phase A is enough" after asking what Phase B is actually for.

Reasoning: The framing that settled it — every benefit an on-chain pool provides (surviving operator downtime, censorship resistance, letting anyone verify the quote) is a benefit of not having to trust the operator. On Sepolia with test USDC there is no adversary to resist and nobody who can lose money, so those guarantees buy nothing yet. Phase B is not wrong, it solves a problem this deployment does not have.

Change: current-plan.md §0 rewritten — G1 closed as NO, G5 re-evaluated. Public docs corrected so they do not claim something untrue: liquidity.ts now says Phase B is deferred and why, and three claims it invalidated were fixed — the lead no longer says Verex "is moving" to LMSR (it has), the ladder no longer "requotes around the last traded price" (it reprices from inventory), and Verex is described as sitting between a pure CLOB and a full hybrid rather than being the full hybrid already.

Result: the decision unblocked G5 as a side effect, which is the practically important part. The ~0.5 ETH target existed because wave 2 was three deploys plus a fresh seed; without Phase B it is one deploy plus the seed, so ~0.1 ETH is comfortable. The operator now holds 0.1788 ETH (up from 0.0496) — G5 passes and jay can stop faucet-farming. Remaining plan is ~3–4 days, down from 6–9, and every gate is closed except G2, which is one command.


G2 closed — WIF applied to verex-499205

Cause: jay authorised running scripts/setup-wif.sh directly.

Reasoning / what went wrong first: the first run failed halfway through step 5 with Service account github-deployer@... does not exist — immediately after step 4 had created it successfully. IAM is eventually consistent: a service account can be created and still be invisible to the policy API for several seconds. Fixed with a retry helper rather than a fixed sleep, since the propagation delay is not a knowable constant: the script now polls describe until the account is visible (12 × 5s) and retries each binding (6 ×). The partial first run cost nothing because the script is idempotent — the re-run skipped the pool, provider and account it had already created and resumed at the bindings.

Change: scripts/setup-wif.sh gained retry() and DRY_RUN branches for the two binding steps. Applied to verex-499205.

Result: verified independently of the script's own output —

Values jay must add as GitHub Actions variables (identifiers, not secrets):

WIF_PROVIDER        = projects/496608424746/locations/global/workloadIdentityPools/github/providers/github-oidc
WIF_SERVICE_ACCOUNT = github-deployer@verex-499205.iam.gserviceaccount.com

Next: the CD workflow itself is still unwritten — the pool exists but nothing uses it yet.


GitHub repo variables registered — WIF wiring complete end to end

Cause: jay authorised setting the two GitHub values via gh rather than pasting the long provider path by hand.

Reasoning: Registered as repository variables, not environment variables. GitHub's "Environment" is a specific feature (deployment targets with protection rules, required reviewers, branch restrictions), and none are defined for this repo — the empty "Environment variables" box jay saw belongs to a feature that does not exist yet. The WIF values are identical for every deploy because there is one GCP project, so repository scope is correct. They are variables, not secrets: both are public identifiers, and neither grants access without a GitHub OIDC token whose repository claim matches.

Change: WIF_PROVIDER and WIF_SERVICE_ACCOUNT set on linked0/verex.

Result: cross-checked against the live GCP resources rather than trusting the paste — both values compared byte-for-byte equal to gcloud ... providers describe --format=value(name) and service-accounts describe --format=value(email). A typo here would not fail now; it would fail much later as an opaque auth error inside a workflow run.

Next: the CD workflow is still unwritten. Every piece of the auth chain now exists — pool, provider, service account, roles, impersonation binding, repo variables — and nothing consumes it yet.


CD workflow — wave 0 deliverable, the first thing that consumes the WIF chain

Cause: every piece of the auth chain existed and nothing used it. This is wave 0's actual deliverable.

Reasoning: The important decision was what NOT to put in it. scripts/deploy.sh mixes two jobs — provisioning (create the Cloud SQL instance, generate the DB password, create Secret Manager entries) and deploying. Only the second is safe to repeat. Re-running provisioning on every push is how a password gets rotated mid-deploy, so the workflow deploys and never provisions; deploy.sh stays the one-time hand-run tool.

It also never seeds, and that is the single most important property. prisma/seed.ts deletes Trade / PricePoint / Outcome / Market rows and re-runs on-chain prepareCondition calls that revert against an already-prepared backbone — the exact trap docs/runbooks/deploy.md §5/§7/§10 documents as requiring SKIP_SEED=1. A CI pipeline that ran it would silently destroy live staging data on every deploy. Only prisma migrate deploy runs here: schema changes, never data.

Smaller choices: id-token: write is mandatory (without it GitHub mints no OIDC token and WIF cannot work at all — the most common WIF failure); concurrency without cancel-in-progress, because killing a run mid-migration is worse than queueing; the DATABASE_URL read from Secret Manager is ::add-mask::-ed before use, since it contains the DB password and would otherwise be one set -x away from the log; the proxy is polled rather than sleep-ed; and a smoke test fails the run because a green gcloud run deploy only means the revision was accepted, not that the app works.

Change: .github/workflows/deploy-staging.yml — checkout → WIF auth → pnpm/node → resolve targets → migrate → Cloud Build the API image → deploy API → deploy web → smoke test → summary. workflow_dispatch only, with a skip_migrations input.

Result: YAML parses; 13 steps resolve; structural checks confirm id-token: write, migrate deploy, and no reference to seed.ts in any run step. All five referenced secrets verified present in verex-499205 (verex-{database-url,rpc-url,operator-key,demo-mnemonic, telegram-bot-token}-verex).

Not yet run, and it cannot be yet: workflow_dispatch only appears once the workflow exists on the default branch. It has to reach main before jay can trigger it — the first real run is also the first true test of the WIF setup.


UMA CTF adapter — wave 2, contract + tests (not deployed)

Cause: jay: "you can go to next wave." With G1 and G5 resolved, wave 2 is the UMA adapter.

Reasoning — three decisions worth recording.

Why the oracle is a contract, not a setting. conditionId = keccak256(oracle, questionId, 2) means "who may report the result" is part of the market's identity. The adapter registers itself as the oracle at prepare time, so it must exist before any market that uses it and a live market can never be migrated to a different oracle. The test asserts this directly rather than trusting the comment.

Why resolve is permissionless. It has no discretion — it copies whatever UMA settled. If it required the operator, an absent operator could strand every payout, which is precisely the failure mode UMA is here to remove. Making it admin-only would have reintroduced the trust assumption the whole wave exists to delete.

Why unsupported prices revert. UMA returns an int256 and only three values are defined (1e18 / 0 / 0.5e18). Coercing anything else — rounding toward YES, say — would resolve a real market on a number nobody voted for. A 256-run fuzz asserts every other value reverts. Unresolvable maps to [1,1]: both sides redeem half, returning collateral rather than handing the pot to one side of a question that was never answered.

Change: src/UmaCtfAdapter.sol, src/interfaces/IOptimisticOracleV2.sol (hand-written 5-function surface rather than importing UMA's protocol package and its older-solc dependency tree), test/UmaCtfAdapter.t.sol with a MockOptimisticOracleV2 reproducing the two behaviours the adapter depends on — cannot settle before liveness, returns the proposed price once settled.

Result: 48/48 tests pass across the whole suite (14 new, 34 pre-existing unaffected). The new ones cover: adapter is the oracle in the conditionId; admin-only init; empty-ancillary and double-init rejection; YES/NO/unresolvable payout vectors; revert before liveness expires; permissionless resolve; double-resolve rejection; unsupported-price revert plus the fuzz; and a full lifecycle where collateral is split, resolved through the mock OO, and redeemed — 100 USDC in, 100 USDC out to the winning side.

Not done in this slice: no deploy script, and no API integration — Market still has no oracleType and market creation cannot yet choose UMA over operator resolution. The contract is also untested against the real Sepolia OO; the mock encodes my understanding of it, which is exactly the thing a testnet run would falsify.