04 · Basket ETF Live on Robinhood Chain
Fixed-recipe ETFs,
backed by the tokens they hold.
A basket ETF is an ERC-20 whose every unit is redeemable for a fixed list of constituent tokens held by the basket contract itself. Anyone registers a recipe through the BasketFactory; anyone mints by depositing the recipe and redeems by burning. There is no manager, no fee, no rebalancing and no oracle — the recipe is the product.
4.1 Purpose
Holding a diversified position in Stock Tokens means holding several ERC-20s. A basket ETF folds that set into one token with a fixed, public recipe, so a portfolio can be transferred, quoted and held as a single asset while remaining fully backed by the underlying tokens at every moment.
The design is deliberately minimal. A basket contract stores an ordered list of constituent addresses and an amount of each per unit. It can do exactly two things with those tokens: pull them in on mint and pay them out on redeem. It never reads a price, never trades, never charges, and cannot be changed after deployment.
The performance studio in the app — presets, weights, historical shape — is modeled and does not execute anything. The onchain card beneath it (deploy, mint, redeem) moves real tokens through the contracts described here.
4.2 Recipe registration
BasketFactory.create(name, symbol, tokens[], unitsPerBasket[]) is callable by any address. The factory validates the recipe, deploys a BasketToken with CREATE2 using the recipe hash as salt, and records it. What the creator chooses and what is fixed:
| Who | Anyone. The caller is recorded as creator on the basket for attribution only; the address carries no powers. |
|---|---|
| Name · symbol | Free text, non-empty. Names carry no authority — two baskets can share a name; the recipe is the identity. |
| Constituents | 1 to 20 token addresses in strictly ascending order, each a deployed contract that answers decimals(). No duplicates, no zero address. |
| Units per basket | One non-zero raw token amount per constituent, in that token's own decimals: the deposit required for one basket unit (1e18). |
| Identity | recipeHash = keccak256(abi.encode(tokens, unitsPerBasket)). A hash deploys at most once; a second create with the same recipe reverts with the existing address. |
| Address | Deterministic. predictBasket(name, symbol, creator, tokens, units) returns the address before deployment. |
| Basket decimals | 18. The basket token is a standard ERC-20; UNIT = 1e18 is one basket. |
The interface derives unitsPerBasket from percentage weights and the marks it observes, sized so that one unit is worth a reference notional at those marks (default 100 USD). Those prices exist only in the browser; the chain sees the resulting integer units and nothing else.
4.3 Mint and redeem
Both operations are exact and symmetric up to rounding. There is no fee, no slippage and no dependency on any price.
- Mint.
mint(baskets, receiver)computesceil(units_i × baskets / 1e18)for each constituent, pulls that amount from the caller withsafeTransferFrom, verifies the vault balance rose by at least that amount, then mintsbasketsunits toreceiver. A short deposit — fee-on-transfer, rebasing, or a partial fill — reverts the whole call. - Redeem.
redeem(baskets, receiver)burnsbasketsfrom the caller, then paysfloor(units_i × baskets / 1e18)of each constituent toreceiver. The burn happens before any transfer.
4.4 Backing invariant
For every constituent i and at every block:
balanceOf(basket, token_i) ≥ unitsPerBasket_i × totalSupply / 1e18
The inequality follows from the rounding direction alone. Every mint deposits at least the pro-rata amount (rounded up, and verified by balance delta), and every redeem pays out at most the pro-rata amount (rounded down). No other code path moves constituents: there is no admin withdrawal, no fee sweep, no rebalancing and no swap. The vault can only ever hold at least what its supply claims.
The basket never reads uiMultiplier() or any price. It holds raw token balances; whatever an issuer multiplier means for a direct holder of a Stock Token means the same for the balances the basket holds. The recipe itself is immutable — a corporate action does not change unitsPerBasket.
4.5 The registered Mag 7 basket
A Mag 7 basket is registered on the factory. The interface's Mag 7 preset is six constituents at equal weight: AAPL, NVDA, TSLA, MSFT, AMZN, GOOGL; SPY and QQQ are excluded from the preset, and META has no Robinhood Chain representation in the registry the interface mints from. The exact unitsPerBasket of any deployed basket are chain state — read them from constituents() on the basket or from /api/baskets; they are not reproduced here because they depend on the marks observed at registration.
Constituents the interface can mint from, with their Robinhood Chain addresses:
| Symbol | Kind | Decimals | Address |
|---|---|---|---|
| AAPL | Stock Token | 18 | 0xaF3D…93f9 |
| NVDA | Stock Token | 18 | 0xd060…9EEC |
| TSLA | Stock Token | 18 | 0x322F…3B2D |
| MSFT | Stock Token | 18 | 0xE932…2e74 |
| AMZN | Stock Token | 18 | 0x12f1…Bf54 |
| GOOGL | Stock Token | 18 | 0x2E08…4Fe3 |
| SPY | Stock Token (ETF) | 18 | 0x117c…4c0C |
| QQQ | Stock Token (ETF) | 18 | 0xd5F3…dE68 |
| ETH | WETH | 18 | 0x0Bd7…AD73 |
The factory itself accepts any ERC-20 that reports decimals(); this list is the interface's, not the contract's.
4.6 Trading
A basket unit is an ordinary ERC-20. It can be transferred, held in any wallet, and traded wherever a market for it exists. The protocol deploys no pool, no order book and no market maker for baskets, and guarantees no venue or liquidity.
Reference value is arithmetic: Σ units_i × price_i / 1e18. Because anyone can mint at that value and redeem for it, any market price away from it is an opportunity for whoever is willing to do the mint or redeem — but the contracts do not perform that arbitrage, and no one is obliged to.
The app's registry endpoint, /api/baskets, reads allBaskets() from the factory, resolves each basket's recipe, name, symbol, supply and creator, and lists the most recent 50 with a 30-second cache. It is public chain state served without a wallet.
4.7 Risks
- Issuer controls on constituents. Stock Tokens are issuer-controlled instruments that can be paused, blocked, burned or upgraded. A constituent that refuses transfers blocks the whole mint or redeem — the basket will not pay out an incomplete recipe. A redeem to a blocked receiver reverts; redeem to another address. An upgrade that changes transfer semantics (a fee, a rebase) makes mints revert with
ShortDeposit. - Fixed units, drifting weights. The recipe is a quantity of each token, not a percentage. As prices move, the value share of each constituent moves with them. There is no rebalancing.
- Names carry no authority. Anyone can deploy a basket named anything. Verify the recipe —
constituents()— and the factory'sisBasket()before treating a token as a StockYield basket. - No venue. Liquidity for basket units, if any, is external. The only guaranteed exit is redemption for the underlying tokens.
- Immutability. A basket cannot be paused, migrated or corrected. A recipe that includes a token later frozen by its issuer stays frozen with it.
4.8 Key parameters
| Parameter | Value | Meaning | Source |
|---|---|---|---|
UNIT | 1e18 | One basket unit; basket token has 18 decimals | BasketToken.sol:31 |
MAX_CONSTITUENTS | 20 | Recipe size, minimum 1 | BasketToken.sol:32, 46 |
| Units per constituent | > 0 | Raw token amount per unit; zero rejected | BasketToken.sol:52 |
| Duplicates | rejected | Each constituent once | BasketToken.sol:53–55 |
| Mint rounding | up | ceil(units_i × baskets / 1e18) | BasketToken.sol:79 |
| Redeem rounding | down | units_i × baskets / 1e18 | BasketToken.sol:87 |
| Deposit check | received ≥ expected | Balance delta per constituent, else ShortDeposit | BasketToken.sol:102–107 |
| Constituent order | ascending | Strictly increasing addresses | BasketFactory.sol:72 |
| Constituent checks | code · decimals() | Must be a contract that answers decimals() | BasketFactory.sol:71, 75–78 |
recipeHash | keccak256 | abi.encode(tokens, unitsPerBasket); also the CREATE2 salt | BasketFactory.sol:45–47, 84 |
| Fees · oracle · admin | none | No fee, no price feed, no owner on factory or basket | BasketToken.sol:11 · DeployBasketFactory.s.sol:9 |
| Registry page size | 50 · 30 s | /api/baskets lists the latest 50, cached 30 s | server.mjs:190, 192 |