Docs

How Cairn works

One small factory, the baskets it makes, and Uniswap. Everything here can be checked on the explorer.

What it is

A cairn is an ERC-20 token backed by a fixed basket of Robinhood's tokenized stocks. One whole share (1018 units) is always backed by exactly the same amount of each stock, set when the cairn was made. The cairn contract holds the stocks itself; there is no owner, no fee and no way to change what is in it.

Backing and rounding

For n shares a cairn needs units[i] · n / 10¹⁸ of each stock. Minting takes that amount rounded up; redeeming and selling release it rounded down. So after any sequence of mints and redeems, the cairn holds at least units[i] · supply / 10¹⁸ of every stock — every share is fully backed, and the rounding dust stays behind for everyone.

Buying with dollars

  1. You approve the cairn to take up to your maximum in USDG, and call buy(shares, maxUsdg, to).
  2. The cairn takes your maximum, then for each stock asks Uniswap's SwapRouter02.exactOutputSingle for exactly the rounded-up amount the shares need, never spending more than what is left of your maximum.
  3. It mints the shares to to, sets the router's allowance back to zero, and sends every unspent dollar back to you — all in the same transaction.

The page quotes every leg live with Uniswap's QuoterV2 and sets your maximum 1% above the quote. If prices move further, the whole transaction reverts and nothing moves.

Selling for dollars

sell(shares, minUsdg, to) burns your shares, sells each stock through its pool (exactInputSingle), sends the dollars to to, and reverts unless the total is at least your minimum (the page sets it 1% under the live quote).

Redeeming for the stocks

redeem(shares, to) burns your shares and sends the stocks themselves. No pool, no price, no one's permission. mint(shares, to) is the reverse: hand over the stocks, receive shares.

Making a cairn

The factory's make(name, symbol, tokens, units, fees) creates the cairn with CREATE2 over a hash of everything it was given, so its address is known before it exists and the same basket under the same name can only exist once. The page sets the units so that one share is worth $10 at Robinhood's price feeds at that moment, split by your weights. The factory refuses an empty basket, more than eight stocks, a stock twice, a zero amount, USDG as a stock, a Uniswap fee tier that does not exist, and an empty name or ticker.

The ready-made cairns on the front page have contents frozen once (on the day they were published), so their addresses are permanent; the first person to buy one makes it.

Risks

  • Not audited. The factory is 9730 bytes and each cairn 5636 bytes, tested as described below, but no one has audited them.
  • No rebalancing. The amount of each stock per share never changes, so the weights drift with prices, exactly as if you held the stocks yourself.
  • Pools cost money. Buying and selling pay each pool's fee and its price impact. The page shows the total against Robinhood's prices before you sign.
  • Tokenized stocks are not brokerage shares. Robinhood's tokens are governed by Robinhood's terms, including pause and blocklist powers over each token — a paused stock would stop a cairn that holds it from minting and redeeming until it is unpaused.

The contract

The factory is deployed by whoever makes the first cairn, through the deterministic CREATE2 deployer, so its address is fixed by its code and nobody holds a key to it:

Factory address0x5166514fc563624C7589C952FF67a02DbF01E406
Init code hash0xee37ad9e1ed197cfff9dbd15cd5aa42f621a099883b7fe9cfe4e6def633cde60
Runtime code hash0xae3c6931d1a95c73487db72418ce66251d29d8527c3aaec12d342e19d8995a37
Compilersolc 0.8.26, optimizer 200 runs, EVM cancun, salt 0
Read the source (Cairn.sol)
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

/// @title Cairn — stack several Robinhood stock tokens into one ERC-20.
/// @notice A cairn is a basket with FIXED contents: one whole cairn share (1e18) is backed by exactly `units[i]` raw
/// units of each stock `tokens[i]`, forever. Anyone can:
///   • mint in kind  — hand over each stock (rounded UP) and receive shares;
///   • redeem in kind — burn shares and receive each stock (rounded DOWN), so every share stays fully backed;
///   • buy with USDG — the cairn buys each stock through its Uniswap v3 pool (exact output) and mints, refunding
///     whatever of the caller's maximum was not spent, all in one transaction;
///   • sell for USDG — burn shares, sell each stock through its pool, and pay out at least the caller's minimum.
/// There is no owner, no fee, no upgrade, no oracle and nothing anyone can change after a cairn is made.
interface IERC20 {
    function transferFrom(address, address, uint256) external returns (bool);
    function transfer(address, uint256) external returns (bool);
    function approve(address, uint256) external returns (bool);
}
interface IRouter {
    struct ExactOutputSingleParams {
        address tokenIn; address tokenOut; uint24 fee; address recipient;
        uint256 amountOut; uint256 amountInMaximum; uint160 sqrtPriceLimitX96;
    }
    struct ExactInputSingleParams {
        address tokenIn; address tokenOut; uint24 fee; address recipient;
        uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96;
    }
    function exactOutputSingle(ExactOutputSingleParams calldata) external payable returns (uint256);
    function exactInputSingle(ExactInputSingleParams calldata) external payable returns (uint256);
}

