What it is
Cairn buys a tokenized stock for you on a schedule: a fixed number of USDG, every day, week, fortnight or thirty days, a set number of times. Each plan lives in the Cairn contract on Robinhood Chain, in your name. The shares are sent to your wallet by the same transaction that buys them.
Cairn has no owner, no fee, no admin key and no upgrade path. The page is a website over it; you can use the contract from the explorer without the page.
What a buy does
- Checks the plan has buys left and that its day has come (
block.timestamp ≥ next). - Reads Robinhood's price feed for the stock and computes the fewest shares the buy may return (below).
- Moves the next day forward by one period — or, if the buy is late by more than a period, to one period from now, so missed buys are never run in a burst.
- Pulls
amount + tipUSDG from the owner, using the allowance the owner gave. - Calls Uniswap's
SwapRouter02.exactInputSinglethrough the plan's pool withrecipient = ownerandamountOutMinimum = floor. If the pool would give less, Uniswap reverts and nothing moves. - Pays the tip to whoever sent the transaction and emits
Boughtwith the shares that arrived.
Between buys the contract holds nothing. Its only power over your money is the USDG allowance, which you can revoke at any time.
The price floor
Robinhood publishes a Chainlink-style price feed for 26 of its stock tokens. At every buy Cairn computes, in integers:
shares = amount · 10¹² · 10^decimals / answer, then shares · (10000 − slippage) / 10000, then converts shares to raw token units with the token's own uiMultiplier() (Robinhood's stock tokens are scaled for splits and dividends).
A feed whose last price is more than four days old stops every buy of that stock: that covers a normal weekend and a long one, and refuses a market that has been shut for longer. The default slippage the page suggests is what the stock's pool measurably charged over the feed at that size, plus one percent.
Limits
| Shortest period between buys | 1 hour |
| Largest slippage a plan may allow | 10% |
| Largest tip | 1% of the buy |
| Oldest feed price a buy will use | 4 days |
| Cairn fee | none |
Who runs buys
Anyone may call run(id) on a due plan, and is paid its tip. That is the whole keeper system: the Due now page lists what is due and lets anyone run it, and you can run your own plans from My plans. Whoever runs a buy chooses only when, within what you allowed: never earlier than its day, never below your floor, never to anyone but you.
Honest limit: if nobody presses the button, a buy waits. Cairn does not run a server that buys for you. A plan with a tip of a few cents is worth running for anyone with a wallet on Robinhood Chain, where a buy costs a fraction of a cent in gas.
Stopping
cancel(id) sets a plan's remaining buys to zero; only its owner can call it. Revoking Cairn's USDG allowance stops every plan of that wallet at once. Neither touches shares you already bought.
Risks
- Not audited. The contract is 5207 bytes and tested as described below, but no one has audited it.
- Stock prices move. A recurring buy averages your entry price; it does not protect you from a fall.
- Tokenized stocks are not brokerage shares. Robinhood's tokens are governed by Robinhood's terms, including pause and blocklist powers over each token.
- A feed or a pool can be wrong. The floor protects a buy from a pool priced below the feed; it cannot protect you if the feed itself is wrong.
The contract
Cairn is deployed by whoever starts the first plan, through the deterministic CREATE2 deployer, so its address is fixed by its code and nobody holds a key to it:
| Address | 0x99384d3536b0eE99AA38E61A5b1ee1053a660fBD |
| Init code hash | 0xbe691912513930cc687480b8e182860311664b2f7984fb6e6c193385622b285e |
| Runtime code hash | 0x704b709d36020543b3f6b5493d4803344a81ce8159f6fd82dfdb17f106a9ab15 |
| 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 — recurring buys of Robinhood stock tokens.
/// @notice A plan says: every `every` seconds, spend `amount` USDG on `stock` through one Uniswap v3 pool, `count`
/// times. The contract holds nothing between buys. It pulls each buy's dollars from the owner by ALLOWANCE at the
/// moment of the buy, sends the shares straight to the owner, and pays whoever pressed the button a tip the owner set.
/// Anyone may run a due buy. What stops a stranger running it at a bad price is a floor taken from the price feed the
/// owner chose: the swap must return at least (1 - slip) of the shares that feed says the dollars are worth.
/// No owner, no fee, no upgrade, no storage of anyone's tokens. Revoke the allowance and every plan stops.
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 IScaled { function uiMultiplier() external view returns (uint256); }
interface IFeed {
function decimals() external view returns (uint8);
function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80);
}
interface IRouter {
struct ExactInputSingleParams {
address tokenIn; address tokenOut; uint24 fee; address recipient;
uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96;
}
function exactInputSingle(ExactInputSingleParams calldata) external payable returns (uint256);
}
contract Cairn {
address public constant USDG = 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168;
address public constant ROUTER = 0xCaf681a66D020601342297493863E78C959E5cb2; // Uniswap SwapRouter02
uint256 public constant MAX_AGE = 4 days; // stock feeds stop printing when the market shuts; a long weekend is ~88 h
uint256 public constant MAX_SLIP_BPS = 1000; // 10%
uint256 public constant MIN_EVERY = 1 hours;
struct Plan {
address owner; uint64 next; uint32 every; // slot 0
address stock; uint24 fee; uint16 slipBps; uint32 left; // slot 1
address feed; // slot 2
uint128 amount; uint128 tip; // slot 3 (USDG, 6 decimals)
}
Plan[] internal _plans;
uint256 private _entered; // 0 = free, 1 = inside a buy. Zero is the resting state, so no initialiser is needed.
event Created(uint256 indexed id, address indexed owner, address indexed stock, uint24 fee, address feed,
uint128 amount, uint128 tip, uint32 every, uint32 count, uint16 slipBps, uint64 first);
event Bought(uint256 indexed id, address indexed runner, uint256 spent, uint256 shares, uint256 floor, uint128 tip, uint32 left, uint64 next);
event Cancelled(uint256 indexed id);
error BadPlan();
error NotOwner();
error NotDue();
error Finished();
error BadPrice();
error StalePrice();
error Reentrant();
error TransferFailed();
function create(address stock, uint24 fee, address feed, uint128 amount, uint128 tip, uint32 every,
uint32 count, uint16 slipBps, uint64 first) external returns (uint256 id) {
if (stock == address(0) || stock == USDG || feed == address(0) || amount == 0 || count == 0
|| every < MIN_EVERY || slipBps > MAX_SLIP_BPS || tip > amount / 100) revert BadPlan();
if (first < block.timestamp) first = uint64(block.timestamp);
id = _plans.length;
_plans.push(Plan(msg.sender, first, every, stock, fee, slipBps, count, feed, amount, tip));
emit Created(id, msg.sender, stock, fee, feed, amount, tip, every, count, slipBps, first);
}
function cancel(uint256 id) external {
Plan storage p = _plans[id];
if (p.owner != msg.sender) revert NotOwner();
p.left = 0;
emit Cancelled(id);
}
/// @notice The fewest raw token units this plan's next buy may return, from the owner's feed, now.
function floorOf(uint256 id) public view returns (uint256) {
Plan storage p = _plans[id];
(, int256 answer,, uint256 updatedAt,) = IFeed(p.feed).latestRoundData();
if (answer <= 0) revert BadPrice();
if (updatedAt + MAX_AGE < block.timestamp) revert StalePrice();
// shares (18 decimals) the dollars are worth at the feed: amount·1e12 · 10^fd / answer
uint256 shares = uint256(p.amount) * 1e12 * (10 ** IFeed(p.feed).decimals()) / uint256(answer);
shares = shares * (10_000 - p.slipBps) / 10_000;
// Robinhood stock tokens are ERC20ScaledUI: shares = raw · uiMultiplier / 1e18, so raw = shares · 1e18 / m.
return shares * 1e18 / IScaled(p.stock).uiMultiplier();
}
function run(uint256 id) external returns (uint256 got) {
if (_entered != 0) revert Reentrant();
_entered = 1;
Plan storage p = _plans[id];
if (p.left == 0) revert Finished();
if (block.timestamp < p.next) revert NotDue();
uint256 floor = floorOf(id);
uint64 next = p.next + p.every;
if (next <= block.timestamp) next = uint64(block.timestamp) + p.every; // missed buys are not caught up in a burst
p.next = next;
uint32 left = --p.left;
address owner = p.owner;
uint128 amount = p.amount; uint128 tip = p.tip;
_pull(owner, uint256(amount) + tip);
_call(USDG, abi.encodeCall(IERC20.approve, (ROUTER, amount)));
got = IRouter(ROUTER).exactInputSingle(IRouter.ExactInputSingleParams(
USDG, p.stock, p.fee, owner, amount, floor, 0));
if (tip != 0) _call(USDG, abi.encodeCall(IERC20.transfer, (msg.sender, tip)));
emit Bought(id, msg.sender, amount, got, floor, tip, left, next);
_entered = 0;
}
function plan(uint256 id) external view returns (Plan memory) { return _plans[id]; }
function count() external view returns (uint256) { return _plans.length; }
function _pull(address from, uint256 amt) private {
_call(USDG, abi.encodeCall(IERC20.transferFrom, (from, address(this), amt)));
}
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 TransferFailed();
}
}
Addresses
| Cairn (recurring buys) | 0x99384d3536b0eE99AA38E61A5b1ee1053a660fBD |
| CREATE2 deployer (deploys Cairn) | 0x4e59b44847b379578588920cA78FbF26c0B4956C |
| Uniswap v3 SwapRouter02 | 0xCaf681a66D020601342297493863E78C959E5cb2 |
| Uniswap v3 QuoterV2 (page quotes) | 0x33e885eD0Ec9bF04EcfB19341582aADCb4c8A9E7 |
| USDG (what you pay with) | 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 |
| Multicall3 (reads) | 0xcA11bde05977b3631167028862bE2a173976CA11 |
Stocks and feeds
Each plan buys through the pool that returned the most shares for $25 at block 71,637,930, and is floored by the feed below.
Tests
Every property runs the page's own transactions against live Robinhood Chain state — the real USDG, the real pools, the real feeds — inside eth_simulateV1, moving the clock forward to make weekly buys due. Nothing is broadcast. Expected amounts are computed by the test from the feed and the token directly, and every refusal must be the specific error its guard raises. 12/12 properties pass, with 85 checks.
| Property | Checks | Result |
|---|---|---|
| the page deploys the published build at the published address | 3 | pass |
| a due buy spends exactly the plan, tips the runner, and sends every share to the owner | 11 | pass |
| a buy is refused until it is due, and never twice in one period | 8 | pass |
| missed buys are not caught up in a burst | 8 | pass |
| a plan stops after its last buy, and its budget was exactly enough | 8 | pass |
| only the owner can stop a plan, and a stopped plan never buys | 5 | pass |
| the feed floor refuses a pool worse than the owner allowed, and allows one within it | 8 | pass |
| a feed older than four days stops every buy | 5 | pass |
| a buy the owner cannot pay for is refused and moves nothing | 7 | pass |
| the contract refuses a plan outside its limits | 8 | pass |
| the tip is paid in full and never above the cap | 5 | pass |
| two owners' plans never touch each other | 9 | pass |
To test the tests, a sweep plants one bug at a time in the contract — deleting the floor, the due check, the stale-price check, sending the shares to the runner, and so on — and runs every property against the broken build. PENDING planted bugs were caught by the property written for them.
Finally the whole journey runs in a real browser against the same simulated chain: the page deploys Cairn exactly as the first real user will, approves, starts a plan, runs its first buy, jumps a week and runs the next from the public board, stops the plan and starts another — PENDING stages, PENDING checks, every outcome read back from the chain.
| Planted bug | Verdict | Caught by |
|---|