Docs
GrowETF is one immutable contract on Robinhood Chain. There is no factory, no owner, no proxy and no admin key. This page is what it does, how to call it, and what it cannot do.
What it is
A vault holding six tokenized stocks at fixed weights. You deposit USDG (or ETH, which it sells for USDG first) and it buys all six legs inside your transaction, then issues you an ERC-20 share. Burning a share returns a proportional slice of each stock in kind — nothing is sold, so leaving needs no liquidity and no counterparty.
Every deposit and every redemption pays 0.50%. The fee never leaves the contract. On the way in it is spent on the same six stocks and credited before your shares are priced; on the way out it is simply a slice withheld in stock. Either way it mints no shares, so it raises the backing behind every share already issued.
A fee that mints no shares can only raise the backing of every share already issued.
Addresses
| Chain | Robinhood Chain, id 4663 (0x1237) |
| RPC | https://rpc.mainnet.chain.robinhood.com |
| Deployed | block 51936947, 3,318,624 gas |
| Source | verified, v0.8.26+commit.8a97fa7a, optimizer on, 200 runs |
| Share token | GROW, 18 decimals, plain ERC-20 |
| Settlement | USDG, 6 decimals. Six impostor USDGs exist on this chain; this one is pinned in the bytecode |
The mechanism
Deposit. Your USDG is pulled in, the fee is taken, and the remainder plus the whole idle fee float is swapped across the six legs in one Uniswap v4 lock. One swap per leg serves both purposes, so both fill at exactly the same price. The output is split in the same ratio as the input, which makes the split exact rather than approximate.
Ordering is the whole trick. The stock bought with the fee is added to the reserves before your shares are priced. You are therefore priced against a vault that already contains the fee, which is what makes the fee accrue to holders who were there before your transaction rather than to you.
Share maths reads tracked reserves, never balanceOf. Sending tokens to the contract cannot inflate the denominator. Shares are issued against the smallest proportional increase across the legs, so a leg that over-delivers benefits everyone instead of diluting them.
Redeem is in kind and needs no swap. You get reserves[i] * shares / supply of each leg, less 0.50% withheld in those same stocks, which stays as backing for whoever remains.
The guard. Each pool's price is read before and after the vault's own swap. If any leg moved more than maxImpactBps (200 = 2%), the entire deposit reverts. A deposit too large for the liquidity fails loudly instead of filling badly in silence.
// what one deposit does, in order
fee = usdgIn * 50 / 10000
invested = usdgIn - fee
totalIn = invested + feeReserve() // the idle float rides along
// one swap per leg, priced once
received[i] = swap(leg[i], totalIn * weight[i] / 10000)
forDepositor[i] = received[i] * invested / totalIn
forEveryone[i] = received[i] - forDepositor[i]
// fees land first, then you are priced against the result
base[i] = reserves[i] + forEveryone[i]
shares = totalSupply * min(forDepositor[i] / base[i])
reserves[i] = base[i] + forDepositor[i]
The basket
Fixed at deployment. No rebalance, no committee, no key that can move them. Weights follow pool depth, not equal-weight vanity: MSFT's pool is around forty times thinner than NVDA's, and an equal-weight fund would be capped on every deposit by its worst leg.
| Ticker | Weight | Address | Pool |
|---|---|---|---|
| NVDA | 25.00% | 0xd0601CE1…0D9EEC | 3000 / 60 |
| AAPL | 20.00% | 0xaF3D76f1…8a93f9 | 3000 / 60 |
| TSLA | 20.00% | 0x322F0929…03b2d | 3000 / 60 |
| GOOGL | 15.00% | 0x2e0847E8…AD4FE3 | 3000 / 60 |
| META | 12.00% | 0xc0D6457C…e02f35 | 3000 / 60 |
| MSFT | 8.00% | 0xe93237C5…9c2e74 | 3000 / 60 |
Every leg is a hookless Uniswap v4 pool against the real USDG, and every one is addressed by address rather than by ticker. Tickers on this chain lie: the token calling itself GME is worth two thousandths of a cent.
The intended basket was NVDA, AAPL, TSLA, AMZN, META, MSFT. AMZN has no Uniswap v4 pool against the real USDG on this chain, so the sixth seat went to GOOGL, the next megacap that does.
Interface
| Function | What it does |
|---|---|
deposit(uint256 usdgIn, uint256 minShares, address to) | Pulls USDG, buys the basket, mints shares. Returns shares. Reverts Slippage below minShares. |
depositETH(uint256 minShares, address to) payable | Sells the ETH for USDG through the native pool first, then identical. Returns shares. |
redeem(uint256 shares, address to) | Burns shares, sends each stock in kind less the 0.50% exit fee. Returns the tokens and amounts. |
grow() | Spends the idle fee float on the basket, minting nothing. Permissionless. Reverts below 0.10 USDG. |
holdings() | (address[] tokens, uint256[] amounts) — the tracked reserves. |
previewRedeem(uint256 shares) | Exactly what a burn returns, after the exit fee. |
quoteDeposit(uint256 usdgIn) | (uint256 fee, uint256 invested). |
navUsdg() | Holdings marked at each pool's spot price, plus the idle float. A mark, not an oracle. |
backingPerShare() | NAV behind 1e18 shares, in USDG. |
feeReserve() | USDG sitting in the vault, not yet turned into stock. |
totalRecycled() · recycleCount() | Lifetime USDG turned into stock, and how many times. |
legCount() · legAt(uint256) | The basket, including each pool key. |
Constants: entryFeeBps 50, exitFeeBps 50, maxImpactBps 200, MAX_FEE_BPS 200, MIN_RECYCLE 1e5, plus 1,000 shares burned on the opening deposit and a 1 USDG minimum first deposit. All immutable or constant.
Events and errors
event Deposited(address indexed to, uint256 usdgIn, uint256 fee, uint256 shares);
event Redeemed(address indexed from, uint256 shares, uint256 feeBps);
event Grew(uint256 usdgRecycled, uint256 totalRecycled);
| Error | When |
|---|---|
TooMuchImpact | A leg's pool moved past 2%. The deposit was too big for the liquidity. |
Slippage | Fewer shares than minShares. |
NothingToDeposit | Zero in, or the deposit rounded to zero shares. |
FirstDepositTooSmall | The opening deposit invested less than 1 USDG. |
NothingToRecycle | grow() with under 0.10 USDG idle. |
TooLittleOut | A swap returned nothing. A pool has gone bad. |
NoSupply | Redeeming from an empty vault. |
Reentrancy · NotPoolManager | Guards. Should never be seen. |
Integrating
Reading needs nothing but an RPC. The share token is a plain ERC-20, so wallets and explorers handle it without help.
# what a share is worth, in USDG
cast call 0x2E4ff1cC6D18a57995c3017f5945AA31fF4c4E3d \
"backingPerShare()(uint256)" --rpc-url https://rpc.mainnet.chain.robinhood.com
# deposit 0.0005 ETH, accept any number of shares
cast send 0x2E4ff1cC6D18a57995c3017f5945AA31fF4c4E3d \
"depositETH(uint256,address)" 0 $YOU \
--value 500000000000000 --rpc-url $RPC --private-key $KEY \
--gas-limit 1300000 --gas-price 413000000
# leave, in kind
cast send 0x2E4ff1cC6D18a57995c3017f5945AA31fF4c4E3d \
"redeem(uint256,address)" $SHARES $YOU --rpc-url $RPC --private-key $KEY
Always set --gas-limit and --gas-price: the block gas limit here is ~1.126e15, and a transaction without an explicit gas figure fails the balance check. And an explicit gas limit skips estimation, so a reverting transaction still exits 0 — read status from the receipt, never the exit code.
Worked examples, all real transactions on mainnet: the first deposit, a deposit from the app, and a redemption which returned all six stocks and left 0.50% behind.
Limits
- Fees push backing up. Prices push it wherever they like. Backing per share is monotone in fees and nothing else. If the six stocks fall, your share falls with them. This is an index fund, not a yield product.
- Capacity binds. About $65,000 per deposit at the time of writing, set by the thinnest leg, and it moves with the pools. Larger money must arrive in pieces. Adding up pool depth overstates this by roughly ten times.
navUsdg()is a spot mark, not an oracle. It reads the same pools the vault trades and can be pushed around like any spot price. Fine for a dashboard. Do not lend against it.- The basket cannot change. That removes an admin key, and with it any way to drop a name that stops trading.
- Not audited. Twenty-seven tests against a mainnet fork is evidence, not an audit.
feeReserve() reads the vault's USDG balance, so anyone can push USDG in and force it into the next swap. That is deliberate — a stray transfer becomes everyone's backing instead of being stranded. But above roughly $67,078 the float is larger than one transaction can push through the pools inside the 2% guard, so deposits and grow() both revert and the donated USDG cannot be reached, because redemptions hand back stock rather than USDG. It costs an attacker more than sixty thousand dollars, permanently, to buy nothing but the inconvenience, and redeeming is never affected. The contract is immutable, so this cannot be patched here. A v2 caps the recycled amount per transaction instead of spending the whole float.
Check it yourself
None of the above is worth anything on trust. Everything here is checkable:
- The code is the deployed code. The on-chain runtime differs from a local build of the source in 427 bytes, and every one of them sits inside an immutable slot. Blockscout verified it independently.
- The basket is what this page says.
legAt(i)returns each token and pool key. The test suite asserts the deployed values against the survey rather than trusting either. - The app invents nothing. It labels holdings from the addresses
holdings()returns, prints an address rather than a name for anything it does not recognise, and shows the first reading as it arrives instead of counting up to it. - The numbers on the site come from
forge testagainst a fork of mainnet, or from the transactions linked above. Nothing is illustrative.
# the whole suite, against a fork of mainnet as it is right now
cd contracts && forge test # 27 passed