address constant USDG = 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168;
address constant ROUTER = 0xCaf681a66D020601342297493863E78C959E5cb2; // Uniswap SwapRouter02

error Refused();          // a transfer or approval the token did not accept
error BadBasket();        // the factory refused those contents
error TooExpensive();     // a buy would cost more than the caller's maximum
error TooLittle();        // a sale would pay less than the caller's minimum
error Reentrant();
error NoShares();

contract CairnBasket {
    string public name;
    string public symbol;
    uint8 public constant decimals = 18;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    address[] internal _tokens;
    uint256[] internal _units;   // raw units of _tokens[i] behind one whole share (1e18)
    uint24[] internal _fees;     // the Uniswap v3 fee tier of the USDG pool each token is bought and sold through
    uint256 private _entered;    // 0 = free, 1 = inside mint/redeem/buy/sell

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
    event Minted(address indexed by, address indexed to, uint256 shares, uint256 usdgSpent);
    event Redeemed(address indexed by, address indexed to, uint256 shares, uint256 usdgReceived);

    constructor(string memory name_, string memory symbol_, address[] memory tokens_, uint256[] memory units_, uint24[] memory fees_) {
        name = name_; symbol = symbol_;
        _tokens = tokens_; _units = units_; _fees = fees_;
    }

    modifier lock() { if (_entered != 0) revert Reentrant(); _entered = 1; _; _entered = 0; }

    // ------------------------------------------------------------------ the basket
    function contents() external view returns (address[] memory tokens, uint256[] memory units, uint24[] memory fees) {
        return (_tokens, _units, _fees);
    }
    /// @notice Raw units of each stock that `shares` cost to mint (rounded up) or return on redeem (rounded down).
    function amountsFor(uint256 shares, bool roundUp) public view returns (uint256[] memory out) {
        out = new uint256[](_tokens.length);
        for (uint256 i; i < out.length; i++) {
            uint256 p = _units[i] * shares;
            out[i] = p / 1e18 + (roundUp && p % 1e18 != 0 ? 1 : 0);
        }
    }

    // ------------------------------------------------------------------ in kind
    function mint(uint256 shares, address to) external lock {
        if (shares == 0) revert NoShares();
        uint256[] memory amt = amountsFor(shares, true);
        for (uint256 i; i < amt.length; i++) _call(_tokens[i], abi.encodeCall(IERC20.transferFrom, (msg.sender, address(this), amt[i])));
        _mint(to, shares);
        emit Minted(msg.sender, to, shares, 0);
    }
    function redeem(uint256 shares, address to) external lock {
        if (shares == 0) revert NoShares();
        _burn(msg.sender, shares);
        uint256[] memory amt = amountsFor(shares, false);
        for (uint256 i; i < amt.length; i++) if (amt[i] != 0) _call(_tokens[i], abi.encodeCall(IERC20.transfer, (to, amt[i])));
        emit Redeemed(msg.sender, to, shares, 0);
    }

    // ------------------------------------------------------------------ with dollars, through Uniswap
    /// @notice Buy `shares` for at most `maxUsdg`: every stock is bought at exactly the amount a mint needs, and
    /// every unspent dollar goes back to the caller in the same transaction.
    function buy(uint256 shares, uint256 maxUsdg, address to) external lock returns (uint256 spent) {
        if (shares == 0) revert NoShares();
        _call(USDG, abi.encodeCall(IERC20.transferFrom, (msg.sender, address(this), maxUsdg)));
        _call(USDG, abi.encodeCall(IERC20.approve, (ROUTER, maxUsdg)));
        uint256[] memory amt = amountsFor(shares, true);
        for (uint256 i; i < amt.length; i++) {
            spent += IRouter(ROUTER).exactOutputSingle(IRouter.ExactOutputSingleParams(
                USDG, _tokens[i], _fees[i], address(this), amt[i], maxUsdg - spent, 0));
        }
        if (spent > maxUsdg) revert TooExpensive();   // the router already refuses this per leg; kept for clarity
        _call(USDG, abi.encodeCall(IERC20.approve, (ROUTER, 0)));
        if (maxUsdg > spent) _call(USDG, abi.encodeCall(IERC20.transfer, (msg.sender, maxUsdg - spent)));
        _mint(to, shares);
        emit Minted(msg.sender, to, shares, spent);
    }
    /// @notice Sell `shares` for at least `minUsdg`, paid to `to`.
    function sell(uint256 shares, uint256 minUsdg, address to) external lock returns (uint256 got) {
        if (shares == 0) revert NoShares();
        _burn(msg.sender, shares);
        uint256[] memory amt = amountsFor(shares, false);
        for (uint256 i; i < amt.length; i++) {
            if (amt[i] == 0) continue;
            _call(_tokens[i], abi.encodeCall(IERC20.approve, (ROUTER, amt[i])));
            got += IRouter(ROUTER).exactInputSingle(IRouter.ExactInputSingleParams(
                _tokens[i], USDG, _fees[i], to, amt[i], 0, 0));
        }
        if (got < minUsdg) revert TooLittle();
        emit Redeemed(msg.sender, to, shares, got);
    }

    // ------------------------------------------------------------------ ERC-20
    function transfer(address to, uint256 v) external returns (bool) { _move(msg.sender, to, v); return true; }
    function approve(address s, uint256 v) external returns (bool) { allowance[msg.sender][s] = v; emit Approval(msg.sender, s, v); return true; }
    function transferFrom(address f, address to, uint256 v) external returns (bool) {
        uint256 a = allowance[f][msg.sender];
        if (a != type(uint256).max) allowance[f][msg.sender] = a - v;
        _move(f, to, v); return true;
    }
    function _move(address f, address to, uint256 v) private { balanceOf[f] -= v; balanceOf[to] += v; emit Transfer(f, to, v); }
    function _mint(address to, uint256 v) private { totalSupply += v; balanceOf[to] += v; emit Transfer(address(0), to, v); }
    function _burn(address f, uint256 v) private { balanceOf[f] -= v; totalSupply -= v; emit Transfer(f, address(0), v); }
    function _call(address token, bytes memory data) private {
        (bool ok, bytes memory ret) = token.call(data);
        if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert Refused();
    }
}

