B

Index Baskets on Robinhood Chain

Interactive walkthrough · live testnet

Developer deep dive

The code behind the baskets

The walkthrough shows what the dapp does. This page shows how it is built: the two Solidity contracts, the deploy pipeline for Robinhood Chain testnet and mainnet, and the places where this chain genuinely differs from the Ethereum playbook you may already know. Every snippet is real code from the repo and links to the exact lines on GitHub.

the shape of the system

Architecture

Two contracts, no owner, no upgrade path. BasketFactory is a permissionless registry that deploys BasketToken instances. Each basket is an ERC-20 backed by a fixed composition of Stock Tokens it holds itself, priced through the Chainlink feed attached to each component.

The full system. Solid arrows move tokens, the dashed arrow is a read. The factory touches nothing after deployment.
The full system. Solid arrows move tokens, the dashed arrow is a read. The factory touches nothing after deployment.

contracts/src/BasketFactory.sol

L16–L27 on GitHub ↗
// deploys a new basket token and registers it
function createBasket(
    string calldata name,
    string calldata symbol,
    BasketToken.Component[] calldata components,
    uint256 maxPriceAge
) external returns (address basket) {
    basket = address(new BasketToken(name, symbol, components, maxPriceAge));
    _baskets.push(basket);
    isBasket[basket] = true;
    emit BasketCreated(basket, msg.sender, name, symbol);
}

The factory is deliberately thin: deploy, record, emit. All the interesting constraints live in the basket constructor, which validates every component and rejects any feed that does not answer with 8 decimals before the basket can exist.

contracts/src/BasketToken.sol

L54–L81 on GitHub ↗
constructor(
    string memory name_,
    string memory symbol_,
    Component[] memory components_,
    uint256 maxPriceAge_
) ERC20(name_, symbol_) {
    uint256 count = components_.length;
    if (count == 0) revert EmptyComponents();
    if (count > 10) revert TooManyComponents(count);

    for (uint256 i = 0; i < count; i++) {
        Component memory c = components_[i];
        if (c.token == address(0) || c.feed == address(0)) revert ZeroAddress();
        if (c.unitsPerShare == 0) revert ZeroUnits(c.token);
        if (_tokenDecimals[c.token] != 0) revert DuplicateToken(c.token);

        uint8 feedDecimals = AggregatorV3Interface(c.feed).decimals();
        if (feedDecimals != USD_DECIMALS) {
            revert UnexpectedFeedDecimals(c.feed, feedDecimals);
        }

        _tokenDecimals[c.token] = IERC20Metadata(c.token).decimals();
        _components.push(c);
    }

    maxPriceAge = maxPriceAge_;
    factory = msg.sender;
}

four functions carry the design

The contract tour

mint pulls each component with transferFrom and issues shares in the same transaction, so every share is fully backed from the moment it exists. The load-bearing detail is the rounding direction: amounts round up, so even the smallest mint pays its full share of the backing and the reserves stay whole.

contracts/src/BasketToken.sol

L83–L96 on GitHub ↗
// deposit the components and mint shares, amounts round up so minters never underpay
function mint(uint256 shares, address to) external nonReentrant {
    if (shares == 0) revert ZeroShares();

    uint256 count = _components.length;
    for (uint256 i = 0; i < count; i++) {
        Component memory c = _components[i];
        uint256 amount = Math.mulDiv(c.unitsPerShare, shares, SHARE_UNIT, Math.Rounding.Ceil);
        IERC20(c.token).safeTransferFrom(msg.sender, address(this), amount);
    }

    _mint(to, shares);
    emit Minted(msg.sender, to, shares);
}

redeem is the mirror: burn first, then transfer out, rounding down. Note what is absent: no allowance check, no oracle read, no pause switch. The exit path depends on nothing but the tokens themselves.

contracts/src/BasketToken.sol

