Skip to main content

Session — Staging: put it where people can use it

What this session is for

Stand the whole system up on staging so the developer can gather feedback on what to improve. Sepolia contracts, a Supabase project, the indexer on the Contabo box, the interface on Vercel.

It is Phase 4 of notes/PLAN.md. Not production — that is Arbitrum One, a prod branch and a second Supabase project, and it is not in scope.

Read first

  • notes/DEPLOY-STAGING.md — the runbook. The order, what has to be supplied, the traps
  • notes/STATUS.md — where the project stands
  • CLAUDE.md — the trap list, especially Frontend and deployment and Shell and ops
  • docker-compose.staging.yml, contracts/ops/sepolia.sh, .github/workflows/

Already decided — do not reopen

  • A completely fresh Sepolia deploy, not an upgrade (21 Sep 2026). What is there runs code with known bugs whose fixes were never shipped, has no Lens, and is WETH-only; an upgrade would leave those launches on that cohort for life (D12) and in the catalog people are giving feedback on. Nothing there has value. notes/DEPLOY-STAGING.md §1 has the consequences.
  • A new Vercel project for app/; the old one retired along with web/ (21 Sep 2026).
  • Supabase is managed and CLI-only (D35). Nothing is ever changed in the dashboard, and the project already linked (vbtioxfmmayjbhnbhzsp) is not empty — read supabase migration list before pushing anything.
  • One Compose file per environment (D34), named volumes, no bind mounts for anything durable.
  • A contract deploy is a decision, not a consequence of a push. Rehearsed, costed, stated, and signed by the keystore. Never CI (D7).
  • The backend observes; nothing settles against it (D38). Staging must still work with the indexer off, showing fewer numbers.

What the developer supplies

An Alchemy Sepolia URL; the Contabo IP and root password (the box ends up key-only); the Supabase project's database password and keys. None of it enters the repository.

The open question worth answering early

How a tester gets ARB and RHUB. The stand-ins are mintable by anyone by design, but nothing in the interface offers it — so somebody arriving with an empty wallet can look and not trade, and the feedback comes back about the catalog rather than about the whole flow. A faucet button is small. Decide it before inviting anyone, not after.

Related: how staging gets enough launches to be worth looking at. DevSeed.s.sol is local-only.

Done — the wallet write path (21 Sep 2026)

It works now. A buy from MetaMask against the local chain landed, and the page invalidated without a reload: Raised 0 → 96.0 dARB, Holders 0 → 1. writeContract → receipt → invalidate is proved. It took four attempts, and each failure was a real bug that would have reached staging.

Four wallets, and the button could reach none of them by name. Wallet.tsx connected connectors[0], which is the configured injected() — and that targets window.ethereum, which is whichever extension won the injection race. Rabby had, and it sets isMetaMask too, so nothing about the object said which wallet was behind it. wagmi's EIP-6963 discovery was running the whole time and appending a properly named connector per wallet after the explicit one, where the UI never looked. Now it offers the discovered ones and drops the generic duplicate.

Expired() — the local chain's clock, not the deadline logic. dev/seed.sh warps with evm_increaseTime between epochs and that offset is permanent, so anvil times every later block at real-now plus several hours. anvil also mines only on demand. A chain seeded the night before therefore had a newest block that was both stale in wall-clock terms and far behind where the chain's clock had moved to — and the deadline is built from that block. The quote simulated at the stale timestamp and passed; the block the trade landed in was 16,322 seconds past the deadline. scripts/dev.sh now mines one block at startup.

ModuleGasTooLow(236737, 640000), then ObserverGasTooLow(597407, 640000) behind it. The interface sent simulated × 1.5. But a gasleft() floor is gas that must be handed over, and a simulation runs with a whole block, so it reports what the trade used and nothing about what the floor saw. The floors are also sequential: clearing the curve's module check at 640k leaves the closing transfer short of the token's observer check, which is why raising the limit to 940,000 moved the failure rather than fixing it. trade.ts now hands over 3,000,000 — the worst case the protocol can ask for, graduation's 2M plus headroom — and stops trying to predict which check binds. A limit is a ceiling, not a price. Both traps are in CLAUDE.md.

Unrelated but found on the way: make lint had been red since the Lens landed, on three Slither unused-return false positives. Fixed. The directive must be the last comment line above the statement — prose on a second line below it and all three still fired.

Sepolia, costed and ready to send

make sepolia-rehearse against the Alchemy endpoint, clean: 30,852,499 gas, 0.00568 ETH at a max fee of 0.184 gwei, against a base fee of 0.09206 and a deployer holding 19.23 ETH. All thirteen contracts resolved, hook mined, quote menu registered — dARB at 69,000 and dRHUB at 250,000. The book was not touched; the vm.isContext(ScriptBroadcast) gate held.

Decided — the quote assets testers get (21 Sep 2026)

WETH stays off the quote menu. It was tempting to put it back as a third pairing, because its threshold is 0.01 ETH and that would have made graduation cheap to exercise. Rejected: Deploy.s.sol dropped it on purpose — "a testnet that only ever pairs against WETH never exercises the menu v1 actually ships" — and adding it back for convenience buys a testnet that tests something else. The menu is the two stand-ins. WETH is the router's unwrap target and nothing more.

A faucet button, tokens only. Ether for gas is a public faucet's job. Whether to offer it is decided by simulating the mint rather than by a list of chains, because the question is not which chain this is but whether this token hands some over. Ten thousand a press, under both thresholds.

Consequence, now unavoidable: the ETH pay option cannot route for any launch on staging, so it has to be hidden rather than left to fail. That is stage 4 and issue #1.

Tracked on GitHub