/// @notice Makes cairns. The same contents always make the same cairn at the same address (CREATE2 over a hash of the
/// name, symbol and contents), so a basket can be linked to before anyone has made it, and nobody can make a second
/// cairn with identical contents under the same name.
contract Cairn {
    address[] public all;
    event Made(address indexed cairn, address indexed by, string name, string symbol, address[] tokens, uint256[] units, uint24[] fees);

    function saltOf(string calldata name, string calldata symbol, address[] calldata tokens, uint256[] calldata units, uint24[] calldata fees) public pure returns (bytes32) {
        return keccak256(abi.encode(name, symbol, tokens, units, fees));
    }
    function addressOf(string calldata name, string calldata symbol, address[] calldata tokens, uint256[] calldata units, uint24[] calldata fees) external view returns (address) {
        bytes32 h = keccak256(abi.encodePacked(type(CairnBasket).creationCode, abi.encode(name, symbol, tokens, units, fees)));
        return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), saltOf(name, symbol, tokens, units, fees), h)))));
    }
    function make(string calldata name, string calldata symbol, address[] calldata tokens, uint256[] calldata units, uint24[] calldata fees) external returns (address c) {
        uint256 n = tokens.length;
        if (n == 0 || n > 8 || units.length != n || fees.length != n || bytes(name).length == 0 || bytes(name).length > 40
            || bytes(symbol).length == 0 || bytes(symbol).length > 12) revert BadBasket();
        for (uint256 i; i < n; i++) {
            if (tokens[i] == address(0) || tokens[i] == USDG || units[i] == 0) revert BadBasket();
            if (fees[i] != 100 && fees[i] != 500 && fees[i] != 3000 && fees[i] != 10000) revert BadBasket();
            for (uint256 j; j < i; j++) if (tokens[j] == tokens[i]) revert BadBasket();
        }
        c = address(new CairnBasket{salt: saltOf(name, symbol, tokens, units, fees)}(name, symbol, tokens, units, fees));
        all.push(c);
        emit Made(c, msg.sender, name, symbol, tokens, units, fees);
    }
    function count() external view returns (uint256) { return all.length; }
}

