Plan — Stadium v1
The launchpad, end to end: local first, then Arbitrum Sepolia. Written 20 Sep 2026. This is the
document to come back to; notes/STATUS.md still says where the project stands, and if the two
disagree the repo wins and both get fixed.
1. What v1 is
Everything in the design demo that our existing contracts already support, plus the data layer that makes it real. A creator launches a token against ARB or RHUB, picks from five mechanics, and it trades on a curve and then in a Uniswap v4 pool. A visitor browses a catalog with live numbers, reads a chart, trades, and watches a portfolio.
It is not stocks or baskets, not the ten hooks in the demo's lab, not holder rewards, buybacks, burns, gacha or lotteries, and not mainnet. Those are named in §8 so they are not argued again.
2. Ground rules new to this effort
Recorded as D33–D39 in DECISIONS.md, with the working shape in PROCESS.md — environments,
CI, CD, and a services and Supabase section. Summarised here only so this document reads on its own.
masteris a permanent staging environment, now covering web, docs and services.- Services are Rust, in Docker Compose, one Compose file per environment, named volumes.
- Supabase is managed, driven by the CLI. Every schema change is a committed migration;
nothing is ever changed in the dashboard.
supabase db resetmust replay from zero. - Types are generated, never hand-written — Supabase types and Rust ABI bindings alike.
- The web is rebuilt, with two isolation rules: one chain layer nothing else imports, and one token layer no component bypasses.
- No keepers. The indexer observes and caches. Nothing settles against it.
3. The spine
Three things decided before anything forks. Everything in §4 keys off them.
S1 — The read surface — frozen 20 Sep 2026
LaunchLens ships, in contracts/src/lens/. It holds nothing, is not upgradeable and is freely
replaceable; ../888's Lens888 is the precedent. It is not the catalog API — once the indexer
exists the catalog comes from Postgres. It earns its place for three things: the local stack before
any indexer has run (a bare anvil has no Multicall3, so 100 launches is ~800 sequential calls), the
token page's live values, and the indexer's own reconciliation snapshot.
Two structs, because one half never changes. LaunchStatic — identity, config, pool key, names
and decimals, the module and any set members — read once and cached forever. LaunchLive —
collected, sold, locked, graduated, seeded, price — polled. Halving the polled payload is the point.
The fields themselves live in the contract's NatSpec, not here.
Three things it must get right, each of which a naive version gets wrong:
- After graduation the curve's
spotPrice()is stale. OnepriceX18, taken from the curve before graduation and the pool'ssqrtPriceX96after, so no caller has to know which. launchOfrecords neither the migrator nor the module. Both are reachable through the curve, so the Lens closes that gap without an upgrade to the factory.- A module may be a stranger's contract (D26). Resolving
ModuleSetmembers must be wrapped — one revertingmembersOfmust not take a whole page down.
USD is derived through the pools, never from an off-chain feed: ARB against a deep v3 pool, RHUB through its own pool to ARB and on. The indexer owns that number, not the Lens, which keeps the Lens stateless and stops every browser reading pools. The path is configuration per chain, so Sepolia's USD figures are as fake as its liquidity — and before the indexer has run, the web shows quote-denominated values rather than guessing.
Where each screen reads from:
| Chain | Indexer | Supabase | |
|---|---|---|---|
| Catalog card | — | FDV, 24h, volume, holders, progress | logo, curation, badges |
| Filters and sort | — | everything | — |
| Token header and stats | live price, progress | volume, holders | metadata |
| Chart | the live candle | closed candles | — |
| Trade panel | everything — quotes, balances, allowances | — | — |
| Create | limits, quotes, modules | — | writes metadata |
| Portfolio | balances | positions, cost basis, activity | — |
The trade panel reading only from the chain is D38 drawn concretely: the backend can be down and trading still works.
S2 — The data contract — frozen 20 Sep 2026
Our events → indexer tables → what the web reads. Every track downstream depends on it and none may change it alone. Columns live in the migrations; what follows is what the migrations must honour.
Two trade sources, one shape. Before graduation a trade is the curve's Bought / Sold; after
it, the PoolManager's Swap for our pool id. Prepared gives the pool id at launch, so pool ids
are known long before they are used. Both normalise into one trades row.
Two subscription shapes, and they are not alike:
| Filter | The problem | |
|---|---|---|
Curve trades, token Transfer | A growing set of addresses — a curve and a token per launch | Providers cap address-list size. Filter by topic0 and validate against known addresses locally; never assume a 5,000-address filter is accepted |
Pool Swap | One address, the PoolManager, filtered by our pool ids | Easy |
Three semantic rules — the real content of this section:
- Candles track the market price; positions track what the trader paid. Different numbers. On
the curve, spot is derived from tracked state —
(V + collected) / (Y − sold), withVandYfrom the Lens; in the pool it issqrtPriceX96off theSwap. Consistent across both phases, which per-trade execution prices are not. Bought'squoteInis what the curve accepted, not what the buyer paid. The module fee is taken alongside and emitted separately asModuleFeeCharged. Anything claiming to be "what this cost" joins the two within the transaction, or it silently understates.- Cost basis comes from trades only. Tokens arriving by plain transfer have no knowable basis: they get zero, they are flagged, and the portfolio says so rather than inventing a number.
Tables
launches | Identity, config snapshot, stage, current state. Seeded by Launched, advanced by Locked / Graduated |
launch_modules | token → module(s), ordered. What "filter by mechanic" reads |
trades | Both venues, normalised. PK (block_number, log_index) |
candles | Per token per interval, closed bars only. The live bar is the browser's |
transfers | Every launch token transfer, keyed on its position in the chain |
balances, holder_counts | A sum over transfers, so re-reading is free. Structural addresses excluded — curve, migrator, PoolManager, zero |
positions | Per wallet per token: quantity, basis, realised |
quote_prices | ARB and RHUB in USD, derived through the pools (S1) |
indexer_state | Cursor per stream |
metadata, curation, watchlist | Supabase's own. The watchlist is per wallet (D40), not per device |
badges, points | Shaped now, filled later — out of v1 (§8) |
Four invariants:
- Every row traces to
(block_number, log_index); writes are idempotent on it. - Nothing is computed from a balance read — only from events. A replay must reproduce the database exactly, or it is not rebuildable.
Launchedis processed before any trade for that launch. Ordering by(block, log_index)gives this for free, including the creator's own buy inside the launch transaction.- Drop the database, replay, get the same numbers (D38).
holder_counts is the expensive one — every Transfer of every launch token, forever, and the
figure most visible on every card. Everything else is cheap beside it.
S3 — The local stack — frozen 20 Sep 2026
The bring-up, each step idempotent so a restart is fast — the shape scripts/dev.sh already has:
| Brings up | Skipped when | |
|---|---|---|
| 1 | anvil, 31337, no fork | something is already on :8545 |
| 2 | Uniswap v3, dev markets, launchpad, Lens | the factory has code |
| 3 | supabase start, migrations, generated types | already running |
| 4 | the indexer, via Compose, tailing anvil | already up |
| 5 | the seeder | the catalog already has launches |
| 6 | the web | — |
Docker becomes a dependency, and the answer to that is worth more than the inconvenience.
supabase start needs it; anvil and the contracts do not. So make dev brings up what it can and
the web degrades when Supabase or the indexer is missing — no catalog stats, no chart history,
quote-denominated prices, and trading entirely unaffected.
That degraded path is the same code path as production with the backend down. Building it into the local stack means D38's central claim is exercised every day rather than tested during an outage.
The seeder is what makes this worth doing. A catalog with two tokens says nothing about a catalog with a hundred.
- Deterministic — a fixed seed, so the same launches appear every run and screenshots and tests are stable.
- Varied along every axis the UI filters on — stage, module combinations including sets and none, pairing, holder count, age.
- It fabricates chart history by warping time between trades. anvil mines on demand, so this is
evm_increaseTimebetween buys, and candles are real rather than one bar. - It writes metadata to local Supabase, so cards have art. Placeholder art, per the hard rule.
- Dev quotes are mintable, so graduating several launches costs nothing.
make dev-reset wipes chain state, resets the database, redeploys and reseeds — one command,
because iterating on a schema or a seeder otherwise becomes a ritual.
Ports: anvil 8545, Supabase 54321–54324, web 5173, indexer none. The indexer reaches the
host chain via host.docker.internal on macOS.
The rule that does not move: no step requires an RPC or an API key. Clone, one command, a working launchpad.
4. Tracks
Each states what it owns, what it waits for, the seam it exposes, and when it is done.
| Owns | Waits for | Exposes | Done when | |
|---|---|---|---|---|
| T1 Contracts | LaunchLens; a testnet RHUB stand-in; quote registration | S1 | The Lens struct | Lens tested, stand-in deployed, both quotes registered on Sepolia |
| T2 Indexer | Rust: tail, reconcile, backfill, candles, holders, positions | S2 | Rows in Supabase | Replays a chain from zero and matches on-chain truth |
| T3 Supabase | Schema, migrations, RLS, generated types, metadata and curation functions | S2 | Tables and types | db reset replays clean and CI verifies committed types |
| T4 Web foundation | Tokens, chain layer, shell, routing, wallet | S1 | Theme file and typed hooks | A themed shell routes every page with a connected wallet |
| T5 Web screens | Catalog, token page, create, portfolio, watchlist, lab, FAQ | T4 | — | Each screen works against the local stack at every breakpoint |
| T6 Local stack | Compose per environment, seeding, make targets | S3 | make dev | One command, cold, offline |
| T7 Ops | Contabo, staging deploys, CI jobs | T2, T3 | — | master deploys itself to staging |
T1 is serial and reviewed. It is the only track that touches contracts/, and that is where the
money is.
5. Phases and stages
Around thirty stages. No session holds them all, so each is written to be picked up cold: tick the box, and the next one knows where to start. The spine (§3) is done — it was Phase 0's predecessor and unlocks everything below.
Phase 0 — Rename · one short session
- 0.1 In-repo text:
richcat→ Stadium —README.md, bothpackage.jsons,docusaurus.config.ts,supabase/config.toml. Left alone on purpose:ModuleSet.sol'sPENDING_SLOTstring, which is a transient storage slot, and thenotes/lines naming the GitHub and Vercel projects, which stay true until those are actually renamed (0.3) - 0.2a The working directory:
~/Projects/arb/richcat->~/Projects/arb/stadium. Local Supabase was stopped first, because its containers are named fromconfig.toml'sproject_id; the old containers and their volumes are orphaned and can be pruned - 0.2b GitHub:
stealthy-town/richcat-launchpad->stealthy-town/stadium, remote updated. Vercel tracks a repository by id rather than by name, so nothing had to be reconnected - 0.2c Deferred by the developer, 20 Sep 2026: the four Vercel projects (renaming changes the live URLs) and the hosted Supabase project keep their names for now
- 0.3 Notes follow the world, never lead it. The GitHub name was corrected in
sessions/once it had actually changed;STATUS.mdandDECISIONS.mdstill name the Vercel projects, which are still called that, and change when 0.2c does
Phase 1 — Foundations · four tracks in parallel, the bulk of the work
T3 Supabase — start first, T2 and T4 both key off the schema.
- 3.1
20260920120000_indexer_core.sql— nine tables in a privateindexerschema, seven text-casting views inpublic. Verified: replays from zero,uint256max round-trips exactly, anon reads the views and cannot reachbalancesorstate - 3.2
20260920130000_curation_and_watchlist.sql—curation,watchlistkeyed to the wallet claim throughpublic.current_wallet(), andprivate.auth_nonces.launch_metadataleft untouched on purpose. Verified: two wallets see only their own rows, neither anon nor an empty claim sees any, a wallet cannot insert for another, and the nonces are unreachable - 3.3
functions/authand20260920140000_auth_nonce_rpc.sql, driven end to end: a fresh wallet signs the challenge withcast wallet sign, exchanges it for a token carryingwalletandrole: authenticated, writes a watchlist row (201), reads it back (1 row), is invisible to anonymous (0 rows), is refused when writing for another wallet (42501), and the spent nonce replays as 401 - 3.4
make supabase-types/make supabase-check,.github/workflows/supabase.yml, andsupabase/types/database.types.tscommitted. The generated types prove D41 in TypeScript:total_supply: string,price_usd: number. D36 amended — these are committed rather than gitignored, because Vercel typechecks without a database
T1 Contracts — serial and reviewed. The only track that touches contracts/.
- 1.1
src/lens/LaunchLens.sol+ 9 tests, all passing (276 local total). Static/live split, price source switching at graduation,membersOfwrapped and gas-capped.src/lensadded to the boundary check's core list - 1.2
Deploy.s.soldeploys it last and writeslensto the book;ops/sepolia.shchecks its bytecode with the rest. Verified on the local chain: deployed, in the book, answeringlaunchCount() - 1.3 Stand-ins for both ARB and RHUB on Sepolia (D39; WETH drops off the menu and stays the router's unwrap target), a
dRHUBmarket on the local chain with its own v3 route, andDeploy.s.soltaught to deploy a stand-in for any quote left at the zero address — refusing outright on Arbitrum One, where a mintable ARB would be a counterfeit - 1.4
Upgrade.s.solalways ships a freshLaunchLensand gainsSHIP_QUOTES=1, which moves a live launchpad ontoscript/SepoliaQuotes.sol's menu and retires the rest — Sepolia only, since the local chain's quotes have real v3 routes behind them. Rehearsed on the local chain.Deploy.s.solalso gained the0x91hook-prefix guardUpgradealready had
T2 Indexer — the longest track.
- 2.1 Crate,
config.rs(environment only, no default that reaches a network),db.rs(per-stream cursor, chain claim), two-stage Dockerfile,docker-compose.{local,staging,production}.yml,services/README.md,maketargets that skip cleanly without a toolchain. Compiles, clippy clean at-D warnings - 2.2
make indexer-abiwrites six committed ABIs;src/abi.rsbinds them, one module each. Two things the ABIs needed first: user-defined value types normalised away (PoolIdisbytes32on the wire and unresolvable to anything reading an ABI alone), andLaunchLensleft unbound —LaunchStatichas 29 fields and alloy'sSolTypetuples stop short of that, so the indexer reads the curve's own getters instead - 2.3
src/chain.rs— the range walker, halving only on a size refusal, with the rule under test: four real provider phrasings shrink the range and five other failures do not, including a rate limit, which is the one that turns a brief throttle into a request storm - 2.4
src/tail.rs— backfill, then subscription where one exists and polling where it does not, plus the sweep on a timer. Verified live: it reportedtailing by subscription, and a buy sent while it ran was indexed within a block - 2.5
src/launches.rs. Launches are recorded in a phase of their own, before anything else: inside one launch transaction the migrator'sPreparedfires before the factory'sLaunched, so strict log order would leavepool_idnull and silently lose every pool trade on that launch - 2.6
src/trades.rs, both venues normalised from the trader's side, with the module's cut joined fromModuleFeeChargedin the same transaction. Proved against the chain: the indexer's derived price,quote_collectedandtokens_soldmatch the curve's ownspotPrice(),quoteCollected()andtokensSold()to the wei - 2.7
src/rollup.rs. A bar is closed when the chain has moved past it, not the wall clock — a local chain warps time to fabricate history, and the same code has to produce a chart there and on a live network - 2.8
src/holders.rsand a newindexer.transferstable. Balances are asumover stored transfers, not a running total: a sweep re-reads ranges by design, and a total added twice is wrong in a way nothing downstream can detect. Verified: the curve, the singleton and the zero address all hold balances and none is counted as a holder - 2.9 Average cost, recomputed from the whole trade history rather than carried forward —
a running total has to be applied exactly once, which is the guarantee this indexer
deliberately does not make. Basis from trades alone; anything held beyond that arrived by
transfer and is counted as
untracked - 2.10
src/usd.rs— one or two v3 hops per quote, configured rather than discovered. Verified locally: dARB priced at $0.40 through WETH, which is exactly 3000/7500 - 2.11
src/replay.rs— four database-backed tests behindmake test-services-db, plus thegetLogsrules insrc/chain.rs. They cover what a dropped socket, a restart mid-range and a sweep all end in: something processed twice.#[ignore]rather than skipped when the database is absent, because a test that silently skips is one that stops running and tells nobody
T6 Local stack
- 6.1
docker-compose.{local,staging,production}.yml, validated. Local reaches the host's chain throughhost.docker.internal, withextra_hostsso the same name works on Linux - 6.2
scripts/dev.shbrings up chain, contracts,.env.local, indexer and web, and degrades rather than refusing when Supabase or the toolchain is missing — the same code path as production with the backend down.scripts/dev-env.shresolves the addresses and the v3 pools for USD pricing, so both routes appear without anyone pasting a pool address. The indexer is compiled natively, never in Docker: a cold image build measured eleven minutes, and no loop run twenty times a day can carry that. The image stays for staging - 6.3
script/DevSeed.s.sol+dev/seed.sh. 305 launches across three pairings, 33 graduated, candles over four intervals, positions across 5 wallets, both quotes priced in dollars. Deterministic, and the clock warps between epochs so a chart has bars rather than a bar. It now sells as well as buys — a quarter of the positions it opens — which is what gives the stack red candles, realised P&L and a two-sided ticker; before that, half of what the interface renders had never been seen. Still no metadata, so no art: the seeder is a forge script and cannot write to Supabase.shell/Avatar.tsxanswered that with a deterministic monogram, so every screen has a mark whether or not a launch has a picture.make dev-curatefills the podium and endorsements, which are ours rather than derived and which nothing else writes - 6.4
make dev-reset— stop the chain, forget the books, truncate the indexed tables, redeploy.truncaterather thansupabase db reset, which replays every migration and takes minutes: the right tool for "the data should be gone" is not the one for "a migration changed"
Phase 2 — Web foundation · serial; all of Phase 3 waits on it
- 4.1
app/, a workspace of its own besideweb/, on port 5175 so both run at once until the cut-over (D37).servicesleft the yarn workspaces, being Rust now - 4.2
theme/tokens.css— colour, type, space, radius, both themes — plusbase.cssandbreakpoints.ts. The breakpoints are the design's own, because CSS custom properties cannot be used inside a media query and the numbers would otherwise drift from what JavaScript thinks - 4.3
chain/—generated.tsfrom the address book,abis.generated.tsfrom the same Foundry artifacts the indexer's JSON comes from (D36), wagmi config, and typed hooks over the Lens. Nothing outside this folder imports wagmi or viem - 4.4
data/— oneCatalogRowtype, two sources. The indexer serves it when there is an indexer and the chain serves a reduced version when there is not, so a screen cannot tell; what is missing isnullrather than invented. Exact values arrive as strings and becomeBigIntin one place (D41) - 4.5 Rail, header, routing, injected-wallet connect and the theme toggle. Every Phase 3 route exists already as an honest placeholder, so the layout is exercised against all of them from the first day
- 4.6
session/siwe.ts— nonce, sign, exchange, and a separate authed client so a public read can never carry somebody's session. The flow it drives is the one proved in 3.3
Phase 3 — Screens · fans out widely, roughly one session each
- 5.1 Catalog: cards and table, four sorts, search, filters (stage, pairing, mechanic,
holders), three densities, and the watch star — which is the only thing that asks for a
signature, and only when somebody tries to save. Backed by a new
launch_statsview for 24-hour volume, because a browser cannot sort by a number it would have to download every trade to compute. Responsive rules written but not verified — the browser tooling here would not give me a narrow viewport; that is 5.11's job - 5.2 Discover: hero with live totals, a trade ticker, the podium, the endorsed row and
the pairing strip — all counted from the one catalog query, so nothing on the page can
disagree with anything else. The podium and the endorsements come from
curation, which is ours;make dev-curatefills it on the local stack. The card moved tocatalog/LaunchCardso both pages show the same one - 5.3 Token page: header, hook disclosure, stats, about. The disclosure reads each
module's own
termsOf(token)live, so it states this launch's settings rather than the mechanic's general shape, and a module we did not publish is labelled as such. Launches without an image get a deterministic monogram (shell/Avatar.tsx) rather than a hole - 5.4 Chart: closed candles from the indexer, the bar in progress built in the browser, a "pool opened" marker at graduation. Bars now open at the previous bar's close, which on a curve is the truth rather than a convention — the price between trades is the last trade's price, and opening at each bar's own first trade drew every quiet bar as a bodiless line
- 5.5 Trade panel: buy/sell, ETH-or-quote, presets, slippage, curve-or-pool, progress.
A quote is the trade, simulated (
eth_simulateV1, approval bundled in), not the curve'squoteBuy: a module runs inside the trade and aviewcannot ask it, so the curve's own preview overstates by exactly the module's cut — measured at 657,503 tokens against the real 644,360 on a launch running the 2% fee module. Impact is measured against a thousandth of the same trade, so one number covers the v3 leg an ETH payment adds - 5.6 Create: form, mechanics picker, live ticket, review, submit, metadata write. The
review simulates the launch and then asks each module what it recorded, in the same
simulation — because every
Termsfield pads to a word, so a field in the wrong slot encodes into bytes the module decodes happily and stores backwards. Measured: a tax module meant as 1%/5% encoded as 5%/1% simulates clean and is visible only in the read-back. The sentence on the ticket is phrased by the same function the token page uses - 5.7 Portfolio: holdings, activity, launches started, P&L. Totals are per pairing,
never one number — dARB and dUSD are different things and the chain owes us no rate between
them. Tokens that arrived by transfer are counted as held and left out of every cost column,
because their basis is nobody's to know. Reachable by address (
/portfolio/:wallet), since positions are public chain data; a watchlist is not - 5.8 Watchlist. The one screen that needs a signature, so it explains what a signature is
before asking for one — no transaction, no spend, no permission over anything. The policies
were probed from the wrong side rather than assumed: anon reads nothing, a signed-in wallet
asking for somebody else's rows gets
[], and writing as somebody else is a 403 - 5.9 The lab. Not a roadmap — the destination is not known, so a dated list of promises is a list we would contradict. Instead: what is running today read from the factory (with how many launches use each mechanic), the mechanism/policy split, the three seams, the directions labelled as examples, and the three conditions an outside mechanic has to meet — two proved, the third stated as not yet met
- 5.10 FAQ. Every number is read from the chain — the protocol fee, the supply split,
the fee ceiling, the creator's share of pool fees — because a FAQ that remembers a setting is
wrong the first time it changes, and silently, since nobody re-reads a FAQ looking for drift.
<details>, so it opens without JavaScript and the browser can find text in it - 5.11 The responsive pass — every breakpoint, both themes. Stylesheets had drifted to
900/720/640; they are back on the widths
breakpoints.tsdefines. The narrow type scale is one block intokens.cssrather than a rule per page. Overflow was found by measuringscrollWidthagainstclientWidthon all eight routes rather than by eye: the chart column and the hero both refused to shrink below their content. Clean at 320px and up
Phase 4 — Staging
- 7.1 Contabo: SSH config, Docker, Compose deploy
- 7.2 Staging Supabase project, migrations, secrets
- 7.3 Sepolia upgrade carrying the Lens and the RHUB stand-in
- 7.4 Vercel projects pointed at the new app
- 7.5 CI jobs for
services/andsupabase/ - 7.6 Cut over
Phase 5 — Documentation · its own session
- 8.1 Internal: architecture, the data contract, runbooks
- 8.2 Public: the trimmed version
6. State
| Done | Phases 0, 1, 2 and 3. The rename; contracts, indexer, database and local stack; the interface's foundation; and all eleven screens. D33–D41, and the spine frozen |
| In flight | — |
| Next | Phase 4: staging. Sepolia, Supabase, Contabo, Vercel, CI, cutover. Runbook in notes/DEPLOY-STAGING.md |
Where it actually is, for a session picking this up cold:
make devbrings up a chain, the launchpad, Supabase, the indexer and the old web. The new app iscd app && yarn devon:5175until the cut-over.dev/seed.shfills the chain with 145 launches; the indexer turns them into a catalog.- Signing in locally needs
make supabase-functionsrunning, becausesupabase start's bundled runtime never reads the functions' env file. - Nothing is deployed anywhere. Sepolia still runs the pre-security-pass code (
STATUS.md).
7. Open questions
Agreed not to assume.
- The badge list and points table, if and when badges return (§8).
8. Out of scope for v1
Stock and basket pairing. The demo lab's ten hooks — three of which break settled laws and one of which this hook cohort can never run. Holder rewards, buybacks, burns, gacha, lotteries. Mainnet.
Badges and points left v1 on 20 Sep 2026 — a good idea, not a first-shipment one. The tables stay in S2 so the indexer's shape does not have to change to add them later; nothing fills them yet. When they arrive they stay decorative (D38).
New modules are not in this list: they ship continuously after launch, behind the existing hook, reaching new launches. Only a new hook address is a cohort break.