Bugs and improvements go to stealthy-town/stadium issues from here, so they outlive the session file. Open at the time of writing:

  • #1 — the interface offers an ETH pay option that cannot route on Sepolia
  • #2 — a faucet button, closed by fb18316

Done — Supabase (21 Sep 2026)

Migration history was the clean case: 20260918000000 was present on both sides with its row, so no migration repair was needed and the five indexer migrations pushed without conflict. All six now match. Both edge functions deployed and answered a live request — auth/nonce returned a real nonce, which proves the JWT secret loaded and the nonce table landed, and metadata accepted 421614 while refusing 999, which proves FACTORY_ADDRESSES points at the new factory.

Two things that cost time and are worth not repeating:

  • FACTORY_ADDRESSES was stale, still naming the factory retired that morning. The metadata function refuses a chain whose factory it does not recognise, so every picture and description for a new launch would have been rejected with nothing in the interface explaining why.
  • The JWT secret is not any of the keys. Supabase's new projects issue opaque sb_publishable_… / sb_secret_… keys and bury the legacy symmetric secret, but the anon and service_role tokens are still HS256, so the secret exists. Setting JWT_SECRET to the service_role key looks like it worked — supabase secrets list gave both the same digest, which is how it was caught. tmp/set-jwt-secret.sh verifies a candidate by re-deriving the anon token's signature and only writes on a match. Issue #3 covers removing the dependency before mainnet.

Domains, decided by the developer: the site at stage.stadium.money, the docs at docs.stadium.money. ALLOWED_ORIGIN is already set to the first.

Done — Contabo (21 Sep 2026)

Audited before hardening, not after. 16,940 failed password attempts in under two days and five successful logins, all from the developer's two Serbian IPs. No unknown access, only one key on root, no unexpected listeners, stock cron. Clean, so hardening was worth doing rather than futile.

stadium user, key-only, passwordless sudo — created and verified working before root login and password auth were disabled, then both confirmed refused from outside. ufw allows 22 and nothing else; the indexer opens no inbound port. Docker 29.8.1, Compose v5.5.1. The developer's ~/.ssh/config alias now points at stadium (backup at ~/.ssh/config.bak).

Four things cost time, all now in CLAUDE.md:

  • sshd reads the first value for a keyword, not the last, so a 99- hardening drop-in loses to cloud-init's 50-. The image's own 60- file had already been overridden the same way, which is why the box had password auth at all.
  • The database URL is the session pooler on 5432, not the transaction pooler on 6543. sqlx keeps server-side prepared statements; transaction pooling rotates the backend underneath them and it fails instantly with prepared statement "sqlx_s_1" already exists. Host aws-1-eu-west-1, found by connecting to both rather than guessing, and the username is postgres.<ref>.
  • Alchemy's free tier is worse than the public endpoint for log scanning — ten blocks per eth_getLogs, then a compute-unit throttle, against a public endpoint that served the whole 12,553-block backfill in one call. The runbook said the opposite and has been corrected.
  • Compose forwards only the keys its environment: block names, so MAX_BLOCK_RANGE in .env did nothing while the file on the box said otherwise.

Indexer state: running, cursor at 311269876 against a head of 311269878 — caught up and polling. All eleven tables present, every one empty, which is correct: launchCount on the new factory is zero. It stays unproven end to end until something launches on Sepolia.

Done — Vercel, the web retirement, and CI (21 Sep 2026)

The site is live at https://stage.stadium.money, built from app/ by the new stadium-app project. TLS, the SPA rewrite and the Sepolia addresses all verified from outside rather than from the dashboard. ALLOWED_ORIGIN already matched.

Rehearsing the Vercel build locally is what caught the bug that would have broken it. isLocal compared the generated chain id against 31337, and at 421614 TypeScript reads that as two literals that can never be equal and refuses to build. Nothing had noticed because sync.mjs defaults to 31337 — every check we ran typechecked a chain nobody deploys. build-app-testnet is in make check now and typechecks against the book the site is actually built from.

A password on the door, as Edge Middleware rather than Vercel's feature, which bills $20 per project per month and would be $40 once production exists beside staging. SITE_PASSWORD, deliberately not VITE_-prefixed: Vite inlines those into the client bundle, which would ship the password to the people it is meant to stop. Its presence is the switch, so the same code goes to production with the variable unset. Verified: no credentials 401, wrong password 401, right one 200, and the JS bundle is behind it too.

web/ is gone — the directory, its workspace entry, its mentions, and the stadium-launchpad Vercel project. That project's build had already started failing the moment the directory left the repo, which is what confirmed which of the four it was.

All three workflows are green, two of them for the first time ever. Both had to be fixed first:

  • The contracts job ran the repo-wide make fmt-check, which reaches into app/ for prettier in a job that installs only Foundry. It had never passed since that line was added.
  • Then it failed on one test that passes locally: test_aSetOfEveryMechanic_stillRevertsOrGraduates walked 150k to 12M in 1,000-gas steps, which with its sweep came to 1.04 billion gas against forge's 2**30 ceiling — three percent of headroom, enough here and not on CI's build. Binary search over the same grid gives the same answers at 509 million.

Still open in this thread

ETH trading will not work on staging, and the interface offers it anyway. The Sepolia config makes the two stand-ins the whole quote menu and demotes WETH to the router's unwrap target (Deploy.s.sol:151-160), but trade.ts routes the ETH path through a Uniswap v3 pool that will not exist for either stand-in. TradePanel.tsx:176,209 offers ETH unconditionally, so every launch will show an ETH option whose every quote fails. Cheapest fix is to hide it when the chain has no v3 route, rather than seeding v3 liquidity on Sepolia. Undecided.

This stacks with the faucet question above: without one, a tester can neither buy with ETH nor obtain dARB, and can only look.