Addresses

Cairn factory (makes every cairn)0x5166514fc563624C7589C952FF67a02DbF01E406
CREATE2 deployer (deploys the factory)0x4e59b44847b379578588920cA78FbF26c0B4956C
Uniswap v3 SwapRouter020xCaf681a66D020601342297493863E78C959E5cb2
Uniswap v3 QuoterV2 (page quotes)0x33e885eD0Ec9bF04EcfB19341582aADCb4c8A9E7
USDG (what you pay with)0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168
Multicall3 (reads)0xcA11bde05977b3631167028862bE2a173976CA11

Stocks

Each stock is bought and sold through the pool that returned the most shares for $25 at block 73,074,128; its Robinhood feed values it on this site (the contract itself uses no oracle).

StockTokenRobinhood feedPool fee
NVDA0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC0xCF169363636D73dbBf77733629CB38919d14232d0.05%
SGOV0x92FD66527192E3e61d4DDd13322Aa222DE86F9B50xa7a18Ca3F19E17FfA28F92302B817Ca8c1A94b060.05%
SLV0x411eFb0E7f985935DAec3D4C3ebaEa0d0AD7D89f0xdA81cD9c76F1D3Ea32655dfFc7408ef22BB0Ee2a0.05%
AAPL0xaF3D76f1834A1d425780943C99Ea8A608f8a93f90x4bDbb3150014c6Ab2C6D9347B0779c49015a2f3f0.05%
SPY0x117cc2133c37B721F49dE2A7a74833232B3B4C0C0xa68CA83408bE3f78d1c58a82081c619e9d21486d0.05%
META0xc0D6457C16Cc70d6790Dd43521C899C87ce02f350x5cBC53D382E56cBb223f118CF8Eefb6c9c2759f50.30%
QQQ0xD5f3879160bc7c32ebb4dC785F8a4F505888de680x41ed2c58611790af0760e31e80Bb427e4e83D6030.05%
GLD0xC9a981FEE1F9DEc688bb123ccDeCc63D0deBFC4e0x470A51258068043bd43dC0a56245625C9fE86eB00.05%
GOOGL0x2e0847E8910a9732eB3fb1bb4b70a580ADAD4FE30xA04EE5c4c8827F17e82f93bE9e19DeA221A749a80.05%
USAR0xd917B029C761D264c6A312BBbcDA868658eF86a60x451B1295aA84FD6d6b58af1a5002eA1b1A1913A00.30%
SPCX0x4a0E65A3EcceC6dBe60AE065F2e7bb85Fae35eEa0x42a95341ff361e81fd934F39943c5C98F69918440.05%
TSLA0x322F0929c4625eD5bAd873c95208D54E1c003b2d0xE4479F01738B4e8C428CD8eB72D47AB9BC3c7de60.05%
AMZN0x12f190a9F9d7D37a250758b26824B97CE941bF540x9244830430bC7D9C9A48dd47603F24AD61f7c56e0.30%
GME0x1b0E319c6A659F002271B69dB8A7df2F911c153E0x42A4652D447A5B0bccF3B265bE8530b85A33b3A20.05%
MU0xfF080c8ce2E5feadaCa0Da81314Ae59D232d4afD0x5b40F4E78FA58B60a4F59b8cc8cB8d2Fb06904670.30%
AMD0x86923f96303D656E4aa86D9d42D1e57ad2023fdC0xF6d57763DFa625F4A413485261Ab2E71Ff4304CF0.30%
PLTR0x894E1EC2D74FFE5AEF8Dc8A9e84686acCB964F2A0x8cd1DFC0fc61fcA55FA77b37e008A90f13364Fce0.30%
MSFT0xe93237C50D904957Cf27E7B1133b510C669c2e740xaD6D88eab22aa4867Efe807a5311Ed64962f740D0.30%
INTC0xc72b96e0E48ecd4DC75E1e45396e26300BC396810x127B1DeDeE6269E962a59E6C1295b4002c56c4030.30%
ASML0x47F93d52cBeC7C6D2CfC080e154002370a60dAEA0x3eFBba343e2b1cF9ed4d4D5768e20B70307Aa8c90.30%
TSM0x58FfE4a942d3885bAa22D7520691F611EF09e7AA0xB48D6D5729Ca032ca43729F4b605bBf9f257A84d0.05%
MSTR0xec262a75e413fAfD0dF80480274532C79D42da090x2521a77F42098357e83bDea7fBb2A38745bf92800.30%
SNDK0xB90A19fF0Af67f7779afF50A882A9CfF424464000xd1016D9Da414B13D55abe02221A8A145eB89aA0D1.00%
DELL0x941AE714EC6D8130c7B75d67160Ca08f1e7d11Dd0xe9B94828424a8Efe6e773c28AB5Fd486851867c81.00%
CRCL0xdF0992E440dD0be65BD8439b609d6D4366bf1CB50x025Ba3B3569Ca7d15Da7BFC1648F13F06A0728510.30%
USO0xa30FA36Db767ad9eD3f7a60fC79526fB4d56D3440x6D054DECb74Cf8ef3675B0Abc100e02921176EdF0.30%

