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.

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.

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.

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" >&22 · 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.