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.
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.
Five commands, about ten minutes. Each is explained step by step in the getting-started guide.
Install the tools — curl -L https://foundry.paradigm.xyz | bash && foundryup, Node ≥ 20, npm i -g pnpm
Create a project — npx create-robinhood-app my-app (add --fullstack for a website)
Run the tests — cd my-app && pnpm verify
See it work locally — pnpm dryrun (deploys, mints, deposits, distributes on a local chain)
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) externalviewreturns (uint256) {
return1 + 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.
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.
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.
Token-bound accounts — every membership mints an ERC-6551 wallet; control follows ownerOf, so selling the NFT hands over its balance too
Explicit-deposit vault — depositRevenue() is the only way money enters; no receive(), no mint-fee routing, no auto-tax — enforced by tests
Pull-based payouts — distribute() 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 weight — IWeightStrategy.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 safe — nonReentrant on distribute()/claim(), fee-on-transfer credited by actual delta, tested against a re-entering mock
Deterministic clones — Factory.predict(deployer, salt) before deploy(); salts are namespaced per deployer so they never collide
Minimal owner powers — Ownable2Step, 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 run — pnpm 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 ready — CLAUDE.md + llms.txt in every scaffold
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:
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.
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
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.
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.
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 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.
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 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)
Deposit. Tokens come in and are counted as waiting to be split.
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.
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.
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.
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) externalviewreturns (uint256) {
return1 + 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 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.
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).
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.
You set levels: Alice 0, Bob 1, Carol 3. So the weights are 1, 2 and 4 — total 7.
Somebody deposits 100 tokens into the vault. Waiting: 100.
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.
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.
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.
The machines stay the same; you change the rule, the price and the story. Nine shapes, each
with the rule it needs:
Creator club. Fans mint a card; every month you deposit a slice of sponsorship or merch
income. Rule: Equal — ships, nothing to write.
Arcade guild. Your game server raises a player's level as they play; higher level, bigger
share. Rule: Level — the example project, copy it.
Loyalty club. The longer someone has held their card without selling, the bigger their
share; buyers start over. Rule: Tenure — ships, pick the period.
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.
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.
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.
Bronze / silver / gold tiers. Tiers assigned after mint by the owner, worth 1 / 3 / 10.
Rule: an owner-set tier table.
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".
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.
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.
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.
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).
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.
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.
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.
A member joined. Card #1 was minted and got its own wallet (the tba address).
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).
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.
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.
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: MITpragmasolidity ^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) externalviewreturns (uint256) {
return1 + 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:
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.
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.
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.
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).
Copy TenureWeightStrategy.sol next to it and rename.
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.
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.
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.
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.
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.
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.
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:
starts anvil (killed on exit);
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;
runs DryRun.s.sol: mint → deploy a mock reward token → deposit 1000 → distribute → claim,
and prints what happened;
prints deployments/31337.json.
deployments/31337.json is git-ignored; it is a rehearsal artefact.
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.
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.
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/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.
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.sol — CHAIN_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.toml — ROBINHOOD_RPC_URL, ROBINHOOD_BLOCKSCOUT_API_URL from official docs
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
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.
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.
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 holdings — Holdings.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.
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.
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() publicoverride {
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.
It inheritsDeploy 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).
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.
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.
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/.
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.
Three functions, three phases, each permissionless except where noted:
Phase
Who
What changes
depositRevenue(token, amount)
anyone with an approval
distributable[token] += received — received 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 is immutable and holds two implementation addresses plus the registry and account
implementation. deploy(salt, Params):
derives nftSalt = keccak256(deployer, salt, "nft") and vaultSalt = keccak256(deployer, salt, "vault") —
namespaced per deployer, so two projects can both use salt "membership";
Clones.cloneDeterministic the NFT and the vault (EIP-1167 minimal proxies, ~45 bytes each);
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.
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.
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.
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.
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.
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.
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.
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.
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.
Ownable2StepUpgradeable, ReentrancyGuard. Pro-rata distribution of explicitly deposited ERC-20
revenue to current holders. No receive(). Uses SafeERC20 and Math.mulDiv.
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
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.
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).
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.
Deploy.s.sol — run() 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.
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.
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.
Without --fullstack: removes apps/, pnpm-workspace.yaml, pnpm-lock.yaml.
Without --with-token: removes contracts/src/GameToken.sol and its test.
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.
git init && git add -A && git commit -m "chore: scaffold with create-robinhood-app".
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.
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
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.
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.
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.
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.
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.
Both must stay green before any integration work — they are what makes the rest trustworthy.
Vault conservation — RevenueVault.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 NFT — MembershipNFT.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.
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.
test/utils/Fixture.sol — abstract 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
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.
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.
Anything touching ownership → add to the relevant invariant handler.
Handlers must therefore never revert on their own: they read values beforevm.prank (a prank is
consumed by the very next call, view calls included) and bound their inputs.
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.
#Golden rules (architectural invariants — do not violate)
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.
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.
Do not ship market-making, multi-wallet, or volume-simulation tooling. Not in
contracts, scripts, or frontend. Out of scope, permanently.
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.
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.
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
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.
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 ^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.
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.
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.
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.
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/.
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).
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.