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
- You approve the cairn to take up to your maximum in USDG, and call
buy(shares, maxUsdg, to). - The cairn takes your maximum, then for each stock asks Uniswap's
SwapRouter02.exactOutputSinglefor exactly the rounded-up amount the shares need, never spending more than what is left of your maximum. - 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 address | 0x5166514fc563624C7589C952FF67a02DbF01E406 |
| Init code hash | 0xee37ad9e1ed197cfff9dbd15cd5aa42f621a099883b7fe9cfe4e6def633cde60 |
| Runtime code hash | 0xae3c6931d1a95c73487db72418ce66251d29d8527c3aaec12d342e19d8995a37 |
| Compiler | solc 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 SwapRouter02 | 0xCaf681a66D020601342297493863E78C959E5cb2 |
| 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).
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.
| Property | Checks | Result |
|---|---|---|
| the factory lands at the published address and makes a cairn exactly where it said it would | 7 | pass |
| the factory refuses a basket it cannot honour | 11 | pass |
| minting in kind takes each stock rounded up, redeeming returns it rounded down, and every share stays backed | 23 | pass |
| a stranger cannot redeem shares that are not theirs | 12 | pass |
| buying with dollars spends only what the pools charge and refunds the rest, in one transaction | 13 | pass |
| a buy that would cost more than the limit is refused and moves nothing | 6 | pass |
| selling pays at least the limit to the named wallet, and a limit above the market is refused | 12 | pass |
| two cairns never touch each other's stocks | 11 | pass |
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 bug | Verdict | Caught by |
|---|---|---|
mint-rounds-down | caught | minting in kind takes each stock rounded up, redeeming returns it rounded down, and every share stays backed |
redeem-rounds-up | caught | minting in kind takes each stock rounded up, redeeming returns it rounded down, and every share stays backed |
mint-to-payer | caught | minting in kind takes each stock rounded up, redeeming returns it rounded down, and every share stays backed |
redeem-burns-nothing | caught | minting in kind takes each stock rounded up, redeeming returns it rounded down, and every share stays backed |
transferFrom-no-allowance | caught | a stranger cannot redeem shares that are not theirs |
no-refund | caught | buying with dollars spends only what the pools charge and refunds the rest, in one transaction |
router-approval-left | caught | buying with dollars spends only what the pools charge and refunds the rest, in one transaction |
buy-ignores-limit | redundant guard | the 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-pull | caught | buying with dollars spends only what the pools charge and refunds the rest, in one transaction |
buy-mints-to-payer | caught | buying with dollars spends only what the pools charge and refunds the rest, in one transaction |
buy-short-one-leg | caught | buying with dollars spends only what the pools charge and refunds the rest, in one transaction |
event-lies | caught | buying with dollars spends only what the pools charge and refunds the rest, in one transaction |
sell-no-minimum | caught | selling pays at least the limit to the named wallet, and a limit above the market is refused |
sell-pays-seller | caught | selling pays at least the limit to the named wallet, and a limit above the market is refused |
sell-burns-nothing | caught | selling pays at least the limit to the named wallet, and a limit above the market is refused |
duplicates-allowed | caught | the factory refuses a basket it cannot honour |
zero-units-allowed | caught | the factory refuses a basket it cannot honour |
any-fee-tier | caught | the factory refuses a basket it cannot honour |
address-lies | caught | the factory lands at the published address and makes a cairn exactly where it said it would |
contents-swapped | caught | the 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.