// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; /// Minimal token surface. Robinhood stock tokens and USDG return bool from transfers. interface IERC20 { function transfer(address to, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256); function decimals() external view returns (uint8); } /// Chainlink aggregator surface. Robinhood publishes one feed per stock token; the answer /// already includes the corporate-action multiplier, so it is the price of one token. interface AggregatorV3Interface { function decimals() external view returns (uint8); function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); function getRoundData(uint80 roundId) external view returns (uint80, int256 answer, uint256 startedAt, uint256 updatedAt, uint80); } /// CUTOFF: a stock pool that prices every order at the next oracle print. /// /// An order is submitted, its input is locked, and it settles at the first Chainlink round /// published after submission. Nobody can trade on a price the pool has not seen yet, so /// there is nothing to pick off: the liquidity provider's value moves with the feed and /// changes only by the fee. Orders cannot be cancelled; a cancellable order is a free option /// on the next print, which is the same leak by another name. contract Cutoff { IERC20 public immutable usdg; // quote IERC20 public immutable stock; // base AggregatorV3Interface public immutable feed; // stock / USD uint256 private immutable usdgScale; // 10 ** (18 - usdg decimals) uint256 private immutable stockScale; // 10 ** (18 - stock decimals) uint256 private immutable feedScale; // 10 ** (18 - feed decimals) uint256 public immutable feeBps; // taken on the input, stays with the pool uint256 public immutable bountyBps; // share of the fee paid to whoever settles uint256 public reserveUsdg; // free reserves, owned by the shares uint256 public reserveStock; uint256 public totalShares; mapping(address => uint256) public sharesOf; /// What a provider handed over. A withdrawal must be worth at least the same basket held /// on its own, priced at the current print. Every fill here happens at a print plus a fee, /// so the pool can only end up ahead of holding; if it ever did not, the call reverts. mapping(address => uint256) public depositedUsdg; mapping(address => uint256) public depositedStock; struct Order { address owner; bool usdgIn; bool poolMayFill; // false: only other orders may fill this, so the pool's inventory never moves uint256 amountIn; uint256 submittedAt; // also the roll pointer: a fill must happen at the first print after it bool closed; uint80 roundId; // the print it last filled at, 0 while open uint256 amountOut; // what the owner has received so far uint256 filledIn; // how much of amountIn has been used } Order[] public orders; uint256 public constant MINIMUM_LIQUIDITY = 1_000; uint256 private constant BPS = 10_000; uint256 private constant WAD = 1e18; uint256 private _lock = 1; event Submitted(uint256 indexed id, address indexed owner, bool usdgIn, uint256 amountIn); event Settled(uint256 indexed id, uint80 roundId, uint256 price1e18, uint256 amountOut, uint256 fee, address settler); event Refunded(uint256 indexed id, uint256 amountIn); event LiquidityAdded(address indexed from, uint256 usdgAmount, uint256 stockAmount, uint256 shares); event LiquidityRemoved(address indexed to, uint256 usdgAmount, uint256 stockAmount, uint256 shares); error NotFirstPrintAfterSubmission(); error AlreadyClosed(); error BadOrder(); error NothingToMatch(); error OrdersOnly(); modifier nonReentrant() { require(_lock == 1, "reentrant"); _lock = 2; _; _lock = 1; } constructor(address _usdg, address _stock, address _feed, uint256 _feeBps, uint256 _bountyBps) { require(_feeBps < BPS && _bountyBps <= BPS, "bps"); usdg = IERC20(_usdg); stock = IERC20(_stock); feed = AggregatorV3Interface(_feed); uint8 du = IERC20(_usdg).decimals(); uint8 ds = IERC20(_stock).decimals(); uint8 df = AggregatorV3Interface(_feed).decimals(); require(du <= 18 && ds <= 18 && df <= 18, "decimals"); usdgScale = 10 ** (18 - du); stockScale = 10 ** (18 - ds); feedScale = 10 ** (18 - df); feeBps = _feeBps; bountyBps = _bountyBps; } // ---- orders ---- /// Lock the input. The price is whatever the feed prints next. function submit(bool usdgIn, uint256 amountIn) external returns (uint256) { return submitTo(usdgIn, amountIn, false); } /// `poolMayFill` lets the pool be the counterparty when no opposite order shows up. Left false, /// the order waits for another trader and the pool's token counts never move. function submitTo(bool usdgIn, uint256 amountIn, bool poolMayFill) public nonReentrant returns (uint256 id) { require(amountIn > 0, "zero in"); IERC20 tokenIn = usdgIn ? usdg : stock; require(tokenIn.transferFrom(msg.sender, address(this), amountIn), "transfer in failed"); id = orders.length; orders.push(Order({owner: msg.sender, usdgIn: usdgIn, poolMayFill: poolMayFill, amountIn: amountIn, submittedAt: block.timestamp, closed: false, roundId: 0, amountOut: 0, filledIn: 0})); emit Submitted(id, msg.sender, usdgIn, amountIn); } /// Settle buyers against sellers at one print. Whatever the two sides have in common is /// exchanged between them, so the pool's own reserves do not move at all; the rest stays /// open and waits for the next print. Anyone may call; the caller earns the bounty. function settleBatch(uint256[] calldata buys, uint256[] calldata sells, uint80 roundId) external nonReentrant { uint256 price1e18 = _printPrice(roundId); // what each side still wants, in USDG terms, net of the fee uint256 buyNet; for (uint256 i = 0; i < buys.length; i++) { Order storage o = orders[buys[i]]; _checkFillable(o, roundId, true); buyNet += _netOf(o); } uint256 sellNet; // sellers bring stock; value it at the print for (uint256 i = 0; i < sells.length; i++) { Order storage o = orders[sells[i]]; _checkFillable(o, roundId, false); sellNet += (_netOf(o) * stockScale * price1e18) / WAD / usdgScale; } uint256 matched = buyNet < sellNet ? buyNet : sellNet; if (matched == 0) revert NothingToMatch(); uint256 bountyUsdg; // buyers: hand over their share of the matched USDG, receive stock at the print for (uint256 i = 0; i < buys.length; i++) { Order storage o = orders[buys[i]]; uint256 net = _netOf(o); uint256 useNet = (net * matched) / buyNet; if (useNet == 0) continue; uint256 useIn = (useNet * BPS) / (BPS - feeBps); uint256 fee = useIn - useNet; uint256 out = (useNet * usdgScale * WAD) / price1e18 / stockScale; o.filledIn += useIn; o.amountOut += out; o.roundId = roundId; o.submittedAt = _printTime(roundId); if (o.filledIn + 1 >= o.amountIn) o.closed = true; uint256 b = (fee * bountyBps) / BPS; bountyUsdg += b; reserveUsdg += fee - b; require(stock.transfer(o.owner, out), "stock out failed"); emit Settled(buys[i], roundId, price1e18, out, fee, msg.sender); } // sellers: hand over their share of the matched stock, receive USDG at the print for (uint256 i = 0; i < sells.length; i++) { Order storage o = orders[sells[i]]; uint256 net = _netOf(o); uint256 netUsdg = (net * stockScale * price1e18) / WAD / usdgScale; uint256 useUsdg = (netUsdg * matched) / sellNet; if (useUsdg == 0) continue; uint256 useNet = (useUsdg * usdgScale * WAD) / price1e18 / stockScale; uint256 useIn = (useNet * BPS) / (BPS - feeBps); uint256 fee = useIn - useNet; o.filledIn += useIn; o.amountOut += useUsdg; o.roundId = roundId; o.submittedAt = _printTime(roundId); if (o.filledIn + 1 >= o.amountIn) o.closed = true; uint256 b = (fee * bountyBps) / BPS; reserveStock += fee - b; require(stock.transfer(msg.sender, b), "bounty failed"); require(usdg.transfer(o.owner, useUsdg), "usdg out failed"); emit Settled(sells[i], roundId, price1e18, useUsdg, fee, msg.sender); } if (bountyUsdg > 0) require(usdg.transfer(msg.sender, bountyUsdg), "bounty failed"); } function _netOf(Order storage o) internal view returns (uint256) { uint256 left = o.amountIn - o.filledIn; return left - (left * feeBps) / BPS; } function _checkFillable(Order storage o, uint80 roundId, bool wantUsdgIn) internal view { if (o.closed || o.usdgIn != wantUsdgIn) revert BadOrder(); _requireFirstPrintAfter(roundId, o.submittedAt); } /// Settle at `roundId`, which must be the first print published after the order. /// Anyone may call; the caller earns the bounty. If the pool cannot fill, the input is refunded. function settle(uint256 id, uint80 roundId) external nonReentrant { if (id >= orders.length) revert BadOrder(); Order storage o = orders[id]; if (o.closed) revert AlreadyClosed(); if (!o.poolMayFill) revert OrdersOnly(); uint256 price1e18 = _firstPrintAfter(roundId, o.submittedAt); o.closed = true; uint256 fee = (o.amountIn * feeBps) / BPS; uint256 net = o.amountIn - fee; uint256 bounty = (fee * bountyBps) / BPS; if (o.usdgIn) { uint256 stockOut = (net * usdgScale * WAD) / price1e18 / stockScale; if (stockOut > reserveStock) { require(usdg.transfer(o.owner, o.amountIn), "refund failed"); emit Refunded(id, o.amountIn); return; } reserveStock -= stockOut; reserveUsdg += net + (fee - bounty); o.roundId = roundId; o.amountOut = stockOut; require(stock.transfer(o.owner, stockOut), "stock out failed"); if (bounty > 0) require(usdg.transfer(msg.sender, bounty), "bounty failed"); emit Settled(id, roundId, price1e18, stockOut, fee, msg.sender); } else { uint256 usdgOut = (net * stockScale * price1e18) / WAD / usdgScale; if (usdgOut > reserveUsdg) { require(stock.transfer(o.owner, o.amountIn), "refund failed"); emit Refunded(id, o.amountIn); return; } reserveUsdg -= usdgOut; reserveStock += net + (fee - bounty); o.roundId = roundId; o.amountOut = usdgOut; require(usdg.transfer(o.owner, usdgOut), "usdg out failed"); if (bounty > 0) require(stock.transfer(msg.sender, bounty), "bounty failed"); emit Settled(id, roundId, price1e18, usdgOut, fee, msg.sender); } } /// The round is valid only if it was published after the order and the previous round was not. function _firstPrintAfter(uint80 roundId, uint256 submittedAt) internal view returns (uint256 price1e18) { _requireFirstPrintAfter(roundId, submittedAt); (, int256 answer,,,) = feed.getRoundData(roundId); price1e18 = uint256(answer) * feedScale; } function _requireFirstPrintAfter(uint80 roundId, uint256 submittedAt) internal view { (, int256 answer,, uint256 updatedAt,) = feed.getRoundData(roundId); if (answer <= 0 || updatedAt <= submittedAt) revert NotFirstPrintAfterSubmission(); (, int256 prevAnswer,, uint256 prevUpdatedAt,) = feed.getRoundData(roundId - 1); if (prevAnswer > 0 && prevUpdatedAt > submittedAt) revert NotFirstPrintAfterSubmission(); } function _printPrice(uint80 roundId) internal view returns (uint256) { (, int256 answer,,,) = feed.getRoundData(roundId); require(answer > 0, "bad round"); return uint256(answer) * feedScale; } function _printTime(uint80 roundId) internal view returns (uint256) { (,,, uint256 updatedAt,) = feed.getRoundData(roundId); return updatedAt; } function ordersCount() external view returns (uint256) { return orders.length; } // ---- liquidity ---- /// Shares are pro rata on the free reserves. Only the first deposit needs a price. function addLiquidity(uint256 usdgAmount, uint256 stockAmount, uint256 minShares) external nonReentrant returns (uint256 shares) { require(usdgAmount > 0 && stockAmount > 0, "both sides required"); if (totalShares == 0) { (, int256 answer,,,) = feed.latestRoundData(); require(answer > 0, "bad oracle price"); uint256 value = usdgAmount * usdgScale + (stockAmount * stockScale * uint256(answer) * feedScale) / WAD; require(value > MINIMUM_LIQUIDITY, "insufficient first deposit"); shares = value - MINIMUM_LIQUIDITY; sharesOf[address(0)] = MINIMUM_LIQUIDITY; totalShares = value; } else { uint256 byUsdg = (usdgAmount * totalShares) / reserveUsdg; uint256 byStock = (stockAmount * totalShares) / reserveStock; shares = byUsdg < byStock ? byUsdg : byStock; require(shares > 0, "zero shares"); totalShares += shares; } sharesOf[msg.sender] += shares; depositedUsdg[msg.sender] += usdgAmount; depositedStock[msg.sender] += stockAmount; reserveUsdg += usdgAmount; reserveStock += stockAmount; require(shares >= minShares, "min shares"); require(usdg.transferFrom(msg.sender, address(this), usdgAmount), "usdg transfer failed"); require(stock.transferFrom(msg.sender, address(this), stockAmount), "stock transfer failed"); emit LiquidityAdded(msg.sender, usdgAmount, stockAmount, shares); } function removeLiquidity(uint256 shares, uint256 minUsdgOut, uint256 minStockOut) external nonReentrant returns (uint256 usdgOut, uint256 stockOut) { require(shares > 0 && shares <= sharesOf[msg.sender], "bad shares"); uint256 total = totalShares; usdgOut = (reserveUsdg * shares) / total; stockOut = (reserveStock * shares) / total; require(usdgOut >= minUsdgOut && stockOut >= minStockOut, "slippage"); { // the deposit record follows the shares out; see versusHolding for the comparison uint256 owned = sharesOf[msg.sender]; depositedUsdg[msg.sender] -= (depositedUsdg[msg.sender] * shares) / owned; depositedStock[msg.sender] -= (depositedStock[msg.sender] * shares) / owned; } sharesOf[msg.sender] -= shares; totalShares = total - shares; reserveUsdg -= usdgOut; reserveStock -= stockOut; require(usdg.transfer(msg.sender, usdgOut), "usdg transfer failed"); require(stock.transfer(msg.sender, stockOut), "stock transfer failed"); emit LiquidityRemoved(msg.sender, usdgOut, stockOut, shares); } /// What a provider's position is worth now, against simply holding what they put in. /// Positive means the pool is ahead of holding. Both in 18 decimals of USD. function versusHolding(address who) external view returns (uint256 positionValue, uint256 holdValue) { (, int256 answer,,,) = feed.latestRoundData(); require(answer > 0, "bad oracle price"); uint256 price1e18 = uint256(answer) * feedScale; uint256 sh = sharesOf[who]; if (totalShares > 0 && sh > 0) { uint256 u = (reserveUsdg * sh) / totalShares; uint256 k = (reserveStock * sh) / totalShares; positionValue = u * usdgScale + (k * stockScale * price1e18) / WAD; } holdValue = depositedUsdg[who] * usdgScale + (depositedStock[who] * stockScale * price1e18) / WAD; } /// Value of the free reserves at the latest print, in 18 decimals of USD. function reserveValue1e18() external view returns (uint256) { (, int256 answer,,,) = feed.latestRoundData(); require(answer > 0, "bad oracle price"); return reserveUsdg * usdgScale + (reserveStock * stockScale * uint256(answer) * feedScale) / WAD; } }