# Robinhood App Boilerplate β€” complete documentation Generated from https://github.com/dvd90/robinhood-app-boilerplate. Every page below is the verbatim source markdown, in the order the site presents it. # πŸͺ™ 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](https://dvd90.github.io/robinhood-app-boilerplate/)** Β· [Start here](docs/getting-started.md) Β· [New to crypto?](docs/tutorial.md) Β· [create-robinhood-app on npm](https://www.npmjs.com/package/create-robinhood-app) ## Start here Five commands, about ten minutes. Each is explained step by step in the **[getting-started guide](docs/getting-started.md)**. 1. **Install the tools** β€” `curl -L https://foundry.paradigm.xyz | bash && foundryup`, Node β‰₯ 20, `npm i -g pnpm` 2. **Create a project** β€” `npx create-robinhood-app my-app` (add `--fullstack` for a website) 3. **Run the tests** β€” `cd my-app && pnpm verify` 4. **See it work locally** β€” `pnpm 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](https://eips.ethereum.org/EIPS/eip-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](https://dvd90.github.io/robinhood-app-boilerplate/llms.txt)** β€” the project, its conventions and its docs index, in one fetch - **[llms-full.txt](https://dvd90.github.io/robinhood-app-boilerplate/llms-full.txt)** β€” every documentation page, concatenated - **[CLAUDE.md](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 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 ## Golden rules Architectural invariants the tests enforce. The full rationale is in [Economics & trust](docs/economics.md) and [CLAUDE.md](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 ` | 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](docs/reference/configuration.md). ## Example project [`examples/arcade-guild/`](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](docs/guides/example-arcade-guild.md). ## Project structure ``` 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](https://dvd90.github.io/robinhood-app-boilerplate/)** β€” searchable, one page. The source lives in [`docs/`](docs/README.md) and the site is generated from it, so the two can never disagree: - **[Tutorial](docs/tutorial.md)** β€” what you built and what you can build with it, in plain words, for non-crypto readers - **[Getting started](docs/getting-started.md)** β€” install β†’ scaffold β†’ test β†’ local run β†’ deploy, step by step - **Guides** β€” [weight strategies](docs/guides/weight-strategies.md) Β· [deploying](docs/guides/deploying.md) Β· [front end](docs/guides/frontend.md) Β· [example: Arcade Guild](docs/guides/example-arcade-guild.md) - **Concepts** β€” [architecture](docs/architecture.md) Β· [economics & trust](docs/economics.md) - **Reference** β€” [contracts](docs/reference/contracts.md) Β· [CLI & scripts](docs/reference/cli.md) Β· [configuration](docs/reference/configuration.md) Β· [testing](docs/reference/testing.md) - **[Maintainers guide](docs/maintainers.md)** β€” publishing the CLI, the docs site, releases ## 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](LICENSE) # 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](getting-started.md) 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. ``` 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](#the-vault-deposit-split-claim) 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". ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” "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. ``` 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. ``` (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. ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 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](guides/example-arcade-guild.md) | 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. ``` 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](economics.md#what-the-owner-can-and-cannot-do). ## 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](guides/example-arcade-guild.md), 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](economics.md#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](guides/weight-strategies.md) 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](guides/deploying.md). - **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](economics.md#legal-note) 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 - [Getting started](getting-started.md) β€” do it: install, scaffold, test, run locally, deploy. - [Weight strategies](guides/weight-strategies.md) β€” write the rule, with tests. - [Example: Arcade Guild](guides/example-arcade-guild.md) β€” a finished project to copy. - [Economics & trust](economics.md) β€” the guarantees, and the tests behind each. - [Architecture](architecture.md) β€” how it works, for engineers. - [Contracts reference](reference/contracts.md) β€” every function, event and error. - [Deploying](guides/deploying.md) β€” the checklist before Robinhood Chain. - [Agent guide](../CLAUDE.md) β€” if an AI agent will help you change the code. # 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](tutorial.md). ## Step 1 β€” Install the tools You need [Foundry](https://getfoundry.sh) (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: ``` 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](reference/cli.md). No flags is the smallest, fastest start β€” you can always add the front end later. You should see: ``` 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: ``` 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: ``` 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](guides/weight-strategies.md). **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= 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](guides/deploying.md). 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 `if`s), 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 - [Weight strategies](guides/weight-strategies.md) β€” make the split rule real, with access control and tests - [Example: Arcade Guild](guides/example-arcade-guild.md) β€” a finished project built on the boilerplate - [Deploying](guides/deploying.md) β€” the full checklist for chain 4663 - [Front end](guides/frontend.md) β€” if you scaffolded with `--fullstack` - [Economics & trust](economics.md) β€” what holders can rely on, what the owner can and cannot do # 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](example-arcade-guild.md): 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](../economics.md). # 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 # 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= # 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 "predict(address,bytes32)" ` β€” 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/.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.sol` β€” `CHAIN_ID`, `ERC6551_REGISTRY`, `ERC6551_ACCOUNT_IMPL` confirmed on the Robinhood Chain block explorer (the registry must have code; `cast code --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 - [ ] `apps/web/.env` β€” `NEXT_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 ` against a fork - [ ] Read the legal note in [Economics & trust](../economics.md#legal-note) Then delete the `VERIFY` tags β€” they are the to-do list. # 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. ``` 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 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. ## 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`. # 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/`](../../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: ``` 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 ``` 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 `CREATE`s 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/.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 ``` ``` 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](weight-strategies.md#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/`. # Architecture Three contracts, one interface, one deploy path. Everything else is tests. ``` 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] += 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 `safeTransfer`s 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. `initialize`s 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](reference/contracts.md). ## 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](reference/testing.md) | # 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. ## Legal note 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. # 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 `safeTransfer`s 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](../guides/weight-strategies.md). ### `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.sol` β€” `run()` deploys implementations + `Factory` + `EqualWeightStrategy` and one project, writes `deployments/.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/.json`, mints, deploys a `MockRewardToken`, deposits `1000e18`, distributes, claims, logs the TBA balance and carried dust. Anvil only. # CLI & scripts ## `create-robinhood-app` ``` npx create-robinhood-app [--bare | --fullstack] [--with-token] [--template ] --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 `` 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