L98–L113 on GitHub ↗
// burn shares and withdraw the components, amounts round down so reserves never overdraw
function redeem(uint256 shares, address to) external nonReentrant {
    if (shares == 0) revert ZeroShares();
    if (to == address(0)) revert ZeroAddress();

    _burn(msg.sender, shares);

    uint256 count = _components.length;
    for (uint256 i = 0; i < count; i++) {
        Component memory c = _components[i];
        uint256 amount = Math.mulDiv(c.unitsPerShare, shares, SHARE_UNIT, Math.Rounding.Floor);
        IERC20(c.token).safeTransfer(to, amount);
    }

    emit Redeemed(msg.sender, to, shares);
}

Valuation is one loop. sharePriceUsd multiplies each Chainlink answer by the units backing one share and sums. The feeds price the raw Stock Token with corporate actions already applied, which is why nothing here touches the ERC-8056 multiplier.

contracts/src/BasketToken.sol

L133–L142 on GitHub ↗
// usd value of one share with 8 decimals
// feeds price the raw stock token with corporate actions applied so no uiMultiplier here
function sharePriceUsd() public view returns (uint256 price) {
    uint256 count = _components.length;
    for (uint256 i = 0; i < count; i++) {
        Component memory c = _components[i];
        uint256 tokenPrice = _readPrice(c.feed);
        price += Math.mulDiv(tokenPrice, c.unitsPerShare, 10 ** _tokenDecimals[c.token]);
    }
}

Every feed read passes through one gate. A non-positive answer or an answer older than maxPriceAge reverts the pricing views, and only the pricing views. This basket uses 4 days because equity feeds pause over the weekend, and the right number depends on the asset class.

contracts/src/BasketToken.sol

L164–L169 on GitHub ↗
function _readPrice(address feed) private view returns (uint256) {
    (, int256 answer,, uint256 updatedAt,) = AggregatorV3Interface(feed).latestRoundData();
    if (answer <= 0) revert InvalidPrice(feed, answer);
    if (block.timestamp > updatedAt + maxPriceAge) revert StalePrice(feed, updatedAt);
    return uint256(answer);
}

A fuzzed invariant backs the rounding guarantees. This property test mints and redeems random amounts and asserts a user keeps everything except at most one wei of dust per component, round trip, while the supply always returns to zero.

contracts/test/BasketToken.t.sol

L293–L305 on GitHub ↗
function testFuzz_mintRedeemRoundTrip(uint96 rawShares) public {
    uint256 shares = bound(uint256(rawShares), 1, 50e18);
    vm.startPrank(alice);
    basket.mint(shares, alice);
    basket.redeem(shares, alice);
    vm.stopPrank();

    // user can lose at most 1 wei of dust per component to rounding
    assertGe(tsla.balanceOf(alice) + 1, 100e18);
    assertGe(amzn.balanceOf(alice) + 1, 100e18);
    assertLe(tsla.balanceOf(alice), 100e18);
    assertEq(basket.totalSupply(), 0);
}

the one new standard

ERC-8056, splits without rebasing

Stock Tokens are ordinary ERC-20s with one extension you will not find on other chains yet. A stock split cannot rewrite every holder's balance without breaking integrations, so raw balances never change. Instead the token exposes a uiMultiplier and display layers multiply: underlying shares equal raw balance times multiplier over 1e18. Contracts keep using raw balances, brokerage UIs call balanceOfUI. This interface was verified selector by selector against the live mainnet TSLA token.

contracts/src/interfaces/IScaledUIAmount.sol

L4–L20 on GitHub ↗
// erc-8056 scaled ui amount extension implemented by robinhood chain stock tokens
// raw balances never rebase, splits apply through the multiplier instead
// underlying shares = raw balance * uiMultiplier / 1e18
// docs: https://docs.robinhood.com/chain/stock-tokens/
interface IScaledUIAmount {
    // 18 decimal fixed point where 1e18 equals 1.0
    function uiMultiplier() external view returns (uint256);

    function balanceOfUI(address account) external view returns (uint256);

    function totalSupplyUI() external view returns (uint256);

    // pending multiplier that activates at effectiveAt
    function newUIMultiplier() external view returns (uint256);

