Foundry · ERC-721 · ERC-6551 · Robinhood Chain 4663

Insert coin. Ship a revenue share.

Membership NFTs that own their own wallets, a vault that splits explicitly deposited revenue across holders by a weight you write in ten lines, and a factory that clones the pair. Tested with fuzz and invariants; every chain address VERIFY-tagged until you confirm it.

Overview

#🪙 Robinhood App Boilerplate

Membership NFTs that own wallets, a vault that shares revenue with them, and a factory that clones the pair — for Robinhood Chain. Scaffold, test, ship.

📖 Documentation · Start here · New to crypto? · create-robinhood-app on npm

#Start here

Five commands, about ten minutes. Each is explained step by step in the getting-started guide.

  1. Install the toolscurl -L https://foundry.paradigm.xyz | bash && foundryup, Node ≥ 20, npm i -g pnpm
  2. Create a projectnpx create-robinhood-app my-app (add --fullstack for a website)
  3. Run the testscd my-app && pnpm verify
  4. See it work locallypnpm dryrun (deploys, mints, deposits, distributes on a local chain)
  5. Make it yours — write a 10-line weight strategy, then deploy with forge script script/Deploy.s.sol --rpc-url robinhood --broadcast

Every project you scaffold is a Foundry monorepo pre-wired to Robinhood Chain (chain id 4663). It ships three small contracts: MembershipNFT — an ERC-721 where every token owns an ERC-6551 token-bound account (a wallet controlled by whoever holds the NFT); RevenueVault — splits ERC-20 revenue that was explicitly deposited across current holders, pro-rata by a pluggable weight, and pays each share into that token's wallet; and Factory — deterministic EIP-1167 clones of the pair, wired and owned by you in one transaction. Optional extras behind CLI flags: a Next 15 + wagmi/viem front end and a plain ERC-20 game token.

Your game logic is one function:

solidity
contract LevelWeightStrategy is IWeightStrategy {
    mapping(uint256 tokenId => uint256) public level;

    function weightOf(address, uint256 tokenId) external view returns (uint256) {
        return 1 + level[tokenId]; // level 3 earns 4× what level 0 earns
    }
}

vault.setStrategy(address(new LevelWeightStrategy())) — that's the whole wiring. The vault never learns what a level is.

#Quick start

bash
npx create-robinhood-app my-app                        # contracts only — the smallest start
npx create-robinhood-app my-app --fullstack            # + apps/web (Next 15 + wagmi/viem), pinned to 4663
npx create-robinhood-app my-app --with-token           # keep the optional plain ERC-20 GameToken
npx create-robinhood-app my-app --template ../local    # scaffold from a local checkout or another git URL

Or use the template directly:

bash
git clone --recurse-submodules https://github.com/dvd90/robinhood-app-boilerplate.git my-app
cd my-app && pnpm verify

Then pnpm dryrun starts a local chain, deploys everything, mints a membership, deposits revenue and distributes it — no RPC, no keys, no accounts needed.

#For AI agents

The CLI never prompts when a name is given, and one command produces a project that already formats, compiles and tests green — including fuzz and invariant runs.

  • llms.txt — the project, its conventions and its docs index, in one fetch
  • llms-full.txt — every documentation page, concatenated
  • CLAUDE.md — the five golden rules, the TDD workflow and the definition of done that agents follow

Generated projects carry CLAUDE.md and llms.txt, so whichever agent opens one respects the invariants the tests enforce — above all, that the vault only ever distributes what was deposited.

#Features

  • Token-bound accounts — every membership mints an ERC-6551 wallet; control follows ownerOf, so selling the NFT hands over its balance too
  • Explicit-deposit vaultdepositRevenue() is the only way money enters; no receive(), no mint-fee routing, no auto-tax — enforced by tests
  • Pull-based payoutsdistribute() allocates, claim() pays; a reward token that refuses one recipient (blocklists, as tokenised stocks tend to have) blocks only that recipient, never the round
  • Pluggable weightIWeightStrategy.weightOf(nft, tokenId) is the whole game surface; EqualWeightStrategy by default, TenureWeightStrategy as a worked reference
  • Dust carried, never dropped — integer-division remainder stays in the vault for the next round; an invariant proves Σ paid + Σ claimable + carried == Σ deposited
  • Hostile-token safenonReentrant on distribute()/claim(), fee-on-transfer credited by actual delta, tested against a re-entering mock
  • Deterministic clonesFactory.predict(deployer, salt) before deploy(); salts are namespaced per deployer so they never collide
  • Minimal owner powersOwnable2Step, non-upgradeable clones, no function can move user funds or undistributed balances
  • Fuzz + invariant suite — 56 tests across 10 suites; CI runs 2048 fuzz runs and 256 invariant runs
  • One-command local runpnpm dryrun boots anvil, deploys, mints, deposits, distributes, claims
  • Optional front end--fullstack adds a Next 15 + wagmi/viem app pinned to chain 4663 that reads TBA balances live
  • Optional game token--with-token keeps a plain ERC-20 with no tax and no hooks
  • CI ready — GitHub Actions runs the same gate as pnpm verify; steps skip themselves in pruned scaffolds
  • Agent readyCLAUDE.md + llms.txt in every scaffold

#Golden rules

Architectural invariants the tests enforce. The full rationale is in Economics & trust and CLAUDE.md.

  1. The vault distributes only what is explicitly deposited. Mint proceeds go to the treasury, never to the vault.
  2. Game mechanics never touch money math. Weight comes through IWeightStrategy and nothing else.
  3. No market-making, multi-wallet or volume tooling. Not in contracts, scripts or the front end. Ever.
  4. Never hardcode an unverified address. Chain constants live in two files and carry VERIFY tags until confirmed.
  5. Trustlessness over convenience. Non-upgradeable clones, Ownable2Step, no owner escape hatch over funds.

#Scripts

Command What it does
pnpm verify forge fmt --check + forge test (+ tsc + lint if apps/web)
pnpm dryrun anvil → deploy → mint → deposit → distribute → claim, prints the result
pnpm dryrun <rpc-url> same, but forking Robinhood Chain (VERIFY the URL first)
forge test (in contracts/) unit + fuzz + invariant tests; FOUNDRY_PROFILE=ci for CI depth
forge script script/Deploy.s.sol --rpc-url robinhood --broadcast deploy to chain 4663, writes deployments/4663.json
pnpm -C apps/web dev run the front end (if scaffolded with --fullstack)
bash packages/create-robinhood-app/test.sh the scaffold gate: tarball, forge hint, bare + fullstack from a fresh shell, dryrun, docs (CI runs it)

#Configuration

Nothing is required to test or dry-run. For a real deploy, Deploy.s.sol reads these (all optional, defaults shown) and the toolchain reads the RPC/explorer URLs from your shell:

Variable Default Used by
PROJECT_NAME / PROJECT_SYMBOL Membership / MBR the NFT
TREASURY the deploying account receives mint proceeds
MINT_PRICE (wei) / MAX_SUPPLY 0.01 ether / 1000 the NFT
SALT "membership" deterministic clone addresses
ERC6551_REGISTRY / ERC6551_ACCOUNT_IMPL Constants.sol (VERIFY) override the 6551 addresses
ROBINHOOD_RPC_URL / ROBINHOOD_BLOCKSCOUT_API_URL / BLOCKSCOUT_API_KEY foundry.toml --rpc-url robinhood / --verify
NEXT_PUBLIC_ROBINHOOD_RPC_URL / _EXPLORER_URL / NEXT_PUBLIC_REWARD_TOKENS apps/web (.env.example)

Every value is documented in Configuration.

#Example project

examples/arcade-guild/ is a complete project built on the boilerplate without copying it: a LevelWeightStrategy where the guild owner raises members' levels and revenue splits by 1 + level, a test that reuses the shared fixture, and a deploy script that reuses Deploy.deployCore(). Three remappings do all the plumbing.

bash
cd examples/arcade-guild && forge test

Walkthrough: Example: Arcade Guild.

#Project structure

code
contracts/
├── src/
│   ├── MembershipNFT.sol        # ERC-721 + one ERC-6551 account per token; proceeds → treasury
│   ├── RevenueVault.sol         # depositRevenue → distribute (allocate) → claim (pay into TBAs)
│   ├── Factory.sol              # cloneDeterministic NFT + vault, wired and owned by the caller
│   ├── Constants.sol            # chain 4663 addresses, every one VERIFY-tagged
│   ├── strategies/              # IWeightStrategy + EqualWeight (default) + TenureWeight (reference)
│   └── GameToken.sol            # optional plain ERC-20 (--with-token)
├── test/                        # unit + fuzz + invariants; mocks incl. a re-entering token
└── script/                      # Deploy.s.sol, DryRun.s.sol, dryrun.sh
apps/web/                        # optional Next 15 + wagmi/viem (--fullstack); lib/robinhood.ts holds addresses
deployments/4663.json            # written by Deploy.s.sol, read by the front end
examples/arcade-guild/           # a project built on the boilerplate via remappings (pruned from scaffolds)
packages/create-robinhood-app/   # the CLI (pruned from scaffolds)
docs/                            # this documentation — the site is generated from it
site/                            # the docs site builder → GitHub Pages (pruned from scaffolds)
CLAUDE.md                        # conventions and invariants for humans and agents — read first

#Documentation

Read it at dvd90.github.io/robinhood-app-boilerplate — searchable, one page. The source lives in docs/ and the site is generated from it, so the two can never disagree:

#Before a real deploy

Every chain-4663 constant (contracts/src/Constants.sol, apps/web/lib/robinhood.ts, foundry.toml) is VERIFY-tagged: the values come from project notes, not from official sources. Confirm each against the official Robinhood Chain documentation and the block explorer, then drop the tag. A wrong address is a silent-failure bug.

And a non-code note: tokenised stocks used as reward tokens are restricted securities, and a token that entitles holders to revenue may itself be one. That is a question about your token, separate from whether this code is correct — get counsel.

#License

MIT

Guides

#Tutorial: what you built, in plain words

This page is for you if you have this repository in front of you, you are not from the crypto world, and you want to understand what it does and what you can make with it. It uses everyday comparisons, small numbers and pictures. There are no commands here — when you want to run things, Getting started has them, step by step.

The whole project on one picture. Read it top to bottom, then keep going: every box gets its own section below.

