How we work
The development process for this repo. CLAUDE.md holds the rules; this holds the loop.
The loop
state -> discuss -> decide -> build -> prove -> land
^ |
+--------------------------------------------------------+
Every step is cheap except building the wrong thing, which is why the first three exist.
1. State
Work starts by knowing where the project stands. notes/STATUS.md is the answer, and keeping
it true is part of the work, not a chore performed afterwards.
It says: what is done, what is in flight, what is next, what is broken, and how to bring the system up from cold. A session that ends without updating it has left the next session — human or Claude — to rediscover things that were already known. That has a cost and it is always paid.
Claude reads it first. If it disagrees with the code, the code wins and STATUS.md gets fixed.
2. Discuss
Non-trivial work gets talked through before it gets written. Not a ceremony — a paragraph or two covering what we are building, what it touches, and what could go wrong.
Claude's job here is to surface decisions rather than silently make them, give one recommendation rather than a survey, and say plainly when a request conflicts with something already settled.
Skip this for anything obvious. A typo fix does not need a design discussion.
3. Decide
A decision that would be expensive to reverse gets written down in notes/DECISIONS.md — what we
chose, what we rejected, and why. One short entry.
The test for whether something belongs there: would a competent person six months from now be tempted to undo this without knowing what we knew? If yes, write it down. Curve parameters, upgradeability, the fee split, which chain, which callback charges the fee — all yes.
Open questions live in the same file, marked open. An open question is not a blocker; it is something we have agreed not to assume.
4. Build
Prefer extending something that exists to writing from scratch. The RHUB contracts are live, audited by use, and hold real money. Start from working code and change it deliberately.
This is a preference, not a law. Wrong-shaped code copied forward is worse than a blank file. Judgement call, made explicitly rather than by default.
Other build rules:
- One logical change at a time. If two things are in the tree, they are two commits.
- Experiments go in
experiments/. Nothing there is imported by production code, and CI does not gate on it. That is what makes it safe to be reckless there. - If a change starts sprawling, stop and go back to step 2.
5. Prove
A change is not done until it is proved, and a test that proves nothing is worse than no test — it costs maintenance and buys false confidence.
The rule, in one line: if you cannot say which bug this test catches, delete it. A test that still passes after you delete the logic it covers is testing plumbing.
What proof looks like depends on the layer:
| Layer | What actually counts as proof |
|---|---|
| Contracts | Unit tests per contract, invariants for anything holding money, a fork test against the live singleton, and a simulation for anything with emergent behaviour. Adversarial tests written from the attacker's side. See the foundry-workflow skill |
| Web | Does the real interface, against a real local chain, do the thing? A component test asserting that a button renders is not proof. Test the hooks and the chain-reading logic; drive the rest through the actual app |
| Services | Integration tests against a real database in Docker, not a mock. The bugs live in the seam between the service and its dependencies, which is exactly what a mock removes |
| The indexer | Replay a chain from zero and match on-chain truth. Then prove the failures that matter: a dropped WebSocket, a reorg, a restart mid-range, a partial eth_getLogs. A test that only indexes a happy chain proves nothing |
| Stylus, if it ever exists | cargo test, plus the same fork test as any other contract. The contract boundary is what matters, not the language |
Coverage is a diagnostic, never a target. An uncovered branch in money-handling code is a bug in the test suite. Coverage of a mock is noise.
6. Land
make check green, then /commit, then push to master. CI runs the same targets you just ran,
so a green local run and a red CI run means the environments diverged — fix that, do not retry.
Environments, in order
Nothing skips a rung.
| What it is for | What it cannot tell you | |
|---|---|---|
| Local | Everything. The default. make up brings the stack up cold with no network | Anything about real chain conditions |
| Fork | Correctness against real mainnet state and real singletons. Pinned block | Anything about wallets, latency, or third parties |
| Arbitrum Sepolia | The rehearsal. Real wallets, real block times, explorers, indexers, aggregators, the frontend against a chain it does not control | Not much — but it is not a correctness tool. Fork tests are better at that |
| Arbitrum One | The real thing. Deliberate, rehearsed, costed, and signed by a named account | — |
Local first is a hard rule. No development step may require an RPC. Forking exists only to put real singletons at their real addresses; without one, the deploy brings its own and the system still runs end to end. Someone with no API key must be able to clone and see it work.
Sepolia is a rehearsal, not a habit. We go there when the thing being tested is integration with the world — never to find a bug a fork test could have found faster.
Chain rungs are not deployment environments
The table above is about which chain. Everything off-chain has its own three, and they are not the same axis: a staging deployment talks to Sepolia, a production one to Arbitrum One (D33).
| Web | Services | Supabase | Chain | |
|---|---|---|---|---|
| Local | Vite on :5173 | Docker Compose | supabase start | anvil, 31337 |
| Staging | Vercel, from master | Contabo, from master | its own project | Arbitrum Sepolia |
| Production | Vercel, from prod | bare metal, from prod | its own project | Arbitrum One |
One Compose file per environment, and named volumes rather than bind mounts for anything that persists (D34). Bind mounts are for source in local development, where hot reload needs them.
The task interface
One entry point, from the repo root, for every toolchain. If a command is worth running twice, it is a make target.
make help # every target, with what it does
make up # bring the whole local stack up
make down # and take it down
make test # everything
make check # what CI gates on: fmt, lint, build, test
make fmt # format everything, in place
Per-area targets follow the same shape: make test-contracts, make dev-deploy, make test-services.
CI runs the same targets. Not a parallel set of commands that drift — the same ones. A failure that reproduces locally with one command is a failure that gets fixed.
.PHONY on every target, without exception: the repo has test/, docs/ and web/
directories, and make will silently do nothing for a target that shares a name with a directory.
CI, and what it gates
Path-filtered jobs — a change to web/ does not run the Foundry suite.
| Area | Gate |
|---|---|
contracts/ | forge fmt --check, forge build --sizes, forge test, Slither at fail-on: medium |
services/ | cargo fmt --check, clippy, cargo test, and the indexer replayed against a seeded local chain |
supabase/ | db reset, replay from zero, regenerate types, fail if the committed types differ (D35). This is the check that stops history ever needing repair by hand |
web/ | No job, by choice (D30). Vercel's build typechecks, so a broken build never ships |
experiments/ | nothing. That is the point |
Contract sizes are gated because a factory approaches the 24KB limit, and finding that out at deploy time is finding out too late.
CD, and what it does not do
web/ | Vercel's Git integration (D30): master → staging, prod → production, previews for other branches |
services/ | Image built and pushed on master, then deployed to Contabo. Deployment is a separate, deliberate step |
supabase/ | Migrations applied deliberately, never on a push. A migration is closer to a contract deploy than to a web build |
| Contracts | Never. Not on a push, not on a tag, not ever |
A contract deploy is a decision, not a consequence of a push. It is rehearsed on a fork, costed, announced in the session, and signed by a named keystore account. Nothing in CI holds a key that can reach a real chain.
Services and Supabase
The one rule everything else hangs off: the backend observes, and nothing settles against it (D38). Every figure it serves is derived from chain events and rebuildable from zero. No trade, fee, graduation or launch may require it to be running. Say indexer, never keeper.
Local first covers the backend too. make dev brings up the chain, the contracts, Supabase,
the services and the web, seeded, with no API key and no RPC. If a service can only be developed
against staging, that is a bug in the service.
Supabase
Managed, driven by the CLI (D35).
supabase start # the local project
supabase db diff -f <name> # author a migration, then read the SQL it wrote
supabase db reset # replay from zero -- the check that keeps history clean
supabase gen types typescript # never hand-written (D36)
- Nothing is ever changed in the Studio dashboard. That is how history breaks.
- One project per environment. Local, staging, production.
- The browser reads, the indexer writes — anon key and RLS for the web, service role only from a service. The service role key lives in secrets and never in the repository.
- An MCP may inspect a live project. It may never apply a change.
Edge functions, locally. Two things about supabase start that each cost an hour once:
- Its edge-runtime container takes the function list as an environment variable written when the
container is created. A new function needs
supabase stop && supabase start.docker restartre-runs the same container with the same variable, andsupabase db resetonly replays migrations against the database. - It does not read
supabase/functions/.env.local. Onlysupabase functions serve --env-filedoes, which ismake supabase-functions. A function that needs a secret — signing in needs the JWT secret — therefore works only with that running beside the stack.
make dev writes both env files from supabase status, so a fresh clone configures nothing by
hand.
The indexer
Rust, in Docker Compose (D34). Four properties, each a real failure rather than a nicety:
- A durable cursor, so a restart resumes rather than guesses.
- Idempotent writes, so replaying a range costs nothing and a double-delivered log is harmless.
- Backfill separate from tailing. Entangling them is how a gap becomes invisible.
- A reconciliation sweep that is the source of truth. The WebSocket is an accelerator; it blips, and a blip nobody notices is a silent gap.
Two traps already paid for, in CLAUDE.md and worth repeating where the indexer is written: a
public RPC can return a partial eth_getLogs range without erroring, and Arbitrum Sepolia's
refuses a query matching more than 10,000 logs with an error rather than truncating. So split a
range only on a size refusal — splitting on any error recurses to single blocks and sends
millions of requests — and verify coverage rather than assuming it.
Documentation, for now
notes/ — findings, decisions, handoffs, anything worth not rediscovering. Committed, plain
markdown, read locally.
docs/ renders both halves: the public site, and notes/ at /internal. Both are hosted, and
the internal one is public (D8 as amended) — so treat everything in notes/ as published, and
keep anything that should not be out of it. notes/scratch/ is gitignored and never built.
notes/STATUS.md | Where the project stands. Updated as part of the work |
notes/PROCESS.md | This file |
notes/DECISIONS.md | What we chose and why. Open questions, marked open |
notes/*.md | Findings, maths, handoffs, anything expensive to rediscover |
notes/scratch/ | Gitignored. Anything not worth keeping |