Tests

Every property runs the page's own transactions against live Robinhood Chain state — the real USDG, the real stock tokens, the real Uniswap pools — inside eth_simulateV1, with the factory deployed through the real CREATE2 deployer. Nothing is broadcast. Expected amounts are computed by the test in plain integer arithmetic, share amounts are deliberately not round so every rounding direction shows, and every refusal must be the specific error its guard raises. 8/8 properties pass, with 95 checks.

PropertyChecksResult
the factory lands at the published address and makes a cairn exactly where it said it would7pass
the factory refuses a basket it cannot honour11pass
minting in kind takes each stock rounded up, redeeming returns it rounded down, and every share stays backed23pass
a stranger cannot redeem shares that are not theirs12pass
buying with dollars spends only what the pools charge and refunds the rest, in one transaction13pass
a buy that would cost more than the limit is refused and moves nothing6pass
selling pays at least the limit to the named wallet, and a limit above the market is refused12pass
two cairns never touch each other's stocks11pass

To test the tests, a sweep plants one bug at a time in the contract — rounding the wrong way, skipping the refund, ignoring the buyer's limit, paying the seller instead of the named wallet, burning nothing, and so on — and runs every property against the broken build. 19/20 planted bugs were caught by the property written for them.

Planted bugVerdictCaught by
mint-rounds-downcaughtminting in kind takes each stock rounded up, redeeming returns it rounded down, and every share stays backed
redeem-rounds-upcaughtminting in kind takes each stock rounded up, redeeming returns it rounded down, and every share stays backed
mint-to-payercaughtminting in kind takes each stock rounded up, redeeming returns it rounded down, and every share stays backed
redeem-burns-nothingcaughtminting in kind takes each stock rounded up, redeeming returns it rounded down, and every share stays backed
transferFrom-no-allowancecaughta stranger cannot redeem shares that are not theirs
no-refundcaughtbuying with dollars spends only what the pools charge and refunds the rest, in one transaction
router-approval-leftcaughtbuying with dollars spends only what the pools charge and refunds the rest, in one transaction
buy-ignores-limitredundant guardthe cairn only ever holds the buyer's maximum, so the router cannot overspend even without the per-leg limit (caught once both guards go: buy-limit-and-pull)
buy-limit-and-pullcaughtbuying with dollars spends only what the pools charge and refunds the rest, in one transaction
buy-mints-to-payercaughtbuying with dollars spends only what the pools charge and refunds the rest, in one transaction
buy-short-one-legcaughtbuying with dollars spends only what the pools charge and refunds the rest, in one transaction
event-liescaughtbuying with dollars spends only what the pools charge and refunds the rest, in one transaction
sell-no-minimumcaughtselling pays at least the limit to the named wallet, and a limit above the market is refused
sell-pays-sellercaughtselling pays at least the limit to the named wallet, and a limit above the market is refused
sell-burns-nothingcaughtselling pays at least the limit to the named wallet, and a limit above the market is refused
duplicates-allowedcaughtthe factory refuses a basket it cannot honour
zero-units-allowedcaughtthe factory refuses a basket it cannot honour
any-fee-tiercaughtthe factory refuses a basket it cannot honour
address-liescaughtthe factory lands at the published address and makes a cairn exactly where it said it would
contents-swappedcaughtthe factory lands at the published address and makes a cairn exactly where it said it would

Finally the whole journey runs in a real browser against the same simulated chain: the page deploys the factory exactly as the first real user will, makes a cairn, approves, buys it with dollars, sells part, redeems part for the stocks and opens a ready-made cairn — 8/8 stages, 43 checks, every outcome read back from the chain.