    function effectiveAt() external view returns (uint256);
}

testnet and mainnet

Deploying on Robinhood Chain

One Foundry script serves three networks by switching on block.chainid. Anvil gets mocks for everything, testnet composes the basket from the real faucet Stock Tokens with mock feeds (Chainlink publishes feeds on mainnet only), and mainnet uses the real tokens with the real feeds.

scripts/deploy.sh routes one forge script to three networks, verifies on Blockscout, and hands addresses to the frontend.
scripts/deploy.sh routes one forge script to three networks, verifies on Blockscout, and hands addresses to the frontend.

contracts/script/Deploy.s.sol

L40–L60 on GitHub ↗
function run() external {
    vm.startBroadcast();

    BasketFactory factory = new BasketFactory();
    console2.log("FACTORY=%s", address(factory));

    BasketToken.Component[] memory components;
    if (block.chainid == 4663) {
        components = _mainnetComponents();
    } else if (block.chainid == 46630) {
        components = _testnetComponents();
    } else {
        components = _localComponents();
    }

    address basket = factory.createBasket("Tech Trio", "TRIO", components, MAX_PRICE_AGE);
    console2.log("DEMO_BASKET=%s", basket);
    console2.log("CHAIN_ID=%s", block.chainid);

    vm.stopBroadcast();
}

The mainnet component set is just addresses from the official token contracts page and the Chainlink directory, wired to the same constructor.

contracts/script/Deploy.s.sol

L62–L67 on GitHub ↗
function _mainnetComponents() internal pure returns (BasketToken.Component[] memory c) {
    c = new BasketToken.Component[](3);
    c[0] = BasketToken.Component(MAINNET_TSLA, MAINNET_TSLA_FEED, 0.4e18);
    c[1] = BasketToken.Component(MAINNET_NVDA, MAINNET_NVDA_FEED, 0.3e18);
    c[2] = BasketToken.Component(MAINNET_AAPL, MAINNET_AAPL_FEED, 0.3e18);
}

Replay the real sessions below. The testnet recording is the exact deploy that produced the contracts this site reads. The mainnet tab starts with the rehearsal: fork tests that run this exact configuration against live mainnet state, free of charge, so the real deploy holds no surprises.

robinhood chain testnet · 46630

Press run to replay this session. Commands and output were recorded from a real deployment.

contracts/test/fork/BasketMainnetFork.t.sol

L32–L44 on GitHub ↗
function setUp() public {
    string memory rpc =
        vm.envOr("ROBINHOOD_RPC_URL", string("https://rpc.mainnet.chain.robinhood.com"));
    vm.createSelectFork(rpc);
    assertEq(block.chainid, 4663);

    factory = new BasketFactory();
    BasketToken.Component[] memory c = new BasketToken.Component[](3);
    c[0] = BasketToken.Component(TSLA, TSLA_FEED, 0.4e18);
    c[1] = BasketToken.Component(NVDA, NVDA_FEED, 0.3e18);
    c[2] = BasketToken.Component(AAPL, AAPL_FEED, 0.3e18);
    basket = BasketToken(factory.createBasket("Tech Trio", "TRIO", c, MAX_PRICE_AGE));
}

Inside a fork test, deal gives a test account real Stock Token balances, so mint and redeem run against the actual mainnet bytecode without owning a single token.

contracts/test/fork/BasketMainnetFork.t.sol

L67–L85 on GitHub ↗
function test_mintAndRedeem_withRealStockTokens() public {
    deal(TSLA, alice, 1e18);
    deal(NVDA, alice, 1e18);
    deal(AAPL, alice, 1e18);

    vm.startPrank(alice);
    IERC20Metadata(TSLA).approve(address(basket), type(uint256).max);
    IERC20Metadata(NVDA).approve(address(basket), type(uint256).max);
    IERC20Metadata(AAPL).approve(address(basket), type(uint256).max);

    basket.mint(2e18, alice);
    assertEq(basket.balanceOf(alice), 2e18);
    assertEq(IERC20Metadata(TSLA).balanceOf(address(basket)), 0.8e18);

    basket.redeem(2e18, alice);
    assertEq(basket.totalSupply(), 0);
    assertEq(IERC20Metadata(TSLA).balanceOf(alice), 1e18);
    vm.stopPrank();
}

