This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Vestal is a token launchpad on Ritual Chain testnet where launch terms (LP lock, team vesting, dev sell caps) are enforced on-chain by a "guardian covenant" wired into the token's transfer path. The repo is two projects in one: a Foundry contract suite in contracts/ and a React frontend in src/ that reads all state directly from deployed contracts — there are no mocks in the data path. README.md (root and contracts/) document the protocol design in depth; keep both in sync with behavior changes.
# Frontend (repo root)
npm run dev # Vite dev server against the live testnet contracts
npm run build # production build
npm run smoke # SSR-renders every static route via scripts/prerender.mjs — the frontend's test suite
# Contracts (cd contracts/)
forge build
forge test # full suite (VestalLaunch.t.sol, LaunchPool.t.sol)
forge test --match-test testName -vvv # single test
forge script script/Deploy.s.sol --rpc-url <url> --broadcast # registry + provider + factory
FACTORY=0x... forge script script/DemoLaunch.s.sol --rpc-url <url> --broadcast
TOKEN=0x... forge script script/DeployMarket.s.sol --rpc-url <url> --broadcastThere is no linter, no TypeScript, and no frontend unit-test framework. npm run smoke is the frontend check — it executes every page component for real through Vite SSR and fails on runtime errors vite build can't catch. Run it after frontend changes. If you add a route in src/App.jsx, add it to the routes array in scripts/prerender.mjs (only static routes — /token/:id takes chain addresses and can't be smoke-tested).
For local full-stack dev: run anvil, deploy with the scripts above (Deploy.s.sol auto-selects MockGuardianProvider when precompile slot 0x080C has no code), then point the frontend at it via VITE_* vars in .env.local (template comments are already in that file). Never hardcode chain values in UI code — every chain constant lives in src/config/ritual.js with a VITE_* env override.
src/config/ritual.js chain constants (RPC, chain id, addresses, precompiles) — the only config source
└─ src/chain/ viem layer: wallet.js, launches.js, market.js, portfolio.js, factory.js, abi.js
└─ src/data/ store hooks (useLaunches, useMarket, usePortfolio) + typedefs/formatters + site copy
└─ src/pages/ Landing, Explore, TokenDetail, Launch, Docs, Portfolio (routes in App.jsx)
└─ src/components/ purely presentational; all data arrives via props
Layering rules that hold everywhere:
- Pages own data-fetching via the
src/data/hooks; components never fetch. Components are dumb primitives (Card,Badge,Timeline, …). - State is module-level external stores consumed via
useSyncExternalStore(seesrc/chain/wallet.js,src/data/useLaunches.js). No Redux/Zustand/context, no storage beyond the wallet-reconnect flag and theme key in localStorage. Stores fetch once and share; expose arefresh...()for post-write refetches (refreshLaunches()after a deploy,refresh()after a trade). - Launch ids are lowercase token addresses —
/token/:idroutes are chain addresses; always.toLowerCase()when comparing. src/data/launches.jsis the read-model contract: JSDoc typedefs (Launch,CovenantTerms,Guardian,EnforcementEvent) plus derived-value formatters. The chain layer maps contract state into exactly these shapes; UI depends only on the typedefs and formatters, never raw contract fields. Static fixtures insrc/data/that were superseded bysrc/chain/are deliberately kept as test fixtures — don't delete them.
- ABIs are hand-written minimal
parseAbistrings insrc/chain/abi.js— only what the frontend touches, not the fullcontracts/outartifacts. Any change to a contract's read/write surface must be mirrored there. - Enum order is a wire format:
ACTION_TYPESandGUARDIAN_STATUSESinabi.jsmap by index to theActionType/GuardianStatusenums incontracts/src/interfaces/ICovenant.sol. Reordering either side breaks the Guardian Panel silently. - Units convert at the chain layer, never in UI: on-chain basis points → UI percent (
bps / 100), days → blocks viaBLOCK_TIME_SECONDS(0.2s — 1 day ≈ 432,000 blocks), everything is 18-decimal, prices are native (tRITUAL) per token.src/chain/factory.jsdoes the wizard→contract unit translation, including landing the bps rounding remainder on the last vesting tranche so totals are exact. - After a testnet redeploy, update
VESTAL_CONTRACTSinsrc/config/ritual.jsand the address tables in both READMEs.
- Every write follows simulate → write →
waitForTransactionReceipt→ checkreceipt.status(seewriteAndConfirminmarket.js). Simulating first surfaces covenant/pool reverts as readable errors before the wallet prompt. ERC20 writes go throughensureAllowance(skip approve when allowance already covers it). Gate every write path onuseWallet().onRitual. eth_getLogsis chunked: the public RPC caps ranges at ~100k blocks, so all event reads use 90k-block windows, newest first, bounded to 10 chunks. Correctness never depends on log completeness — e.g. tranche released-state comes from contract storage; logs only feed timelines.- The public RPC is load-balanced across backends with inconsistent log history.
market.jshandles this with per-chunk retries, duplicate queries per chunk, and a session-levelseenSwapscache (keyedtxHash:logIndex) whose union is what the UI shows;useMarketpolls every 15s so the cache converges. Preserve this pattern for any new event reads. - Errors never throw to the UI. Chain-layer failures are caught in the
src/data/hooks, logged asconsole.warn('[vestal] ...', err), and surfaced as a short human-readable message in store state (error: 'Could not reach Ritual Chain — …'). Keep the last good snapshot on failed background polls. - SSR-safety is mandatory (the smoke test loads every module in Node): touch
window/localStorageonly inside handlers and effects, guardimport.meta.env(seeconfig/ritual.js), and dynamicallyimport()chain modules inside hooks rather than at page module top-level.
- ~5 blocks/second (
BLOCK_TIME_SECONDS = 0.2); block-time math uses it for display countdowns only. - Block timestamps are milliseconds, not seconds — don't feed them to Unix-seconds
Datemath. - Agent precompiles (
0x080C,0x0820, Scheduler) are not live on the testnet yet, so the deployed guardian provider is the mock (guardian = deployer EOA).
Foundry, solc 0.8.28, forge fmt line length 110, only dependency is forge-std (a local minimal ERC20 lives in src/lib/ — no OpenZeppelin). Style: custom errors (never revert strings), NatSpec block comments explaining intent, section-divider comments inside contracts.
Two design invariants shape every contract decision:
- No admin keys anywhere. No owner, no pause, no fee switch, no upgrade path, no setters for terms/covenant/guardian bindings (one-shot binds only). Enforcement is a revert in the token's transfer path, not a policy. Don't introduce privileged roles.
- The Ritual precompile boundary is two files:
src/interfaces/IRitual.sol(assumed ABIs + slot addresses) andsrc/providers/RitualGuardianProvider.sol. The published precompile ABIs aren't final — any precompile touchpoint must stay behindIGuardianProviderso ABI changes never reach covenant/factory/token logic.Deploy.s.solauto-detects (code at slot0x080C→ Ritual provider, else mock).
Other structural facts: one launch is exactly one atomic createLaunch transaction (mint → creator share → deploy covenant → custody vesting → bind covenant → provision guardian committed to termsHash → register); LaunchPool is a native-paired constant-product AMM with 0.3% fee, explicitly tracked reserves, reentrancy lock, and ERC20 LP shares (so covenant custody of LP is literal balanceOf); selling into a pool is a token transfer, which is how the covenant's sell-cap/freeze checks apply to trades with zero extra wiring; vesting has a permissionless failsafe FAILSAFE_GRACE_BLOCKS (~7 days) past due, payable only to the committed recipient.
- JavaScript with JSDoc types (
@typedef,@param,@returnswith viem's`0x${string}`template types) — no TypeScript..jsxfor anything with JSX,.jsotherwise. - Every module opens with a block comment stating its role and the non-obvious constraint it handles (RPC flakiness, SSR, unit mapping). Match this density; comments explain why, not what.
- Tailwind v4 with the theme defined as
@themetokens insrc/index.css: surfacesink/surface/raise/line/linefaint, textcream/fog/faint, accentember/emberdim/gold, status colors, and a 4-steprampfor the allocation donut. Use these tokens (bg-ink,text-fog,border-line), never raw hex in components. Dark is default; light theme overrides via:root[data-theme='light'](stamped pre-paint inindex.html, persisted under thevestal-themelocalStorage key). Fonts: Fraunces (display) / Inter (body).prefers-reduced-motionis honored globally — new animations get that for free, don't fight it. - Shared utility classes live in
@layer componentsinindex.cssand are used everywhere instead of repeated Tailwind stacks:btn-ember(primary CTA),btn-ghost,card-lift,kicker(section micro-label),mono(addresses/hashes),text-ember-gradient,font-display. Micro-labels not using.kickerfollow thetext-[11px] uppercase tracking-wider text-faintidiom. - Number/address display goes through the formatters in
src/data/launches.js(fmtNativefor DEX-style subscript-zero prices,shortAddr,shortHash,fmtBlock,blocksToApproxTime) — don't reinvent formatting inline. Addresses and tx hashes link to${EXPLORER_URL}/address/…or/tx/…withtarget="_blank" rel="noreferrer".
- Transaction UI state is plain local
useStateper widget:submitting/step,error,txHash. On success, call the relevant store refresh (refreshMarketafter a trade or market open,refreshLaunches()after a deploy) rather than mutating local copies of chain state. - Error messages shown to users come from viem's
err?.shortMessage || err?.message || '<fallback sentence>'—shortMessagefirst, always with a human fallback. - Write buttons are wrapped in the
WalletGatepattern: not connected → connect button; connected but wrong chain → "Switch wallet to Ritual Chain";onRitual→ the action. Reuse/extendWalletGateinTokenDetail.jsxrather than duplicating the ladder. - Trades apply a 1% slippage guard (
minOut = estimate * 0.99); multi-transaction flows (openMarket) report progress via anonStepcallback and are written to be idempotently re-runnable — completed stages are detected and skipped on retry. - Creator-only UI (e.g. the open-market card) is gated by
wallet.address?.toLowerCase() === launch.creator.toLowerCase()— an on-chain fact, not a role system.