Skip to main content

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.

  • master is 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 reset must 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. One priceX18, taken from the curve before graduation and the pool's sqrtPriceX96 after, so no caller has to know which.
  • launchOf records 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 ModuleSet members must be wrapped — one reverting membersOf must 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:

ChainIndexerSupabase
Catalog cardFDV, 24h, volume, holders, progresslogo, curation, badges
Filters and sorteverything
Token header and statslive price, progressvolume, holdersmetadata
Chartthe live candleclosed candles
Trade paneleverything — quotes, balances, allowances
Createlimits, quotes, moduleswrites metadata
Portfoliobalancespositions, 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:

FilterThe problem
Curve trades, token TransferA growing set of addresses — a curve and a token per launchProviders cap address-list size. Filter by topic0 and validate against known addresses locally; never assume a 5,000-address filter is accepted
Pool SwapOne address, the PoolManager, filtered by our pool idsEasy

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), with V and Y from the Lens; in the pool it is sqrtPriceX96 off the Swap. Consistent across both phases, which per-trade execution prices are not.
  • Bought's quoteIn is what the curve accepted, not what the buyer paid. The module fee is taken alongside and emitted separately as ModuleFeeCharged. 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

launchesIdentity, config snapshot, stage, current state. Seeded by Launched, advanced by Locked / Graduated
launch_modulestoken → module(s), ordered. What "filter by mechanic" reads
tradesBoth venues, normalised. PK (block_number, log_index)
candlesPer token per interval, closed bars only. The live bar is the browser's
transfersEvery launch token transfer, keyed on its position in the chain
balances, holder_countsA sum over transfers, so re-reading is free. Structural addresses excluded — curve, migrator, PoolManager, zero
positionsPer wallet per token: quantity, basis, realised
quote_pricesARB and RHUB in USD, derived through the pools (S1)
indexer_stateCursor per stream
metadata, curation, watchlistSupabase's own. The watchlist is per wallet (D40), not per device
badges, pointsShaped now, filled later — out of v1 (§8)

Four invariants:

  1. Every row traces to (block_number, log_index); writes are idempotent on it.
  2. Nothing is computed from a balance read — only from events. A replay must reproduce the database exactly, or it is not rebuildable.
  3. Launched is 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.
  4. 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 upSkipped when
1anvil, 31337, no forksomething is already on :8545
2Uniswap v3, dev markets, launchpad, Lensthe factory has code
3supabase start, migrations, generated typesalready running
4the indexer, via Compose, tailing anvilalready up
5the seederthe catalog already has launches
6the 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_increaseTime between 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 5432154324, 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.

OwnsWaits forExposesDone when
T1 ContractsLaunchLens; a testnet RHUB stand-in; quote registrationS1The Lens structLens tested, stand-in deployed, both quotes registered on Sepolia
T2 IndexerRust: tail, reconcile, backfill, candles, holders, positionsS2Rows in SupabaseReplays a chain from zero and matches on-chain truth
T3 SupabaseSchema, migrations, RLS, generated types, metadata and curation functionsS2Tables and typesdb reset replays clean and CI verifies committed types
T4 Web foundationTokens, chain layer, shell, routing, walletS1Theme file and typed hooksA themed shell routes every page with a connected wallet
T5 Web screensCatalog, token page, create, portfolio, watchlist, lab, FAQT4Each screen works against the local stack at every breakpoint
T6 Local stackCompose per environment, seeding, make targetsS3make devOne command, cold, offline
T7 OpsContabo, staging deploys, CI jobsT2, T3master 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, both package.jsons, docusaurus.config.ts, supabase/config.toml. Left alone on purpose: ModuleSet.sol's PENDING_SLOT string, which is a transient storage slot, and the notes/ 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 from config.toml's project_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.md and DECISIONS.md still 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 private indexer schema, seven text-casting views in public. Verified: replays from zero, uint256 max round-trips exactly, anon reads the views and cannot reach balances or state
  • 3.2 20260920130000_curation_and_watchlist.sqlcuration, watchlist keyed to the wallet claim through public.current_wallet(), and private.auth_nonces. launch_metadata left 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/auth and 20260920140000_auth_nonce_rpc.sql, driven end to end: a fresh wallet signs the challenge with cast wallet sign, exchanges it for a token carrying wallet and role: 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, and supabase/types/database.types.ts committed. 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, membersOf wrapped and gas-capped. src/lens added to the boundary check's core list
  • 1.2 Deploy.s.sol deploys it last and writes lens to the book; ops/sepolia.sh checks its bytecode with the rest. Verified on the local chain: deployed, in the book, answering launchCount()
  • 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 dRHUB market on the local chain with its own v3 route, and Deploy.s.sol taught 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.sol always ships a fresh LaunchLens and gains SHIP_QUOTES=1, which moves a live launchpad onto script/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.sol also gained the 0x91 hook-prefix guard Upgrade already 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, make targets that skip cleanly without a toolchain. Compiles, clippy clean at -D warnings
  • 2.2 make indexer-abi writes six committed ABIs; src/abi.rs binds them, one module each. Two things the ABIs needed first: user-defined value types normalised away (PoolId is bytes32 on the wire and unresolvable to anything reading an ABI alone), and LaunchLens left unboundLaunchStatic has 29 fields and alloy's SolType tuples 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 reported tailing 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's Prepared fires before the factory's Launched, so strict log order would leave pool_id null 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 from ModuleFeeCharged in the same transaction. Proved against the chain: the indexer's derived price, quote_collected and tokens_sold match the curve's own spotPrice(), quoteCollected() and tokensSold() 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.rs and a new indexer.transfers table. Balances are a sum over 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 behind make test-services-db, plus the getLogs rules in src/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 through host.docker.internal, with extra_hosts so the same name works on Linux
  • 6.2 scripts/dev.sh brings 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.sh resolves 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.tsx answered that with a deterministic monogram, so every screen has a mark whether or not a launch has a picture. make dev-curate fills 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. truncate rather than supabase 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 beside web/, on port 5175 so both run at once until the cut-over (D37). services left the yarn workspaces, being Rust now
  • 4.2 theme/tokens.css — colour, type, space, radius, both themes — plus base.css and breakpoints.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.ts from the address book, abis.generated.ts from 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/ — one CatalogRow type, 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 is null rather than invented. Exact values arrive as strings and become BigInt in 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_stats view 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-curate fills it on the local stack. The card moved to catalog/LaunchCard so 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's quoteBuy: a module runs inside the trade and a view cannot 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 Terms field 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.ts defines. The narrow type scale is one block in tokens.css rather than a rule per page. Overflow was found by measuring scrollWidth against clientWidth on 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/ and supabase/
  • 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

DonePhases 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
NextPhase 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 dev brings up a chain, the launchpad, Supabase, the indexer and the old web. The new app is cd app && yarn dev on :5175 until the cut-over.
  • dev/seed.sh fills the chain with 145 launches; the indexer turns them into a catalog.
  • Signing in locally needs make supabase-functions running, because supabase 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.