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.

  • Factory BasketFactory
  • Deployed Block 53 579 923
  • Unit 1e18 · 18 decimals
  • Admin None · factory and baskets have no owner

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.

Interface status

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:

WhoAnyone. The caller is recorded as creator on the basket for attribution only; the address carries no powers.
Name · symbolFree text, non-empty. Names carry no authority — two baskets can share a name; the recipe is the identity.
Constituents1 to 20 token addresses in strictly ascending order, each a deployed contract that answers decimals(). No duplicates, no zero address.
Units per basketOne non-zero raw token amount per constituent, in that token's own decimals: the deposit required for one basket unit (1e18).
IdentityrecipeHash = keccak256(abi.encode(tokens, unitsPerBasket)). A hash deploys at most once; a second create with the same recipe reverts with the existing address.
AddressDeterministic. predictBasket(name, symbol, creator, tokens, units) returns the address before deployment.
Basket decimals18. The basket token is a standard ERC-20; UNIT = 1e18 is one basket.
Recipe registration through the BasketFactory Creator · any wallet create(name, symbol, tokens[], units[]) tokens sorted ascending units in token decimals BasketFactory non-empty name, symbol each token: has code, ascending, decimals() recipeHash not yet used BasketToken CREATE2 · salt = hash 1–20 constituents creator recorded no owner · no fee Registry allBaskets() isBasket(address) basketByRecipe(hash) BasketCreated event predictBasket() same address, computed before deployment /api/baskets latest 50 · 30 s cache chain read · no wallet
Fig 4.1One transaction registers and deploys a basket. The factory enforces recipe hygiene; the basket enforces its own constructor limits. The dashed boxes are read-only conveniences over the same chain state.

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) computes ceil(units_i × baskets / 1e18) for each constituent, pulls that amount from the caller with safeTransferFrom, verifies the vault balance rose by at least that amount, then mints baskets units to receiver. A short deposit — fee-on-transfer, rebasing, or a partial fill — reverts the whole call.
  • Redeem. redeem(baskets, receiver) burns baskets from the caller, then pays floor(units_i × baskets / 1e18) of each constituent to receiver. The burn happens before any transfer.
Flow of value on mint and redeem Mint · deposit the recipe, receive units Minter wallet approve each constituent then mint(n, receiver) ceil(units·n) BasketToken vault holds every constituent balance_i ≥ units_i·supply mint n units Receiver + n basket units 18 decimals · ERC-20 Redeem · burn units, receive the recipe Holder wallet redeem(n, receiver) no approval needed burn n units BasketToken vault burn first, then pay out zero amounts skipped floor(units·n) Receiver + each constituent any address units_i × n is divided by 1e18 · mint rounds each constituent up, redeem rounds each down
Fig 4.2Lime edges are token movements. Minting deposits every constituent in one call; redeeming returns every constituent in one call. Nothing is routed through a pool or a market.

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.

Held constituent balances versus the amount claimed by outstanding supply Per constituent · held vs claimed · claim = 100 % 0 50 % 100 % token A token B token C claimed = units_i × supply / 1e18 held = balanceOf(basket) rounding surplus · never withdrawn
Fig 4.3Each constituent is held at least in full against supply; the dashed line is the amount the supply claims. The stroked sliver is rounding surplus from up-rounded mints and down-rounded redeems; it stays in the vault for all holders.

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:

SymbolKindDecimalsAddress
AAPLStock Token180xaF3D…93f9
NVDAStock Token180xd060…9EEC
TSLAStock Token180x322F…3B2D
MSFTStock Token180xE932…2e74
AMZNStock Token180x12f1…Bf54
GOOGLStock Token180x2E08…4Fe3
SPYStock Token (ETF)180x117c…4c0C
QQQStock Token (ETF)180xd5F3…dE68
ETHWETH180x0Bd7…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.

Registry

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's isBasket() 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

ParameterValueMeaningSource
UNIT1e18One basket unit; basket token has 18 decimalsBasketToken.sol:31
MAX_CONSTITUENTS20Recipe size, minimum 1BasketToken.sol:32, 46
Units per constituent> 0Raw token amount per unit; zero rejectedBasketToken.sol:52
DuplicatesrejectedEach constituent onceBasketToken.sol:53–55
Mint roundingupceil(units_i × baskets / 1e18)BasketToken.sol:79
Redeem roundingdownunits_i × baskets / 1e18BasketToken.sol:87
Deposit checkreceived ≥ expectedBalance delta per constituent, else ShortDepositBasketToken.sol:102–107
Constituent orderascendingStrictly increasing addressesBasketFactory.sol:72
Constituent checkscode · decimals()Must be a contract that answers decimals()BasketFactory.sol:71, 75–78
recipeHashkeccak256abi.encode(tokens, unitsPerBasket); also the CREATE2 saltBasketFactory.sol:45–47, 84
Fees · oracle · adminnoneNo fee, no price feed, no owner on factory or basketBasketToken.sol:11 · DeployBasketFactory.s.sol:9
Registry page size50 · 30 s/api/baskets lists the latest 50, cached 30 sserver.mjs:190, 192