where your l1 instincts need updating

Not your standard Ethereum chain

Robinhood Chain is EVM equivalent, so almost everything you already know carries over. These five differences are worth learning early, and each one comes with a simple pattern you can build in from day one.

The life of a transaction: FCFS sequencing into ~250ms blocks, batched and posted to Ethereum as blob data. The fee has two parts.
The life of a transaction: FCFS sequencing into ~250ms blocks, batched and posted to Ethereum as blob data. The fee has two parts.

1 · gas estimates include an L1 data fee that moves

Your fee is L2 execution plus the cost of posting your calldata to Ethereum, and that second component can shift a little between estimation and execution. The pattern to adopt: send deployments with generous gas headroom and they land reliably on the first try. Here is how this repo's deploy script does it for every network:

scripts/deploy.sh

L80–L90 on GitHub ↗
# gas estimates on arbitrum stack chains include an l1 data fee component that
# moves between estimation and execution, extra headroom prevents out of gas
# --slow waits for each receipt so later txs never race earlier deployments
OUTPUT=$(cd "$ROOT/contracts" && forge script script/Deploy.s.sol:Deploy \
    --rpc-url "$RPC_URL" \
    --private-key "$PRIVATE_KEY" \
    --broadcast \
    --slow \
    --gas-estimate-multiplier 300 \
    ${VERIFY_ARGS[@]+"${VERIFY_ARGS[@]}"} 2>&1) || {
    echo "$OUTPUT" >&2

2 · block.number is not your block number

On Arbitrum chains block.numberreturns an estimate of the Ethereum block number, which is handy for logic anchored to L1 time. When you want the chain's own ~250ms block height, ArbSys(address(100)).arbBlockNumber() has you covered. Knowing which clock you are reading makes time-based logic easy to get right.

3 · first come, first served, no priority auction

The sequencer orders transactions by arrival time, and paying more gas does not change your place in line. That removes the economics behind sandwich attacks and makes fee estimation predictable.

4 · contracts can be four times bigger

A contract can hold up to 96KB of code here, four times Ethereum's 24KB, with 192KB for constructor code. Features that would need to be split across several contracts on Ethereum can live together in one readable file.

5 · oracles keep market hours

Equity feeds update 24 hours a day, five days a week, matching the markets they track. Set the staleness window per asset class, this basket uses 4 days to cover weekends, and keep transfers independent of pricing, which is how BasketToken splits its functions.

what the stack buys you

The Arbitrum stack underneath

Robinhood Chain runs on Arbitrum Nitro, the same technology as Arbitrum One, deployed as a dedicated chain. As a developer you inherit, without doing anything:

Ethereum settlement

State roots settle to Ethereum with fraud proofs. The canonical bridge is trustless: deposits in ~10 minutes, withdrawals after the 7 day challenge window, security inherited from L1 rather than a multisig.

ETH gas, tiny fees

No new gas token to acquire. The entire five-contract deployment in the terminal above cost 0.00012 ETH on testnet, with blob data keeping mainnet costs in the same neighborhood.

Account abstraction, day one

ERC-4337 EntryPoints v0.6 through v0.8 are deployed at the canonical addresses, EIP-7702 delegation is live, and Alchemy provides bundler and gas sponsorship infrastructure. Details on the account abstraction page.

Rust contracts, too

The chain also runs Stylus, which lets you write contracts in Rust and deploy them right alongside Solidity ones. Stylus is switched on for both testnet and mainnet, and a Solidity contract can call a Rust one the same way it calls any other contract.

Build your own basket

Clone the repo, run the local flow in five commands, then point the deploy script at testnet with faucet funds. The README covers every step, including the MetaMask setup and the addresses cheat sheet.