code
 Alice ─pays─▶ ┌────────────────┐ ─price─▶ your treasury
 Bob   ─pays─▶ │ Membership NFT │          (a normal wallet;
 Carol ─pays─▶ │ cards #1 #2 #3 │           never the vault)
               └──┬────┬────┬───┘
                  │    │    │      every card owns a wallet
               ┌──▼─┐┌─▼──┐┌▼───┐
               │ w1 ││ w2 ││ w3 │ ◀── claim() pays shares here
               └────┘└────┘└────┘
                        ▲
 revenue ─deposit─▶ ┌───┴────────┐ "shares for #N?" ┌────────────┐
 (any ERC-20)       │   Vault    │ ───────────────▶ │ Weight rule│
                    │  (splits)  │ ◀───── "4" ───── │ (your code)│
                    └────────────┘                  └────────────┘

In one sentence: people buy a numbered membership card; each card comes with its own wallet; revenue you deposit into a vault is split across the cards by a rule you write, and paid into those wallets; sell the card and its wallet goes with it.

#Four words you need first

Blockchain. A shared notebook that thousands of computers keep identical copies of. Anyone can read it. Anyone can add a line, for a small fee. Nobody can erase or edit a line once it is written. Robinhood Chain (chain id 4663) is one such notebook; this project is written for it.

Wallet. A keychain. It has an address — a long number that works like a mailbox number, public, safe to share — and a private key, the only thing that lets you send from that address. Lose the key and nobody, including the people who wrote the software, can help.

Token. An entry in the notebook saying who owns what. Two kinds matter here. Coins are interchangeable, like euros: 5 of them are worth exactly as much as any other 5. Their standard is called ERC-20; tokenised stocks on Robinhood Chain are ERC-20 tokens. Unique items are numbered, like concert tickets: #7 is not #8. Their standard is called ERC-721, and each one is an NFT.

One more thing counts as money but is not a token: the notebook's own built-in coin (ETH), which every writing fee is paid in. The docs call it the native coin. No contract issues it, so it is not ERC-20. In this project it is what members pay the mint price with; the revenue that gets split is always an ERC-20 token — two different currencies, kept apart on purpose.

Smart contract. A vending machine placed inside the notebook. It can hold money and tokens, and it follows rules that were fixed the moment it was placed — the owner cannot quietly change them later. You use it by calling a function: press a button, the machine does exactly what its rules say.

This project is three vending machines plus one small rule — itself a tiny contract — that you either pick from the ready-made ones or write yourself.

#The membership card

The first machine is MembershipNFT. It sells numbered cards: #1, #2, #3, up to a maximum you set when you deploy it. Anyone can buy one by paying the mint price ("minting" is the crypto word for creating a token). Pay too much and the difference comes straight back.

Where does the money go? To your treasury — a normal wallet you name when you deploy. It does not go into the vault. That is a deliberate, tested decision: if entry money flowed into the vault, early members would be paid with later members' entry fees, which is a very different (and much worse) product than a revenue share. See the two pipes below.

A card is a normal NFT. Its holder can sell it or give it away like any collectible. Cards are never destroyed, so if 40 have been sold the ids are exactly 1 to 40. One small extra: each card remembers when its current holder got it (heldSince). Sell the card and that clock resets for the buyer — one of the ready-made rules uses it.

#The card's own wallet

Here is the part with no everyday equivalent, so take it slowly. When a card is minted, the machine also creates a second wallet that belongs to the card itself — not to the buyer, to the card. It is not a keychain like yours — it has no private key at all. It is a tiny vending machine of its own whose single rule is "obey whoever holds the card". The standard is called ERC-6551; the docs call it a token-bound account or "the card's wallet".

code
 ┌──────────────────┐ "who holds card #3?" ┌──────────────────┐
 │ card #3's wallet │ ───────────────────▶ │  Membership NFT  │
 │ (address fixed   │ ◀────── "Carol" ──── │ ownerOf(3)=Carol │
 │  at mint time)   │                      └──────────────────┘
 └──────────────────┘
   nothing is stored: the wallet asks every time.

 Carol sells #3 to Dave ─▶ ownerOf(3)=Dave ─▶ Dave controls the
 wallet and everything already inside it.

Three things to know about it:

  • Its address is fixed forever. It is computed from the card's number, so it can be known even before the card is minted and it never changes.
  • It stores no owner. Whenever someone tries to spend from it, the wallet asks the card machine "who holds card #3 right now?" and obeys that person. There is no "transfer the wallet" step, because there is nothing to transfer: sell the card and the wallet — with everything inside it — follows automatically.
  • Nobody else can reach in. Not you as the project owner, not the factory, not the vault. To move tokens out of a card's wallet — say, into your own — the holder sends the card's wallet one instruction from the wallet that holds the card: "send X of token T to address Y" (the standard calls this execute). The optional website that ships with the project shows each card's wallet balance and lets holders claim; it has no button for this last step yet, so today it is done with a short script or any wallet app that understands ERC-6551.

Why bother? Because it makes the whole membership one thing. The card, its history, and every token it has ever earned travel together. A buyer on a marketplace gets the card and its balance; a seller cannot keep the earnings and sell an empty card.

#The vault: deposit, split, claim

The second machine is RevenueVault. It holds revenue and splits it across the cards.

Money is counted into the vault in exactly one way: someone calls deposit with an ERC-20 token and an amount, on purpose. The native coin cannot be sent to the vault at all — the transaction fails. So the vault can only ever hand out what somebody explicitly put in.

Careful. Use the deposit call, never a plain transfer to the vault's address. Tokens sent directly are accepted by the token but the vault does not count them, and there is no function to recover them — they are stranded.

code
   MINT MONEY (native coin)          REVENUE (ERC-20 tokens)
   a member pays the price           whoever earned it deposits it
            │                                   │
            ▼                                   ▼
     your treasury                          the vault
   (the vault never sees             (split by weight, paid only
    a single unit of it)              into card wallets)

   The two pipes never cross. A test fails if anyone connects them.

From deposit to a card's wallet there are three moves. Anyone can make each of them — not just you — because none of them lets the caller choose where money goes.

code
 (1) depositRevenue(token, 100)   anyone who holds the tokens
             ▼
 ┌────────────────────────┐
 │ distributable = 100    │      "waiting to be split"
 └───────────┬────────────┘
             │  (2) distribute(token)   anyone; bookkeeping only —
             ▼                          not a single token moves
 ┌────────────────────────┐
 │ claimable  #1 → 14     │
 │            #2 → 28     │      remainder 1 goes back up
 │            #3 → 57     │ ───▶ into distributable (next round)
 └───────────┬────────────┘
             │  (3) claim(token, [1,2,3])   anyone; tokens move now
             ▼
   wallet #1 +14      wallet #2 +28      wallet #3 +57

   (weights 1 : 2 : 4 — explained in the next section)
  1. Deposit. Tokens come in and are counted as waiting to be split.
  2. Distribute. The vault asks the weight rule how many shares each card gets (next section), divides the waiting amount in proportion, and writes down each card's share. No tokens move yet. One distribute call is what the docs mean by a round.
  3. Claim. For each card named in the call, the written-down share is sent to that card's wallet. This is when tokens actually move.

Why write it down first and pay later? Because the tokens this project is built for — tokenised stocks — often refuse to be sent to certain addresses. If distribute paid everyone directly, one refused wallet would make the whole round fail for everybody. With pay-on-claim, a refused card simply cannot claim yet; its share stays written down, and every other card claims normally.

Two more details you will meet in the numbers:

  • Leftovers are never lost. Splitting 100 three ways gives 33 each and 1 left over. That 1 stays in waiting and joins the next round. It is never sent to the owner and never destroyed. The docs call it dust.
  • Each token is tracked separately. Deposit stock token A and stock token B and the vault keeps two independent ledgers; each is distributed and claimed on its own.

#The weight rule

At distribute time the vault asks one question per card: "how many shares does card #N get?" It adds up the answers and divides in proportion. The vault does not know why a card gets 4 shares and another gets 1 — the answering is done by a separate, tiny contract called the weight strategy, and that is where all the game, loyalty or business logic lives.

code
 ┌─────────┐                                ┌─────────────────────┐
 │  Vault  │ ── weightOf(nft, #1)? ───────▶ │ Weight rule         │
 │         │ ◀──────────────────── 1 ────── │                     │
 │ knows   │ ── weightOf(nft, #2)? ───────▶ │ Equal   → 1         │
 │ nothing │ ◀──────────────────── 2 ────── │ Tenure  → 1+periods │
 │ about   │ ── weightOf(nft, #3)? ───────▶ │ Level   → 1+level   │
 │ games   │ ◀──────────────────── 4 ────── │ Yours   → ...       │
 └─────────┘                                └─────────────────────┘
        total 7 → #1 gets 1/7, #2 gets 2/7, #3 gets 4/7

Three rules already exist:

Rule Answer to "how many shares?" Where
Equal 1 for every card — everyone earns the same ships, the default
Tenure 1 + the number of full periods the current holder has held the card; a buyer starts again at 1 ships, as a worked reference
Level 1 + the card's level, which the project owner raises (think: a game server) the Arcade Guild example

An answer of 0 means "no share this round" — useful for "suspended" or "not checked in this season". If every card answers 0, nothing is written down and the deposit simply waits for a later round.

The rule really is small. This is the heart of the Level rule, and the only code on this page — the rest of that contract is a table of levels and a setLevel button only the rule's owner can press:

solidity
function weightOf(address, uint256 tokenId) external view returns (uint256) {
    return 1 + level[tokenId]; // level 3 earns 4× what level 0 earns
}

Keep one consequence in mind for later: whoever controls the rule's inputs controls the split. If you can raise a card's level, you can raise its share.

#The factory

The third machine is the Factory. You call it once, with a name and a short symbol (a ticker-style abbreviation such as ARCD), a mint price, a maximum number of cards, a treasury address and the weight rule to use. In one transaction it stamps out a fresh card collection and a fresh vault, wires them to each other, and makes you the owner of both.

code
   Factory.deploy(salt, {name, price, supply, treasury, rule})
                                  │
              one transaction     │    you become owner of both
             ┌────────────────────┴─────────────────────┐
             ▼                                          ▼
   ┌──────────────────┐     wired together     ┌──────────────────┐
   │  Membership NFT  │ ◀───────────────────▶  │      Vault       │
   │  "Arcade Guild"  │                        │ rule: LevelWeight│
   └──────────────────┘                        └──────────────────┘

   Same factory, another salt → a second, fully separate project.

The "salt" is just a label you choose. The addresses of the two new machines are computed from your address and that label, so they can be predicted before you deploy; using the same label twice fails; using a different label gives you a second, fully separate project — same code, its own members, its own money. The factory keeps no power over anything it stamps out.

Nothing the factory creates can be upgraded, paused or replaced afterwards. That sounds like a limitation; it is the point. A member can read the rules once and know they will still be the rules next year.

#Worked example: three members, 100 tokens

A guild called "Arcade Guild", mint price 0.01 ETH (the native coin — a different currency from the revenue token below, which is why it never appears in the vault's books), weight rule 1 + level. The revenue numbers are whole tokens so you can follow along; real tokens have 18 decimal places (see the note at the end).

  1. Alice, Bob and Carol each mint a card — #1, #2, #3 — paying 0.01 each. Your treasury receives 0.03. Three card wallets now exist, all empty. The vault has seen nothing.
  2. You set levels: Alice 0, Bob 1, Carol 3. So the weights are 1, 2 and 4 — total 7.
  3. Somebody deposits 100 tokens into the vault. Waiting: 100.
  4. Anyone calls distribute. 100 × 1/7 = 14.28…, 100 × 2/7 = 28.57…, 100 × 4/7 = 57.14… Shares are always rounded down: 14, 28, 57. That is 99 written down; the leftover 1 stays waiting.
  5. Anyone calls claim for cards 1, 2, 3. Wallet #1 receives 14, wallet #2 receives 28, wallet #3 receives 57.
  6. Somebody deposits 55 more. It joins the leftover 1: waiting is 56.
  7. Distribute again. 56 divides by 7 exactly: 8, 16, 32. Leftover 0.
  8. Claim again. The card wallets now hold 22, 44 and 89.
Step Waiting in vault Written down #1 / #2 / #3 Card wallets #1 / #2 / #3 Treasury
3 mints at 0.01 0 0 / 0 / 0 0 / 0 / 0 +0.03
deposit 100 100 0 / 0 / 0 0 / 0 / 0
distribute 1 14 / 28 / 57 0 / 0 / 0
claim [1, 2, 3] 1 0 / 0 / 0 14 / 28 / 57
deposit 55 56 0 / 0 / 0 14 / 28 / 57
distribute 0 8 / 16 / 32 14 / 28 / 57
claim [1, 2, 3] 0 0 / 0 / 0 22 / 44 / 89

Check the books: 22 + 44 + 89 = 155 = 100 + 55. Nothing appeared, nothing vanished. That is not luck — an automated test runs hundreds of random mint / sell / deposit / distribute / claim sequences and fails if the books ever stop balancing. (With the default Equal rule the same 100 would split 33 / 33 / 33 with 1 carried; the test suite checks exactly those numbers.)

Three twists, because they are the questions people ask next:

Carol sells card #3 to Dave between distribute and claim. Claim pays card #3's wallet, and Dave now controls it — so Dave gets the 57. Pending shares travel with the card. That is on purpose: what you are selling is the card and its wallet, with whatever is inside or on its way. Under the Level rule the level belongs to the card number, so #3 stays level 3 unless you change it; under the Tenure rule Dave's clock starts at zero.

The revenue token (the docs call it the reward token) refuses card #2's wallet. A claim call that includes card 2 fails as a whole, so claim for cards 1 and 3 in a call that leaves #2 out — that works. The 28 stays written down for #2 until the refusal is lifted. Nobody loses anything.

A card's weight is 0. Say the rule answers 0 for Alice this round. Bob and Carol split the 100 by 2 : 4 — 33 and 66 — and 1 is carried. If everyone answers 0, nothing is written down and the 100 waits.

Real numbers. Tokens count in units of 10-18, so "100 tokens" is a 1 followed by 20 zeros in units, and the carried leftover is always fewer units than the total number of shares in the round — invisible in practice, but still never lost.

#What the owner can and cannot do

There are three owner roles. They usually start as the same person (you, the deployer), but they are separate and can be handed over separately.

Owner of the card collection

Can Cannot
change the mint price for future cards (publicly logged; the new price applies to everyone) mint above the maximum, or on a private price — the only price is the public one
change the treasury address touch any card's wallet or what is in it
hand over ownership (two-step: the new owner must accept) destroy, freeze or move a member's card — no such button exists

Owner of the vault

Can Cannot
swap the weight rule for another one (publicly logged) withdraw anything — the only way out is claim, into card wallets
therefore change how future rounds are split change shares already written down
give up ownership for good, freezing the rule forever make a deposit disappear or pause claims

Owner of the weight rule (only if your rule has settings — the Level rule does)

Can Cannot
set the inputs (levels, points, tiers…) anything the vault owner cannot: no access to money

So the one thing members must trust is: whoever picks the rule picks the split. If that matters to your members, put the vault behind a multisig (several people must agree) or a timelock (changes are announced before they apply) — both are standard tools. And notice what is missing from every list: nobody — owner, factory, deployer — can upgrade a machine, drain the vault, or reach into a card's wallet. The full tables, with the tests that enforce them, are in Economics & trust.

#What you can build with this

The machines stay the same; you change the rule, the price and the story. Nine shapes, each with the rule it needs:

  1. Creator club. Fans mint a card; every month you deposit a slice of sponsorship or merch income. Rule: Equal — ships, nothing to write.
  2. Arcade guild. Your game server raises a player's level as they play; higher level, bigger share. Rule: Level — the example project, copy it.
  3. Loyalty club. The longer someone has held their card without selling, the bigger their share; buyers start over. Rule: Tenure — ships, pick the period.
  4. Shop membership sharing tokenised-stock rewards. A business that holds tokenised stocks deposits whatever ERC-20 rewards those pay out; pay-on-claim copes with transfer restrictions. Rule: Equal or Tenure. Read the legal note first.
  5. Co-op or collective treasury. A multisig owns the vault and awards contribution points; earnings split by points. Rule: an owner-set points table — the Level rule with a rename.
  6. Founders' bonus. The first 50 cards count double, with no settings at all (and, if the vault owner renounces, unchangeable). Rule: "if the card number is 50 or below, 2, otherwise 1" — three lines, no owner.
  7. Bronze / silver / gold tiers. Tiers assigned after mint by the owner, worth 1 / 3 / 10. Rule: an owner-set tier table.
  8. Season pass. Only members who checked in this season take part; everyone else answers 0 and is skipped that round, no money lost. Rule: custom, built on "0 means excluded".
  9. Collaborator royalty pool. A book's or album's on-chain royalties are deposited; shares are set once, then the vault owner gives up ownership so the split can never change. Rule: a fixed table, then renounce.

Every one of these is either "use a rule that ships" or "write one small function, test it, pass its address to the factory". Weight strategies is the how-to.

#Before it goes real

Four things that are true today and matter the moment real money is involved:

  • The chain addresses are not yet confirmed. The project talks to two helper contracts on Robinhood Chain (the ones that create card wallets). Their addresses in this repo come from notes, not from official documentation, and are tagged VERIFY. An address with nothing behind it is rejected at deploy time; an address that points at the wrong contract is not — it silently creates wallets nobody controls. Confirm them first: Deploying.
  • Distribute reads every card in one go. Fine for hundreds of cards, fine for a few thousand; beyond that it needs to be done in pages, and the code marks the spot.
  • The website shows no dollar value on purpose. It shows token balances. A price feed is wired in only once its official address is confirmed — never a hardcoded number.
  • The legal question is yours, not the code's. Tokenised stocks are restricted securities, and a card that entitles its holder to a share of revenue may itself be one, depending on your country and your users. This documentation is not legal advice; get counsel before a real deploy. Economics & trust says the same, more formally.

#Glossary

Word Plain meaning
Blockchain A shared, append-only notebook kept identical on thousands of computers
Wallet / address A keychain; the address is its public mailbox number
Private key The one secret that lets a wallet send; unrecoverable if lost
Transaction / gas One write to the notebook, and the small fee it costs
Token A notebook entry saying who owns what
ERC-20 The standard for interchangeable coins (tokenised stocks are ERC-20)
NFT / ERC-721 The standard for unique numbered items; here, the membership card
Mint Create a new token; here, buy a card
Max supply The most cards that can ever exist
Treasury The normal wallet that receives mint money
Smart contract A vending machine in the notebook: holds funds, follows fixed rules
Token-bound account (TBA, ERC-6551) The card's own wallet; obeys whoever holds the card
Registry The public helper contract that creates card wallets at fixed addresses
Vault The contract that receives deposited revenue and splits it
Deposit / distribute / claim Tokens in → shares written down → tokens paid to card wallets
Round One distribute call
Weight / strategy A card's number of shares, and the small contract that answers it
Dust The rounding leftover of a split; carried to the next round, never lost
Factory / clone The contract that stamps out a card collection + vault pair; each copy is a clone
Owner (two-step) Who can change settings; handing over needs the new owner to accept
Multisig / timelock An owner that is several people, or one whose changes are announced first
VERIFY tag A comment marking an address that is not yet confirmed against official sources
Decimals Tokens count in 10-18 units; "1 token" is written as 1 followed by 18 zeros
Reward token Any ERC-20 that is deposited as revenue and split by the vault
Foundry / anvil The developer toolkit, and its throwaway local blockchain for rehearsals

#Where to go next

Guides

#Getting started

From nothing to a project you can deploy, in five steps. Every step has one block to copy-paste and shows what you should see. Total time: about 10 minutes.

Words you will meet, once, right here:

  • NFT — a token with a unique id. Here it is a membership card.
  • Token-bound account (TBA, ERC-6551) — a wallet that belongs to an NFT. Whoever holds the card controls the wallet. Sell the card, the wallet goes with it.
  • Vault — the contract that receives revenue and splits it across every card, paying each share into that card's wallet.
  • Weight — how big a card's share is. You decide the rule; the vault only asks for a number.

The long version, with pictures and a worked example: Tutorial.

#Step 1 — Install the tools

You need Foundry (compiles and tests the contracts, runs a local chain), Node.js 20 or newer, and pnpm.

bash
curl -L https://foundry.paradigm.xyz | bash && foundryup   # installs forge, anvil, cast
npm install -g pnpm                                         # if you don't have it
forge --version && anvil --version && node -v && pnpm -v

You should see four version lines, for example:

code
forge Version: 1.8.1
anvil Version: 1.8.1
v22.12.0
11.0.0

Stuck? forge: command not found after installing → Foundry lives in ~/.foundry/bin. Run export PATH="$HOME/.foundry/bin:$PATH" (and add it to your shell profile).

#Step 2 — Create a project

bash
npx create-robinhood-app my-app

Want a website too? Add --fullstack. Want the optional ERC-20 game token? Add --with-token. Both are explained in CLI & scripts. No flags is the smallest, fastest start — you can always add the front end later.

You should see:

code
Done. Next:
  cd my-app
  pnpm verify        # forge fmt --check && forge test
  pnpm dryrun        # anvil: deploy → mint → deposit → distribute

Read CLAUDE.md before changing anything. Every chain-4663 address is VERIFY-tagged: confirm
them against official Robinhood Chain docs + explorer before a real deploy.

What just happened: the CLI cloned the template, removed everything that is not your project (the CLI itself, the build plan, the example), renamed it, and made a first git commit. No prompts — it is safe to run from a script or an AI agent.

#Step 3 — Run the tests

bash
cd my-app
pnpm verify

You should see every suite end in ok and a final line like:

code
Ran 9 test suites in 0.6s: 54 tests passed, 0 failed, 0 skipped (54 total tests)

(The exact count depends on your flags; --with-token adds two tests.) pnpm verify is the gate: formatting, unit tests, fuzz tests and invariant tests. CI runs exactly this, so green here means green there.

What just happened: among those tests, two invariants ran hundreds of random sequences of mints, transfers, deposits and distributions and checked that (a) nothing is ever minted or lost by the vault and (b) a card's wallet always answers to the card's current owner. Those two are the reason you can trust the rest.

#Step 4 — See it work locally

bash
pnpm dryrun

This starts a local chain (anvil), deploys every contract, mints one membership, deposits 1000 reward tokens into the vault, distributes them, and pays the share into the membership's wallet.

You should see, at the end:

code
  minted tokenId 1 tba 0x590779afeD62ecEDF9a7480bD297CA97d1F80Cd8
  tba reward balance 1000000000000000000000
  vault carried dust 0
{
  "chainId": 31337,
  "factory": "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9",
  "nft": "0x6591BC932234ae32A2bE0C2494e1A01BEC010Fce",
  "vault": "0x2F3409425e0228aDA6352B9c0DF5BCe25E967D5A",
  ...
}

In plain words, line by line:

  1. A member joined. Card #1 was minted and got its own wallet (the tba address).
  2. Revenue came in and was split. 1000 tokens were deposited; there is one card, so it earned all of them: 1000000000000000000000 is 1000 × 10¹⁸ (tokens have 18 decimals).
  3. Nothing was lost. carried dust 0 — when a split does not divide evenly, the remainder stays in the vault for the next round instead of disappearing.
  4. Addresses. The JSON is deployments/31337.json; a real deploy writes deployments/4663.json, and the front end reads it.

Stuck? Address already in use → another anvil is running: pkill anvil and retry.

#Step 5 — Make it yours

Three things make a project yours: the rule for splitting revenue, the name and price, and the chain.

The rule. Create contracts/src/strategies/LevelWeightStrategy.sol:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {IWeightStrategy} from "./IWeightStrategy.sol";

/// Weight = 1 + level. Level 3 earns 4× what level 0 earns.
contract LevelWeightStrategy is IWeightStrategy {
    mapping(uint256 tokenId => uint256) public level;

    function setLevel(uint256 tokenId, uint256 newLevel) external {
        level[tokenId] = newLevel; // add access control before shipping — see the guide
    }

    function weightOf(address, uint256 tokenId) external view returns (uint256) {
        return 1 + level[tokenId];
    }
}

That is the whole game surface. The vault calls weightOf() for every card at distribution time and never learns what a level is. Write a test first (copy contracts/test/strategies/TenureWeightStrategy.t.sol), then pnpm verify. Full guide: Weight strategies.

Name and price. Deploy.s.sol reads them from your shell — nothing to edit:

bash
export PROJECT_NAME="My Club" PROJECT_SYMBOL="CLUB" MINT_PRICE=20000000000000000 MAX_SUPPLY=500

The chain. For a local rehearsal, pnpm dryrun already used these. For Robinhood Chain:

bash
export ROBINHOOD_RPC_URL=<official RPC URL>
cd contracts
forge script script/Deploy.s.sol --rpc-url robinhood --broadcast --private-key $DEPLOYER_KEY

STOP — VERIFY first. Every chain-4663 address in contracts/src/Constants.sol (the ERC-6551 registry and account implementation) comes from project notes and is tagged // VERIFY:. Confirm each against the official Robinhood Chain docs and the block explorer before broadcasting. A wrong address does not revert — it silently creates wallets nobody controls. Checklist: Deploying.

You should see ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. and a new deployments/4663.json.

#Stuck?

Symptom Fix
forge: command not found export PATH="$HOME/.foundry/bin:$PATH"
pnpm verify fails on forge fmt --check only run forge fmt twice in contracts/ (it is not idempotent on long multi-line ifs), then retry
Address already in use from pnpm dryrun pkill anvil
ERR_PNPM_... about ignored build scripts (--fullstack) already handled in pnpm-workspace.yaml (allowBuilds); make sure you are on pnpm ≥ 10
RegistryNotDeployed(0x…) on a real chain the ERC-6551 registry address is wrong for this chain — VERIFY it, or override ERC6551_REGISTRY
failed to open file ../deployments/… the deployments/ directory must exist (it ships with a .gitkeep); do not delete it

#Where to go next

Guides

#Weight strategies

The vault splits every distribution pro-rata by weight. Weight comes from one interface, and that interface is the only place game logic is allowed to live.

solidity
interface IWeightStrategy {
    function weightOf(address nft, uint256 tokenId) external view returns (uint256 weight);
}

At distribute() time the vault calls weightOf(nft, id) for every minted token (1..totalSupply), sums the results, and allocates amount × weight / totalWeight to each token. A weight of 0 means "no share this round". If every weight is 0 the round is a no-op and the deposit waits for the next one.

#The two that ship

EqualWeightStrategy — the default. weightOf returns 1 for everything: every member earns the same. It is what Deploy.s.sol wires in.

TenureWeightStrategy — a worked reference. weight = 1 + (now − heldSince) / period, where MembershipNFT.heldSince(tokenId) is the timestamp the current owner acquired the token — it resets on every transfer, so a buyer starts at weight 1. It exists to prove the point: it is swapped into the vault with zero vault changes (test_VaultUsesTenureWithoutChanges).

solidity
function weightOf(address nft, uint256 tokenId) external view returns (uint256) {
    return 1 + (block.timestamp - MembershipNFT(nft).heldSince(tokenId)) / period;
}

#Write your own

  1. Copy TenureWeightStrategy.sol next to it and rename.
  2. Decide the inputs. Anything on-chain and view-readable: the NFT's heldSince, your own storage (levels, XP, wins), another contract's balances. weightOf is view, so it cannot write.
  3. Decide who can change the inputs. If a server or the guild owner sets levels, gate the setter (Ownable). Remember the trust consequence: whoever controls weights controls the split.
  4. Write the test first — copy contracts/test/strategies/TenureWeightStrategy.t.sol. The shared Fixture gives you a minted-ready NFT, a vault, a mock reward token and helpers; the pattern is vault.setStrategy(address(yours)) in setUp, then mint → deposit → distribute → claim and assert TBA balances. Add a fuzz test for the weight formula.
  5. Wire it in your deploy script: pass its address as Factory.Params.strategy, or call vault.setStrategy() later (owner only; emits StrategyUpdated).

A complete, tested example is Arcade Guild: an owner-set level mapping with weight = 1 + level.

#Rules of thumb

  • Keep weightOf cheap. It runs once per token per distribution, in one transaction. A mapping read is fine; a loop over other tokens is not. Past a few thousand tokens distribute() itself needs pagination — it is marked in the source.
  • Never revert. A reverting weightOf bricks every distribution until the strategy is swapped. Return 0 for "not eligible" instead.
  • Watch the sum. totalWeight is a uint256 sum of every weight; keep individual weights far below 2^200 and overflow is impossible in practice. The vault uses Math.mulDiv, so amount × weight never overflows either.
  • Do not read the reward token. Weight based on how much a member already earned creates a feedback loop. Weight should come from the game, not from the money.

#What a malicious strategy can and cannot do

The owner picks the strategy, so the owner picks the weights. That is a stated trust assumption, and its blast radius is bounded:

A bad strategy can… It cannot…
give all weight to one token (skew a round) move tokens out of the vault — only claim() does, into TBAs
return 0 for everyone (freeze deposits in the vault, still distributable later) take undistributed balances; nothing is ever burned or swept
revert and block distribute() until swapped touch already-claimable allocations from earlier rounds

If that matters to your members, make the vault owner a multisig or a timelock: the strategy change becomes visible before it applies. See Economics & trust.

Guides

#Deploying

Two targets: a local chain for rehearsal (anvil, chain id 31337) and Robinhood Chain (chain id 4663). The same script serves both.

#Local: pnpm dryrun

bash
pnpm dryrun              # blank local chain
pnpm dryrun <rpc-url>    # fork Robinhood Chain state instead (VERIFY the URL first)

contracts/script/dryrun.sh does, in order:

  1. starts anvil (killed on exit);
  2. runs Deploy.s.sol with anvil's account 0 — on chain 31337 it deploys the ERC-6551 registry and account implementation itself, because a blank chain has neither;
  3. runs DryRun.s.sol: mint → deploy a mock reward token → deposit 1000 → distribute → claim, and prints what happened;
  4. prints deployments/31337.json.

deployments/31337.json is git-ignored; it is a rehearsal artefact.

#Robinhood Chain: Deploy.s.sol

bash
export ROBINHOOD_RPC_URL=<official RPC URL>          # read by foundry.toml as `--rpc-url robinhood`
export PROJECT_NAME="My Club" PROJECT_SYMBOL="CLUB"   # optional — see the table
cd contracts
forge script script/Deploy.s.sol --rpc-url robinhood --broadcast --private-key $DEPLOYER_KEY

What it deploys, in one broadcast:

Step Contract Notes
1 MembershipNFT implementation constructor calls _disableInitializers(); never used directly
2 RevenueVault implementation same
3 Factory(nftImpl, vaultImpl, registry, accountImpl) immutable; holds no powers
4 EqualWeightStrategy the default weight
5 factory.deploy(SALT, Params{…}) clones + initializes NFT and vault, owned by the broadcaster

Before step 5 the script checks the registry has code on this chain and reverts with RegistryNotDeployed(address) otherwise — the only guard between you and a wrong constant.

#Environment

All optional; read with vm.envOr.

Variable Default Meaning
PROJECT_NAME Membership ERC-721 name
PROJECT_SYMBOL MBR ERC-721 symbol
TREASURY broadcaster (msg.sender) receives mint proceeds; must accept ETH (probed at init)
MINT_PRICE 10000000000000000 (0.01 ether) wei per mint; excess is refunded
MAX_SUPPLY 1000 hard cap; distribute() is O(supply)
SALT "membership" (as bytes32) clone addresses depend on (broadcaster, SALT) only
ERC6551_REGISTRY Constants.ERC6551_REGISTRY override the registry — VERIFY
ERC6551_ACCOUNT_IMPL Constants.ERC6551_ACCOUNT_IMPL override the account implementation — VERIFY

Predict the addresses before broadcasting: cast call <factory> "predict(address,bytes32)" <you> <salt> — or just deploy; the same (deployer, SALT) on the same factory reverts on a second attempt.

#Verifying source

foundry.toml has an [etherscan] entry named robinhood pointing at Blockscout, driven by ROBINHOOD_BLOCKSCOUT_API_URL and BLOCKSCOUT_API_KEY. Add --verify to the command above once those are set.

#deployments/<chainId>.json

Written by _writeDeployment after the broadcast, replacing the file wholesale:

json
{
  "chainId": 4663,
  "registry": "0x…",
  "accountImpl": "0x…",
  "factory": "0x…",
  "nftImpl": "0x…",
  "vaultImpl": "0x…",
  "equalWeightStrategy": "0x…",
  "nft": "0x…",
  "vault": "0x…"
}

deployments/4663.json is tracked — it is the front end's source of addresses (apps/web/lib/robinhood.ts imports it at build time). The committed placeholder is all zeros with a _comment key that disappears on the first real deploy; commit the real file after deploying. apps/web only reads nft, vault and chainId.

#The VERIFY checklist

Every chain-4663 value in this repo came from project notes, not official sources, and is tagged. Before the first real broadcast:

  • contracts/src/Constants.solCHAIN_ID, ERC6551_REGISTRY, ERC6551_ACCOUNT_IMPL confirmed on the Robinhood Chain block explorer (the registry must have code; cast code <addr> --rpc-url robinhood)
  • apps/web/lib/robinhood.ts — the same two addresses, plus chain name / native currency
  • foundry.tomlROBINHOOD_RPC_URL, ROBINHOOD_BLOCKSCOUT_API_URL from official docs
  • apps/web/.envNEXT_PUBLIC_ROBINHOOD_RPC_URL, NEXT_PUBLIC_ROBINHOOD_EXPLORER_URL, NEXT_PUBLIC_REWARD_TOKENS
  • Constants.STOCK_TOKEN_EXAMPLE and UNISWAP_ROUTER are address(0) placeholders — fill only from official sources or leave unused
  • Rehearsed with pnpm dryrun <rpc-url> against a fork
  • Read the legal note in Economics & trust

Then delete the VERIFY tags — they are the to-do list.

Guides

#Front end

Scaffold with --fullstack and you get apps/web: Next 15 (App Router) + wagmi 2 + viem 2 + TanStack Query, pinned to Robinhood Chain and nothing else. It is deliberately small — one page, three components, plain CSS — so it reads as a reference, not a product.

code
apps/web/
├── app/
│   ├── layout.tsx        # metadata (the CLI rewrites the title), wraps Providers
│   ├── page.tsx          # connect / switch-chain UI + the three components
│   ├── providers.tsx     # WagmiProvider + QueryClientProvider
│   └── globals.css
├── components/
│   ├── Mint.tsx          # price, supply, sold-out state, mint button
│   ├── Holdings.tsx      # your tokens, each TBA's balance per reward token, claim button
│   └── Distribute.tsx    # pending `distributable` per reward token, permissionless distribute
├── lib/
│   ├── robinhood.ts      # THE address file — chain, registry, account impl, NFT, vault, reward tokens
│   ├── abi.ts            # minimal hand-written ABIs (keep in sync with contracts/src)
│   └── wagmi.ts          # config: one chain, injected connector
└── .env.example

#Run it

bash
pnpm install                 # once, at the repo root
cp apps/web/.env.example apps/web/.env
pnpm -C apps/web dev         # http://localhost:3000

Until deployments/4663.json holds real addresses the page shows a Not deployed yet banner and hides the components — IS_DEPLOYED in lib/robinhood.ts is chainId === 4663 && nft !== 0x0.

#Environment

Variable Meaning
NEXT_PUBLIC_ROBINHOOD_RPC_URL RPC for chain 4663 — VERIFY against official docs
NEXT_PUBLIC_ROBINHOOD_EXPLORER_URL Blockscout base URL, used for links — VERIFY
NEXT_PUBLIC_REWARD_TOKENS comma-separated ERC-20 addresses the vault distributes; invalid entries are dropped

All three are read only in lib/robinhood.ts. No component holds an address literal — that is a convention (CLAUDE.md rule 4), and the reason the file exists.

#How it reads the chain

  • Addresses come from a static import: import deployments from "../../../deployments/4663.json". That is a build-time import, not a fetch — after a redeploy, rebuild the app.
  • Your holdingsHoldings.tsx scans ownerOf(1..totalSupply) to find your ids, then tokenBoundAccount(id) for each, then balanceOf(tba) on every reward token and claimable(token, id) on the vault. It is O(supply) reads on purpose; the source marks where to switch to indexing Minted/Transfer events when supply grows.
  • Value in USD is not shown. The spot where it belongs carries a VERIFY comment: wire the official Robinhood Chain price feed once its address is confirmed. Never trust a hardcoded price.

#Conventions

  • One chain. lib/wagmi.ts configures robinhoodChain only; the UI offers switch to 4663, never a chain picker.
  • Read through the ABIs in lib/abi.ts. They are hand-written and minimal — when you add a function to a contract, add it there too.
  • The gate for this app is pnpm -C apps/web tsc --noEmit && pnpm -C apps/web lint, folded into pnpm verify when apps/web/package.json exists.
  • Import injected from wagmi, not wagmi/connectors — the latter pulls a dependency chain that breaks next build.
Guides

#Example: Arcade Guild

A complete project built on the boilerplate without copying it. Members mint a guild card; the guild owner (think: the game server) raises a member's level; revenue deposited into the vault is split by weight = 1 + level. The NFT, the vault and the factory are the boilerplate's own contracts, imported straight from contracts/. The only new contract is the strategy.

It lives at examples/arcade-guild/ and is pruned from scaffolded projects — it is documentation you can run.

#Run it

bash
cd examples/arcade-guild
forge test

You should see:

code
Ran 3 tests for test/LevelWeightStrategy.t.sol:LevelWeightStrategyTest
[PASS] testFuzz_WeightIsOnePlusLevel(uint256,uint256) (runs: 512, …)
[PASS] test_DistributePaysByLevel() (gas: 980691)
[PASS] test_OnlyOwnerSetsLevel() (gas: 36945)
Suite result: ok. 3 passed; 0 failed; 0 skipped

Deploy it to a local chain and read the name back:

bash
anvil &
forge script script/Deploy.s.sol --sig "runArcade()" \
  --rpc-url http://127.0.0.1:8545 --broadcast \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80   # anvil account 0
cast call $(node -e 'console.log(require("./deployments/31337.json").nft)') "name()(string)" --rpc-url http://127.0.0.1:8545
# "Arcade Guild"

#Every file, explained

code
examples/arcade-guild/
├── foundry.toml                    # libs → ../../contracts/lib, allow_paths → ../../contracts
├── remappings.txt                  # robinhood/, robinhood-test/, robinhood-script/ → the parent
├── src/LevelWeightStrategy.sol     # the game
├── test/LevelWeightStrategy.t.sol  # reuses the parent's Fixture
├── script/Deploy.s.sol             # inherits the parent's Deploy
└── README.md

#src/LevelWeightStrategy.sol — the game

solidity
contract LevelWeightStrategy is IWeightStrategy, Ownable {
    event LevelSet(address indexed nft, uint256 indexed tokenId, uint256 level);

    mapping(address nft => mapping(uint256 tokenId => uint256)) public levelOf;

    constructor(address owner_) Ownable(owner_) {}

    function setLevel(address nft, uint256 tokenId, uint256 level) external onlyOwner {
        levelOf[nft][tokenId] = level;
        emit LevelSet(nft, tokenId, level);
    }

    function weightOf(address nft, uint256 tokenId) external view returns (uint256) {
        return 1 + levelOf[nft][tokenId];
    }
}

Three decisions worth copying:

  • Keyed by (nft, tokenId), so one strategy can serve several clones from the same factory.
  • onlyOwner on the setter. Levels are money: whoever sets them sets the split. The event is what an indexer or the front end reads.
  • weightOf is one mapping read — it runs for every card, every distribution.

#test/LevelWeightStrategy.t.sol — reusing the fixture

solidity
import {Fixture} from "robinhood-test/utils/Fixture.sol";

contract LevelWeightStrategyTest is Fixture {
    function setUp() public override {
        super.setUp();                                   // chain 4663, registry, NFT + vault clones
        levels = new LevelWeightStrategy(address(this));
        vault.setStrategy(address(levels));              // the test contract owns the vault
    }

    function test_DistributePaysByLevel() public {
        uint256 a = _mint(alice);
        uint256 b = _mint(bob);
        levels.setLevel(address(nft), a, 3);             // weight 4; bob stays weight 1
        _deposit(token, 500);
        vault.distribute(address(token));
        _claimAll(address(token));
        assertEq(token.balanceOf(_tba(a)), 400);
        assertEq(token.balanceOf(_tba(b)), 100);
    }
}

Fixture is the boilerplate's own test harness (contracts/test/utils/Fixture.sol): it etches the ERC-6551 registry at the canonical address, clones one NFT + vault pair, and provides _mint, _deposit, _claimAll, _tba. Your project's tests get all of it for one import.

#script/Deploy.s.sol — reusing the deploy

solidity
contract DeployArcadeGuild is Deploy {
    function runArcade() external {
        …
        (Factory factory,) = deployCore(registry, accountImpl);   // impls + Factory, from the parent
        LevelWeightStrategy levels = new LevelWeightStrategy(msg.sender);
        (address nft, address vault) = factory.deploy(
            bytes32("arcade-guild"),
            Factory.Params({ name: "Arcade Guild", symbol: "ARCD", treasury: msg.sender,
                             mintPrice: 0.01 ether, maxSupply: 1000, strategy: address(levels) })
        );
        …
    }
}

It inherits Deploy rather than instantiating it: under --broadcast the CREATEs must originate from the script contract to be recorded, and Deploy.run() is external and not virtual, hence the new entry point and --sig "runArcade()". It writes its own deployments/<chainId>.json inside the example directory (vm.createDir first — writeJson does not create directories).

#foundry.toml + remappings.txt — the plumbing

toml
libs = ["../../contracts/lib"]
allow_paths = ["../../contracts"]   # solc may read the parent's src/test/script
code
robinhood/=../../contracts/src/
robinhood-test/=../../contracts/test/
robinhood-script/=../../contracts/script/
@openzeppelin/contracts/=../../contracts/lib/openzeppelin-contracts/contracts/   # pinned on purpose

@openzeppelin/contracts/ is written out because lib/reference (the ERC-6551 reference implementation) vendors an older OpenZeppelin that forge's auto-detection would otherwise pick up. Only the example's own transitive closure is compiled; the parent's out/ is untouched.

#Adapt it

  • Replace setLevel with whatever your game emits — XP, wins, staked tokens, a signature from your server. Only weightOf() is read by the vault, and only at distribute() time.
  • Keep the shape of the test: mint two members, make them differ, deposit a round number, assert the TBA balances. It fails loudly the moment the formula drifts.
  • The owner controls levels, so the owner controls the split — see what a malicious strategy can and cannot do. A multisig owner is the usual answer.
  • To turn this into your repo instead of an example: scaffold with create-robinhood-app, copy src/LevelWeightStrategy.sol into contracts/src/strategies/, the test into contracts/test/strategies/, change the imports to relative paths, and pass the strategy in Deploy.s.sol. The remappings exist only because this example lives outside contracts/.
Concepts

#Architecture

Three contracts, one interface, one deploy path. Everything else is tests.

code
                 Factory.deploy(salt, Params)
                          │  cloneDeterministic ×2, initialize ×2, transfer ownership
          ┌───────────────┴────────────────┐
          ▼                                ▼
   MembershipNFT (clone)            RevenueVault (clone)
   ERC-721 + Ownable2Step           Ownable2Step + ReentrancyGuard
          │                                │
   mint() ─┤                               │ weightOf(nft, id) ──▶ IWeightStrategy
          │  registry.createAccount(...)   │                        (Equal / Tenure / yours)
          ▼                                │
   ERC-6551 account per token ◀── claim() pays each share here
          ▲
   owner() resolves through nft.ownerOf(tokenId)

#Mint → token-bound account

MembershipNFT.mint() is payable. It checks price and supply, mints tokenId = ++totalSupply to the caller, then asks the ERC-6551 registry to create an account for (accountImpl, salt 0, chainId, nft, tokenId). The registry deploys a minimal proxy at an address that is a pure function of those five values — so tokenBoundAccount(id) can be computed before or after the fact and never changes.

Control of that account is not stored anywhere. The account implementation answers owner() by calling nft.ownerOf(tokenId) at that moment. Transfer the NFT and the new holder controls the wallet; no hook, no sync, nothing to get out of date. The invariant invariant_TBAControlFollowsNFT checks exactly this across random mint/transfer sequences.

Proceeds: price goes to treasury, the excess back to the minter, both by low-level call. The treasury is probed at initialize/setTreasury with an empty zero-value call so a contract that rejects ETH cannot brick minting later. Nothing goes to the vault — rule 1, and a test (test_MintProceedsLandAtTreasury_VaultUntouched) that fails if it ever does.

Tokens are never burned, so ids are exactly 1..totalSupply; the vault relies on that.

heldSince[tokenId] is stamped in _update on every mint and transfer. It is the only strategy-facing state the NFT keeps, and it is what TenureWeightStrategy reads.

#Deposit → distribute → claim

Three functions, three phases, each permissionless except where noted:

Phase Who What changes
depositRevenue(token, amount) anyone with an approval distributable[token] += receivedreceived is the actual balance delta, so fee-on-transfer tokens cannot inflate it. Emits RevenueDeposited.
distribute(token) anyone reads weightOf for ids 1..totalSupply, allocates mulDiv(amount, w_i, Σw) into claimable[token][id], leaves the remainder in distributable (dust). Emits Allocated per id and one Distributed. Moves no tokens.
claim(token, ids[]) anyone for each id, zeroes claimable then safeTransfers it to tokenBoundAccount(id). Emits Claimed.

Why allocate and pay separately? Tokenised stocks tend to be transfer-restricted. If payment happened inside distribute(), one blocklisted recipient would revert the round for everyone. With pull-based claims, that recipient's claim reverts and nobody else notices (test_BlockedRecipientDoesNotBlockOthers).

Why is there no receive()? So ETH cannot land in the vault by accident, and so there is exactly one entry point for revenue. distributable is credited only in depositRevenue.

Zero total weight is a no-op that emits Distributed(token, 0, 0, amount) — the deposit waits. NothingToDistribute reverts when distributable is 0.

distribute() is O(totalSupply). It is marked in the source: paginate past a few thousand tokens.

The conservation invariant ties it together: over random mints, transfers, weight changes, deposits, distributions and claims across two reward tokens, Σ claimed + Σ claimable + distributable == Σ deposited, and the vault physically holds every unclaimed unit.

#Factory → clones

Factory is immutable and holds two implementation addresses plus the registry and account implementation. deploy(salt, Params):

  1. derives nftSalt = keccak256(deployer, salt, "nft") and vaultSalt = keccak256(deployer, salt, "vault") — namespaced per deployer, so two projects can both use salt "membership";
  2. Clones.cloneDeterministic the NFT and the vault (EIP-1167 minimal proxies, ~45 bytes each);
  3. initializes both with msg.sender as owner;
  4. emits ProjectDeployed(deployer, salt, nft, vault, strategy).

predict(deployer, salt) returns the same two addresses without deploying. The same (deployer, salt) twice reverts (address already has code). Two projects from the same factory share bytecode and nothing else — invariant_ProjectsAreStateIsolated.

The implementations are deployed once and disabled: their constructors call _disableInitializers(), so nobody can initialize the implementation itself, and clones can be initialized exactly once. There is no proxy admin, no upgrade path, no selfdestruct.

#Events as the read model

Every state change emits: Minted, TreasuryUpdated, MintPriceUpdated, RevenueDeposited, Distributed, Allocated, Claimed, StrategyUpdated, ProjectDeployed. The front end and any indexer read these, not storage layouts. The full list with parameters is in Contracts.

#Where things live

Concern File
Chain constants (VERIFY) contracts/src/Constants.sol, apps/web/lib/robinhood.ts
Game logic contracts/src/strategies/* — nowhere else
Money math contracts/src/RevenueVault.sol — nowhere else
Deploy + local rehearsal contracts/script/Deploy.s.sol, DryRun.s.sol, dryrun.sh
The spec contracts/test/** — see Testing
Concepts

#Economics & trust

What a holder can rely on, what the owner can and cannot do, and why the code is shaped the way it is. If you change the economic model, this is the page that has to change first.

#The model in one paragraph

Someone deploys a project: an NFT collection plus a vault. People mint memberships and pay the mint price to the project's treasury. Separately, revenue — ERC-20 tokens, typically tokenised stocks on Robinhood Chain — is deposited into the vault by whoever earns it. Anyone can then call distribute(), which splits that deposit across every current membership according to a weight the project chose, and anyone can call claim() to push each share into the membership's own wallet. Sell the membership and the wallet, with everything in it, goes to the buyer.

It is a revenue share. It is not a recycled-deposit scheme: mint money never comes back out of the vault as "revenue", because mint money never enters the vault.

#The five golden rules, and why

These are the architectural invariants from CLAUDE.md. Each has at least one test that fails if it is broken.

1. The vault distributes only what is explicitly deposited via depositRevenue(). Mint proceeds go to the treasury. There is no receive(), no fee hook, no path from mint() to the vault. Why: the moment mint fees or sell taxes flow into the vault, early members are paid with later members' entry money and the product becomes a different thing with a different legal character. Tests: test_MintProceedsLandAtTreasury_VaultUntouched, test_MintNeverTouchesVault, test_MintProceedsNeverReachFactoryOrVault, testFuzz_TransferHasNoTax (GameToken).

2. Game mechanics never touch money math. Weight arrives through IWeightStrategy.weightOf() and nothing else. The vault's tests run against a mock strategy that knows nothing about levels or tenure. Why: the money path stays small enough to audit and invariant-test once; the game can change every week.

3. No market-making, multi-wallet or volume-simulation tooling. Not in contracts, scripts or the front end, permanently. Why: it is out of scope for a revenue share, and shipping it in a template invites misuse under your name.

4. Never hardcode an unverified address. Every chain-4663 constant lives in two files and carries a VERIFY tag until confirmed against official docs and the explorer. Why: a wrong registry address does not revert — it silently creates accounts nobody controls.

5. Trustlessness over convenience. Clones are non-upgradeable; ownership is Ownable2Step; no function lets an owner pull tokens out of a member's wallet or out of undistributed vault balances. Why: holders should be able to read the code once and know the rules cannot change under them.

#What the owner can and cannot do

The NFT owner (the deployer, or whoever they hand it to via the two-step transfer):

Can Cannot
change mintPrice (future mints only, public via MintPriceUpdated) mint above maxSupply, or at any price other than the public one
change treasury (must accept ETH) touch any token-bound account or its contents
burn, freeze or transfer members' tokens; there is no such function

The vault owner:

Can Cannot
swap the IWeightStrategy (emits StrategyUpdated) withdraw anything — the only outflow is claim() into TBAs
therefore skew a future round's split change allocations already in claimable
pick a strategy that returns 0 for all (round no-ops, deposit stays distributable) make deposits disappear

The stated trust assumption is therefore: the vault owner chooses the weights. If that matters to your members, put a multisig or a timelock behind the owner so a strategy change is visible before it applies.

Nobody — owner, factory, deployer — can upgrade a clone or drain the vault.

#Rounding, dust and edge cases

  • Dust. amount × w_i / Σw rounds down per member. The remainder stays in distributable and is included in the next round. Over many rounds it converges to zero loss; it is never sent to the owner or burned. Invariant: Σ claimed + Σ claimable + carried == Σ deposited.
  • Zero weight is "no share this round". Zero total weight is a no-op; the deposit waits.
  • Blocklisted recipients. Payment is pull-based. A token that refuses one wallet makes only that wallet's claim revert; the allocation stays in claimable until the block lifts. Everyone else claims normally.
  • Fee-on-transfer tokens. The vault credits the balance delta, not the requested amount.
  • Re-entrancy. depositRevenue, distribute and claim are nonReentrant; a hostile token that re-enters on transfer is part of the test suite.
  • Transfers mid-round. Allocations are per tokenId, not per address, and are paid into the token's wallet. Whoever holds the token at claim time — or later — controls the money. Selling after distribute() but before claim() sells the pending share too; that is by design.
  • Scale. distribute() reads every token's weight in one transaction. Past a few thousand tokens it needs pagination; the code marks the spot.

Non-code, but load-bearing. The reward tokens this template targets — tokenised stocks — are restricted securities. A membership token that entitles its holder to a share of revenue may itself be one. That is a question about your token, your jurisdiction and your users; it is separate from whether this code is correct, and this documentation is not advice. Get counsel before a real deploy.

Reference

#Contracts reference

Every public surface, verbatim from contracts/src. Solidity ^0.8.24, compiled with 0.8.28, OpenZeppelin 5.x. Custom errors everywhere; no revert strings.

#MembershipNFT

ERC721Upgradeable, Ownable2StepUpgradeable. Deployed as an EIP-1167 clone; initialize() replaces the constructor. Every token owns an ERC-6551 account. Mint proceeds go to treasury, never to a vault.

#Functions

Signature Access Behaviour
initialize(string name_, string symbol_, address owner_, address treasury_, uint256 mintPrice_, uint256 maxSupply_, address registry_, address accountImpl_) once (initializer) requires registry_ and accountImpl_ to have code and treasury_ to accept ETH
mint() payable → (uint256 tokenId, address tba) anyone next id, _safeMint, creates the TBA via the registry, sends price to treasury, refunds excess
tokenBoundAccount(uint256 tokenId) view → address anyone deterministic ERC-6551 address (registry.account(accountImpl, 0, chainid, this, tokenId))
setTreasury(address) owner must accept ETH; emits TreasuryUpdated
setMintPrice(uint256) owner emits MintPriceUpdated
treasury(), mintPrice(), maxSupply(), registry(), accountImpl(), totalSupply() view public state
heldSince(uint256 tokenId) view → uint256 view timestamp the current owner acquired the token; reset on every transfer
ERC-721 standard + Ownable2Step (transferOwnership, acceptOwnership, renounceOwnership) inherited

totalSupply only grows; ids are exactly 1..totalSupply.

#Events

solidity
event Minted(uint256 indexed tokenId, address indexed to, address indexed tba);
event TreasuryUpdated(address indexed treasury);
event MintPriceUpdated(uint256 mintPrice);

#Errors

MaxSupplyReached(), InsufficientPayment(uint256 sent, uint256 required), ZeroAddress(), NotAContract(address account), TreasuryNotPayable(address treasury), EthTransferFailed().

#RevenueVault

Ownable2StepUpgradeable, ReentrancyGuard. Pro-rata distribution of explicitly deposited ERC-20 revenue to current holders. No receive(). Uses SafeERC20 and Math.mulDiv.

#Functions

Signature Access Behaviour
initialize(address owner_, address nft_, address strategy_) once both addresses must have code
depositRevenue(address token, uint256 amount) anyone, nonReentrant safeTransferFrom caller; credits the actual balance delta; ZeroAmount if amount == 0
distribute(address token) anyone, nonReentrant reads weightOf for ids 1..totalSupply; allocates mulDiv(amount, w, Σw) per id into claimable; remainder stays in distributable; zero Σw → no-op; NothingToDistribute if nothing pending
claim(address token, uint256[] tokenIds) anyone, nonReentrant zeroes claimable[token][id] then safeTransfers it to nft.tokenBoundAccount(id); skips zero balances
setStrategy(address) owner must have code; emits StrategyUpdated
nft(), strategy() view wired contracts
distributable(address token) view → uint256 view deposited-but-undistributed (includes carried dust)
claimable(address token, uint256 tokenId) view → uint256 view allocated-but-unclaimed

#Events

solidity
event RevenueDeposited(address indexed token, address indexed from, uint256 amount);
event Distributed(address indexed token, uint256 amount, uint256 totalWeight, uint256 carried);
event Allocated(address indexed token, uint256 indexed tokenId, uint256 amount);
event Claimed(address indexed token, uint256 indexed tokenId, address indexed tba, uint256 amount);
event StrategyUpdated(address indexed strategy);

#Errors

ZeroAddress(), NotAContract(address account), ZeroAmount(), NothingToDistribute().

#Factory

Immutable. Deploys a MembershipNFT + RevenueVault pair as deterministic EIP-1167 clones, wired and owned by the caller in one transaction. Holds no powers over anything it deploys.

solidity
struct Params {
    string name;
    string symbol;
    address treasury;
    uint256 mintPrice;
    uint256 maxSupply;
    address strategy;
}
Signature Behaviour
constructor(address nftImpl_, address vaultImpl_, address registry_, address accountImpl_) all non-zero or ZeroAddress()
deploy(bytes32 salt, Params p) → (address nft, address vault) clones at keccak256(msg.sender, salt, "nft"/"vault"), initializes both with msg.sender as owner, emits ProjectDeployed
predict(address deployer, bytes32 salt) view → (address nft, address vault) the addresses deploy would produce for that deployer
nftImpl(), vaultImpl(), registry(), accountImpl() immutables
solidity
event ProjectDeployed(address indexed deployer, bytes32 indexed salt, address nft, address vault, address strategy);

Same (deployer, salt) twice reverts (target already has code). Different deployers with the same salt never collide.

#IWeightStrategy

solidity
interface IWeightStrategy {
    function weightOf(address nft, uint256 tokenId) external view returns (uint256 weight);
}

The vault's only game-facing dependency. See Weight strategies.

#EqualWeightStrategy

weightOf(address, uint256) pure → 1. The default.

#TenureWeightStrategy

constructor(uint256 period_) — reverts ZeroPeriod() on 0. period() is immutable. weightOf(nft, tokenId) view → 1 + (block.timestamp − MembershipNFT(nft).heldSince(tokenId)) / period.

#GameToken (optional, --with-token)

ERC20. constructor(string name_, string symbol_, uint256 supply, address recipient) mints the whole supply once to recipient. No tax, no hooks, no vault wiring — a plain token you may use as a reward token or in-game currency. Tested to have no transfer tax (testFuzz_TransferHasNoTax).

#Constants (library)

The single source of chain-4663 constants; mirrored by apps/web/lib/robinhood.ts. Every value is VERIFY-tagged until confirmed against official Robinhood Chain docs and the explorer.

Constant Value in repo Status
CHAIN_ID 4663 VERIFY
ERC6551_REGISTRY 0x000000006551c19487814612e58FE06813775758 (canonical registry address) VERIFY it is deployed on 4663
ERC6551_ACCOUNT_IMPL 0x41C8f39463A868d3A88af00cd0fe7102F30E44eC (Tokenbound AccountV3) VERIFY it is deployed on 4663
STOCK_TOKEN_EXAMPLE address(0) placeholder
UNISWAP_ROUTER address(0) placeholder

#Deploy scripts

Deploy.s.solrun() deploys implementations + Factory + EqualWeightStrategy and one project, writes deployments/<chainId>.json. deployCore(address registry, address accountImpl) public → (Factory, EqualWeightStrategy) is reusable (the integration tests and the Arcade Guild example call it). Reverts RegistryNotDeployed(address) when the registry has no code. On chain 31337 it deploys the ERC-6551 pieces itself.

DryRun.s.sol — reads deployments/<chainId>.json, mints, deploys a MockRewardToken, deposits 1000e18, distributes, claims, logs the TBA balance and carried dust. Anvil only.

Reference

#CLI & scripts

#create-robinhood-app

code
npx create-robinhood-app <name> [--bare | --fullstack] [--with-token] [--template <git-url-or-path>]

  --bare        contracts only (default)
  --fullstack   contracts + apps/web (Next 15 + wagmi/viem)
  --with-token  keep the optional plain GameToken ERC-20
  --template    git URL or local path to clone from
                (default: https://github.com/dvd90/robinhood-app-boilerplate.git)
  -h, --help

Zero dependencies; Node ≥ 20; needs git on the PATH (and Foundry to do anything afterwards). If <name> is omitted it asks once for it — that is the only prompt, so with a name the command is fully non-interactive. Names must match ^[a-z0-9][a-z0-9-_]*$ (case-insensitive); an existing directory is refused.

What it does, in order:

  1. git clone --depth 1 --recurse-submodules --shallow-submodules <template> <name>.
  2. Removes the template's own history and everything that is not your project: .git, .gitmodules, each contracts/lib/*/.git (submodules become plain vendored directories, so forge test works offline), packages/ (the CLI), examples/, PLAN.md.
  3. Without --fullstack: removes apps/, pnpm-workspace.yaml, pnpm-lock.yaml.
  4. Without --with-token: removes contracts/src/GameToken.sol and its test.
  5. Renames: the name in package.json, the first heading of README.md, and (fullstack) the <title> in apps/web/app/layout.tsx. No other file is templated — contracts keep their names.
  6. git init && git add -A && git commit -m "chore: scaffold with create-robinhood-app".
  7. Prints the next steps and the VERIFY warning.

The result must be green with no manual step; packages/create-robinhood-app/test.sh scaffolds a bare and a fullstack project into a temp dir and runs pnpm verify in each to prove it.

#Root scripts (package.json)

Command What it does
pnpm verify forge fmt --check && forge test in contracts/; then tsc --noEmit && lint if apps/web exists; then the example's own gate if examples/arcade-guild exists
pnpm dryrun [rpc] contracts/script/dryrun.sh: anvil (optionally forking rpc) → Deploy.s.solDryRun.s.sol → prints deployments/31337.json

pnpm verify is the gate: every commit and every CI run must pass it. Do not skip or comment out a failing test to get to green.

#Foundry (contracts/)

Command Notes
forge test unit + fuzz (512 runs) + invariants (64 runs × depth 32)
FOUNDRY_PROFILE=ci forge test fuzz 2048, invariants 256 × 64 — what CI runs
forge test --match-test invariant_Conservation -vvv one test, verbose
forge fmt / forge fmt --check run forge fmt twice before --check — it is not idempotent on long multi-line ifs
forge script script/Deploy.s.sol --rpc-url robinhood --broadcast [--verify] deploy to 4663; robinhood is defined in foundry.toml from ROBINHOOD_RPC_URL
forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast --private-key <key> deploy to a running anvil
cast call <factory> "predict(address,bytes32)(address,address)" <deployer> <salt> --rpc-url … predict clone addresses

forge, anvil and cast are installed by foundryup into ~/.foundry/bin; add it to your PATH.

#Front end (apps/web, when present)

Command What it does
pnpm install (repo root) installs the workspace
pnpm -C apps/web dev Next dev server on :3000
pnpm -C apps/web build / start production build / serve
pnpm -C apps/web tsc --noEmit / lint the two checks pnpm verify folds in

#Example (examples/arcade-guild, repo only)

Command What it does
forge test the example's 3 tests against the parent's contracts
forge script script/Deploy.s.sol --sig "runArcade()" --rpc-url … --broadcast deploy the example project

#Maintainer scripts

Command What it does
bash packages/create-robinhood-app/test.sh scaffold bare + fullstack from the local checkout and verify both
npm --prefix site run build / check build the docs site to site/dist/ / validate without writing
Reference

#Configuration

Nothing is required to compile, test or dry-run. Everything below is for a real chain or the front end. There is no .env loader in the contracts — export variables in your shell (or use direnv); apps/web reads its own .env.

#Deploy script (contracts/script/Deploy.s.sol)

Read with vm.envOr, so every one is optional.

Variable Type Default Meaning
PROJECT_NAME string Membership ERC-721 name
PROJECT_SYMBOL string MBR ERC-721 symbol
TREASURY address the broadcaster receives mint proceeds; must accept a zero-value call
MINT_PRICE uint (wei) 10000000000000000 (0.01 ether) price per mint; excess refunded
MAX_SUPPLY uint 1000 hard cap on memberships
SALT bytes32 "membership" clone addresses depend on (broadcaster, SALT)
ERC6551_REGISTRY address Constants.ERC6551_REGISTRY VERIFY — override without editing code
ERC6551_ACCOUNT_IMPL address Constants.ERC6551_ACCOUNT_IMPL VERIFY — override without editing code

Plus the standard forge flags: --private-key / --account / --ledger for the broadcaster, --rpc-url robinhood (below), --verify.

#Foundry (contracts/foundry.toml)

toml
[rpc_endpoints]
robinhood = "${ROBINHOOD_RPC_URL}"                    # VERIFY: from official Robinhood Chain docs

[etherscan]
robinhood = { key = "${BLOCKSCOUT_API_KEY}", chain = 4663, url = "${ROBINHOOD_BLOCKSCOUT_API_URL}" }
Variable Used by
ROBINHOOD_RPC_URL --rpc-url robinhood
ROBINHOOD_BLOCKSCOUT_API_URL --verify (Blockscout API base URL)
BLOCKSCOUT_API_KEY --verify (may be empty for Blockscout)
FOUNDRY_PROFILE ci for the deeper fuzz/invariant runs

#Profiles

Setting default ci
fuzz.runs 512 2048
invariant.runs 64 256
invariant.depth 32 64
invariant.fail_on_revert true true
solc_version 0.8.28
optimizer_runs 200

fs_permissions grants read-write on ../deployments so Deploy.s.sol can write deployments/<chainId>.json. The directory must already exist (it ships with .gitkeep) — Foundry refuses to create directories outside the project root.

#Front end (apps/web/.env.example)

code
NEXT_PUBLIC_ROBINHOOD_RPC_URL=
NEXT_PUBLIC_ROBINHOOD_EXPLORER_URL=
NEXT_PUBLIC_REWARD_TOKENS=
Variable Meaning
NEXT_PUBLIC_ROBINHOOD_RPC_URL RPC for chain 4663 — VERIFY
NEXT_PUBLIC_ROBINHOOD_EXPLORER_URL Blockscout base URL for links — VERIFY
NEXT_PUBLIC_REWARD_TOKENS comma-separated ERC-20 addresses to show in Holdings/Distribute; entries that are not 0x + 40 hex are dropped

All three are read only in apps/web/lib/robinhood.ts. Contract addresses are not env vars: they come from deployments/4663.json at build time.

#Chain constants (not env, VERIFY-tagged)

File Holds
contracts/src/Constants.sol CHAIN_ID, ERC6551_REGISTRY, ERC6551_ACCOUNT_IMPL, placeholders
apps/web/lib/robinhood.ts the same, plus robinhoodChain (name, currency, URLs from env)

Rule 4: no other file may hold an address literal. Both files carry VERIFY comments to delete once each value is confirmed against official docs and the explorer.

#pnpm (pnpm-workspace.yaml)

packages: ["apps/*"] — the workspace holds only the front end. allowBuilds lists native optional dependencies (bufferutil, keccak, unrs-resolver, utf-8-validate) as false: they have pure-JS fallbacks and pnpm ≥ 10 would otherwise refuse to install with a hard error.

Reference

#Testing

contracts/test/ is the spec. 56 tests across 10 suites (54 without the optional GameToken), plus fuzz and invariant runs. forge test runs them in well under a second; CI runs the deeper ci profile.

#The two headline invariants

Both must stay green before any integration work — they are what makes the rest trustworthy.

Vault conservationRevenueVault.invariant.t.sol › invariant_Conservation. A handler mints, transfers, changes weights, deposits, distributes and claims at random across two reward tokens, tracking a ghost deposited. After every sequence: Σ claimed + Σ claimable + distributable == Σ deposited, and the vault physically holds everything not yet claimed. Nothing minted, nothing lost, dust included.

TBA control follows the NFTMembershipNFT.invariant.t.sol › invariant_TBAControlFollowsNFT. After random mint/transfer sequences, every token's ERC-6551 account exists and ERC6551Account(tba).owner() == nft.ownerOf(id). Plus invariant_SupplyNeverExceedsMax.

#Test map

File Kind What it pins down
MembershipNFT.t.sol unit + fuzz sequential ids, maxSupply, price enforcement, excess refund, TBA at the canonical registry (fuzz: computed == registry), mint proceeds → treasury, vault untouched, events, re-init rejected, owner setters, heldSince, treasury must accept ETH
MembershipNFT.invariant.t.sol invariant the TBA-control invariant above
RevenueVault.t.sol unit + fuzz deposit/distribute/claim accounting, permissionless calls, claim-twice-pays-once, accumulation across rounds, blocked recipient does not block others, dust carry, zero holders / zero weight keep funds, NothingToDistribute, multi-token independence, weights only via strategy, re-entrancy guard holds, mint never touches vault, fuzz: no holder exceeds its weighted share
RevenueVault.invariant.t.sol invariant conservation
Factory.t.sol unit + fuzz clones wired and owned by caller, predict == deployed (fuzz), same salt same deployer reverts, same salt different deployers do not collide, clones/impls cannot be (re)initialized, two projects isolated, proceeds never reach factory or vault, event
Factory.invariant.t.sol invariant distinct salts never collide; projects stay state-isolated
Integration.t.sol end-to-end through the real Deploy.deployCore(): deploy → 5 mints → deposit → distribute → claim → each TBA holds its share (and a TBA can be operated by its owner); transfer mid-cycle pays the new owner next round
strategies/EqualWeightStrategy.t.sol unit + fuzz every token weighs 1; equal split
strategies/TenureWeightStrategy.t.sol unit weight grows per period, transfer resets, vault needs no changes, zero period reverts
GameToken.t.sol unit + fuzz supply to recipient; no transfer tax (fuzz)

Bold entries are the tests that enforce a golden rule.

#The harness and the mocks

test/utils/Fixture.solabstract contract Fixture is Test. setUp pins vm.chainId(4663), etches the real ERC6551Registry bytecode at Constants.ERC6551_REGISTRY, deploys an ERC6551Account implementation, clones one MembershipNFT + RevenueVault pair owned by the test contract, with MockWeightStrategy and MockRewardToken. Helpers: _mint(who), _deposit(token, amount), _claimAll(token), _ids(id), _tba(id). Extend it for any new test — the Arcade Guild example does so from outside the project.

Mock Purpose
MockRewardToken benign ERC-20 with public mint
MockWeightStrategy settable per-token weights + defaultWeight; counts reads, so tests can prove the vault reads weight only through the strategy
BlocklistToken reverts transfers to blocked recipients — models transfer-restricted stock tokens
ReenteringToken re-enters claim() and distribute() on every vault transfer and counts attempts vs successes

#Adding a test

  1. New strategy → copy strategies/TenureWeightStrategy.t.sol; the pattern is vault.setStrategy in setUp, mint two differing members, deposit a round number, distribute, _claimAll, assert TBA balances. Add a fuzz test for the formula.
  2. New vault behaviour → write the failing unit test in RevenueVault.t.sol first, run, confirm red, implement. If it touches arithmetic, extend VaultHandler in the invariant file so conservation covers it.
  3. Anything touching ownership → add to the relevant invariant handler.
  4. forge fmt (twice), pnpm verify.

#Profiles

forge test FOUNDRY_PROFILE=ci forge test
fuzz runs 512 2048
invariant runs × depth 64 × 32 256 × 64
fail_on_revert true true

Handlers must therefore never revert on their own: they read values before vm.prank (a prank is consumed by the very next call, view calls included) and bound their inputs.

Project

#CLAUDE.md

Conventions for AI agents (Claude Code, Cursor, Codex) working in this repo and in any project scaffolded from it. Read this before writing code. Keep changes small, test-first, and green.

#What this is

A Chassis-style boilerplate for on-chain projects on Robinhood Chain (chain ID 4663). Core primitives:

  • MembershipNFT — ERC-721; each token mints an ERC-6551 token-bound account (TBA).
  • RevenueVault — pro-rata distribution of explicitly deposited revenue to current holders.
  • Factory — EIP-1167 cloneDeterministic of NFT + Vault, wired and ownership-transferred in one tx.
  • IWeightStrategy — pluggable holder-weight function (levels, tenure, whatever). Game logic lives here, never in the vault.

Optional layers behind CLI flags: apps/web (Next 15 + wagmi/viem), a plain GameToken ERC-20.

#Golden rules (architectural invariants — do not violate)

  1. The vault distributes only what is explicitly deposited via depositRevenue(). Mint proceeds go to the creator/treasury and are never routed into the vault. This is the load-bearing design decision. Do not add a code path that forwards mint fees, sell taxes, or any auto-tax into RevenueVault. If a task seems to ask for it, stop and flag it — it changes the economic model from "revenue share" to "recycled deposits" and breaks the invariant the tests enforce.
  2. Game mechanics never touch money math. Weight comes from IWeightStrategy via interface only. The vault must compile and pass its tests against a mock strategy with zero knowledge of levels/feeding/etc.
  3. Do not ship market-making, multi-wallet, or volume-simulation tooling. Not in contracts, scripts, or frontend. Out of scope, permanently.
  4. Never hardcode an unverified address. All chain constants live in one place (contracts/src/Constants.sol + apps/web/lib/robinhood.ts) and every external address (6551 registry, account impl, stock tokens, Uniswap) must be marked // VERIFY: <source> until confirmed against official Robinhood Chain docs and the block explorer. A wrong address is a silent-failure bug.
  5. Trustlessness over convenience. Clones are non-upgradeable. Owner powers are minimized and Ownable2Step. No function lets an owner pull tokens out of user TBAs or out of undistributed vault balances.

#Repo layout

code
contracts/
  src/
    MembershipNFT.sol
    RevenueVault.sol
    Factory.sol
    Constants.sol            # chain 4663 constants, all VERIFY-tagged
    strategies/
      IWeightStrategy.sol
      EqualWeightStrategy.sol # default: every holder equal
    GameToken.sol            # optional, plain ERC-20, NO transfer tax
  test/                      # the spec — see PLAN.md
  script/Deploy.s.sol
  foundry.toml
apps/web/                    # optional (--fullstack)
  lib/robinhood.ts
deployments/4663.json        # written by deploy, read by frontend
examples/arcade-guild/       # reference project: builds ON contracts/ via remappings, never copies it
docs/                        # the documentation; the site is generated from it — update with the code
site/                        # builds docs/ into one HTML page (GitHub Pages); pruned from scaffolds

#Commands (the gate)

Every commit and every CI run must pass, in this order:

bash
forge fmt --check
forge test                   # unit + fuzz + invariant
pnpm -C apps/web tsc --noEmit   # if apps/web present
pnpm -C apps/web lint           # if apps/web present

pnpm verify runs all of the above. Do not commit red. Do not --skip or comment out a failing test to "get to green" — fix the code or fix the test with a stated reason.

#TDD workflow (required)

  1. Write or extend the failing test(s) first. Run forge test, confirm red.
  2. Write the minimum implementation to pass. Confirm green.
  3. Add fuzz/invariant runs for anything touching arithmetic or ownership before moving on.
  4. forge fmt, then run the full gate.

Two invariants must be green before any integration work:

  • Vault conservation — Σ distributed == deposited − carried dust; nothing minted or lost.
  • TBA control follows the NFT — the TBA address is fixed per tokenId; its owner resolves through ownerOf(tokenId), so after transfer the new holder controls it.

#Solidity conventions

  • Solidity ^0.8.24, forge fmt defaults, OpenZeppelin for ERC-721/Ownable2Step/Clones/ReentrancyGuard.
  • Clones use initialize() guarded by an initializer; constructors only on implementations (which are then disabled for the impl via _disableInitializers()).
  • Checks-effects-interactions; nonReentrant on distribute(). Assume reward tokens are hostile ERC-20s and test with a re-entering mock.
  • Integer-division remainder ("dust") is carried forward in the vault, never dropped.
  • Emit rich events for every state change (mint, deposit, distribute, clone) — the frontend and any indexer read these, not storage.
  • Custom errors, not revert strings.

#Frontend conventions (if present)

  • wagmi + viem, chain pinned to 4663 from lib/robinhood.ts. No other chains.
  • Read TBA balances by computing the 6551 account address and calling balanceOf on each reward token; value via the official price feed. Never trust a hardcoded balance.
  • All addresses imported from lib/robinhood.ts. No inline address literals in components.

#Out of scope (do not build)

  • Any auto-tax/fee path into the vault (see rule 1).
  • Wallet-farming, wash-trading, or price-manipulation tooling (rule 3).
  • Upgradeable clones, owner escape hatches over user funds (rule 5).

#Note on chain constants

Chain 4663 / registry / stock-token addresses in this repo originate from project notes and are not yet verified. Before any real deploy, confirm every constant against official Robinhood Chain documentation and the block explorer, and drop the VERIFY tags only once confirmed.

Project

#Maintainers guide

From this repo to a public, reusable template, and the routine that keeps it healthy.

#Publish the CLI

packages/create-robinhood-app is zero-dependency and versioned separately from the template it clones: a CLI published today clones tomorrow's main. That is why the prune list (what a scaffold must not contain) matters — adding a top-level directory to the repo means adding it to rm(...) in bin/index.mjs and to the assertions in test.sh.

bash
bash packages/create-robinhood-app/test.sh   # bare + fullstack scaffolds, both verified green
cd packages/create-robinhood-app
npm version patch                            # or minor
npm login && npm publish                     # unscoped → public

Then, from a clean directory: npx create-robinhood-app@latest smoke && cd smoke && pnpm verify.

#The docs site

docs/**/*.md and README.md are the only source. site/build.mjs renders them into one self-contained site/dist/index.html (CSS, client script and search index inlined) plus llms.txt, llms-full.txt, CLAUDE.md, robots.txt and sitemap.xml.

bash
npm --prefix site ci
npm --prefix site run check     # validate: every doc in the nav, no dead links — CI runs this
npm --prefix site run build     # write site/dist/
open site/dist/index.html

Rules the build enforces (as errors, not warnings):

  • every .md under docs/ must be listed in site/pages.mjs, or the build fails — no unreachable pages;
  • every relative markdown link must resolve to a page or a heading; links to source files become GitHub links;
  • every #anchor must exist.

Deployment is .github/workflows/docs.yml: on push to main touching docs/**, README.md, CLAUDE.md, llms.txt or site/**, it builds and publishes to GitHub Pages. Enable once in Settings → Pages → Source: GitHub Actions. The site URL is https://dvd90.github.io/robinhood-app-boilerplate/.

#The gate, and what each part exists for

Every PR runs four CI jobs; all of them also run locally.

Job / command Catches
contractsforge fmt --check && forge test (ci profile), plus the same in examples/arcade-guild contract regressions, the two headline invariants, the example drifting from the core
webtsc --noEmit && lint front-end type/ABI drift
docsnpm --prefix site run check a doc missing from the nav, a dead link, a missing anchor
clibash packages/create-robinhood-app/test.sh see below

test.sh is the scaffold gate. Each assertion is a bug that shipped once:

  1. npm tarball contentsbin/index.mjs, README.md, LICENSE, package.json (0.1.0 went out without LICENSE).
  2. The forge hint — shown when forge is absent, silent when present.
  3. A shell without Foundry on PATH — the scaffold's pnpm verify and pnpm dryrun run with ~/.foundry/bin stripped from PATH; they must find it themselves.
  4. Bare scaffold — nothing repo-only survives (apps, examples, packages, site, docs.yml, PLAN.md, GameToken); pnpm verify green; pnpm dryrun prints the tba reward balance line the tutorial shows.
  5. Fullstack + token scaffold — install and verify green.
  6. Docs quote the real anvil key — the account-0 key in dryrun.sh must appear verbatim in the example README and guide (a typo cost a debugging loop).

Adding a top-level directory? Add it to the CLI's rm(...) list and to assertion 4, or the gate fails.

#Stacked PRs

Base every PR on main unless it truly needs another PR's changes. If you do stack, merge in order — Automatically delete head branches is on for the repo, so GitHub retargets the next PR to main when its base branch disappears. Without that setting a stacked PR merges into its base branch and main silently misses it (it happened once; the fix was a catch-up PR from the last branch to main).

#Release checklist

  • pnpm verify green locally, including the example
  • bash packages/create-robinhood-app/test.sh green (CI runs it too)
  • npm --prefix site run check green
  • Any new top-level directory added to the CLI prune list (and test.sh)
  • Any new contract function mirrored in apps/web/lib/abi.ts and docs/reference/contracts.md
  • Any new env var in docs/reference/configuration.md
  • VERIFY tags: added for any new address, removed only with a source
  • CLI version bumped and published if bin/index.mjs changed

#Known drift

Things PLAN.md describes that the code does differently. Fix the plan or the code, but know which is true today:

  • PLAN.md mentions a --with-factory flag and a template/ directory; the CLI has neither — it clones the repo and prunes. --bare is the absence of --fullstack.
  • The spec checkboxes in PLAN.md are unticked although every phase shipped.
  • deployments/4663.json carries a _comment key the deploy script will drop on first write.

#Keeping the template fresh

  • Foundry libs are git submodules (contracts/lib/*); bump with forge update, run the gate.
  • apps/web pins Next 15 / React 19 / wagmi 2 / viem 2; bump in apps/web/package.json, then pnpm install && pnpm verify. Import injected from wagmi, never wagmi/connectors.
  • forge fmt is not idempotent on long multi-line ifs — run it twice before --check.