// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; interface IERC20 { function transfer(address, uint256) external returns (bool); function transferFrom(address, address, uint256) external returns (bool); function balanceOf(address) external view returns (uint256); function decimals() external view returns (uint8); } 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, uint256, uint256, uint80); } /// MOO: a subscription pool that fills at the stock's first Chainlink print after deploy. /// /// A Robinhood Chain stock token exists on chain before its feed has ever answered. In that gap /// there is no market price, no book, no way to trade. This pool is what happens in the gap: /// subscribers commit USDG to buy at whatever the feed first says, underwriters commit stock to /// sell at the same number, and the round that ends the silence is the one they cross at. /// /// The price is a print the pool has never seen. Nobody can front-run a number that does not /// exist yet. If the feed never prints before the deadline, everyone withdraws untouched. contract MOO { IERC20 public immutable usdg; IERC20 public immutable stock; AggregatorV3Interface public immutable feed; uint256 private immutable usdgScale; // 10 ** (18 - usdg decimals) uint256 private immutable stockScale; // 10 ** (18 - stock decimals) uint256 private immutable feedScale; // 10 ** (18 - feed decimals) /// The pool must fill at a print published after deploy. `latestRound()` at construction /// is the last stale one; the first round with an id strictly greater than this settles. uint80 public immutable deployRoundId; /// After this timestamp, unmatched deposits can be withdrawn if no eligible print has arrived. uint256 public immutable deadline; /// Paid by the buy side to the sell side, in basis points on top of the print. This is the /// underwriting fee: the queue pays whoever showed up with inventory before the number /// existed. Nothing here flows to a protocol or an operator. uint256 public immutable premiumBps; /// The allocation token, and the balance that puts a subscriber in the first tranche. /// A book that is oversubscribed has to decide who actually gets shares, and this is that /// decision written down in advance: holders are filled first, everyone else takes the /// remainder. Zero address turns the tranche off and the whole book fills pro-rata. IERC20 public immutable moo; uint256 public immutable allocMin; /// Set exactly once, by `settle()`. uint80 public settleRoundId; uint256 public settlePrice; // effective crossing price, print plus premium, 1e18 scale uint256 public printPrice; // the raw feed print, 1e18 scale uint256 public settleAt; bool public settled; /// Subscribers put USDG in, underwriters put stock in. mapping(address => uint256) public depositedUsdg; mapping(address => uint256) public depositedStock; uint256 public totalUsdg; uint256 public totalStock; /// The part of each subscription that qualified for the first tranche. Qualification is /// judged at the moment the money arrives, so selling the token later cannot take back an /// allocation already earned, and buying it later only helps the next subscription. mapping(address => uint256) public depositedUsdgPriority; uint256 public totalUsdgPriority; /// At settle, we lock the number that crosses. Whichever side is smaller is fully filled; /// the other side is filled pro-rata, and the unfilled remainder returns on claim. uint256 public filledStock; // stock actually sold (== min(totalUsdg/price, totalStock)) uint256 public filledUsdg; // usdg actually spent (== filledStock * price) uint256 public filledUsdgPriority; // the part of it that went to the first tranche event Subscribed(address indexed who, uint256 usdgIn, bool firstTranche); event Filled(address indexed who, uint256 stockIn); event Settled(uint80 roundId, uint256 price1e18, uint256 filledStock, uint256 filledUsdg); event Claimed(address indexed who, uint256 stockOut, uint256 usdgOut); error NotOpen(); error AlreadySettled(); error NotSettled(); error NoNewPrint(); error BadPrice(); error DeadlineNotReached(); error DeadlinePassed(); error Nothing(); constructor( IERC20 _usdg, IERC20 _stock, AggregatorV3Interface _feed, uint256 _deadline, uint256 _premiumBps, IERC20 _moo, uint256 _allocMin ) { require(_deadline > block.timestamp, "past deadline"); require(_premiumBps <= 1000, "premium too high"); usdg = _usdg; stock = _stock; feed = _feed; deadline = _deadline; premiumBps = _premiumBps; moo = _moo; allocMin = _allocMin; usdgScale = 10 ** (18 - _usdg.decimals()); stockScale = 10 ** (18 - _stock.decimals()); feedScale = 10 ** (18 - _feed.decimals()); (uint80 rId,,,, ) = _feed.latestRoundData(); deployRoundId = rId; } error WindowClosed(); /// The window is open only while the feed has not spoken since deploy. The moment a /// newer round exists the number is public, so commitments close, exactly like an /// opening auction stops taking orders at the open. function _requireWindowOpen() private view { (uint80 rId,,,, ) = feed.latestRoundData(); if (rId > deployRoundId) revert WindowClosed(); } /// Buy-side commit. USDG comes in, its 1e18-scaled amount is remembered. function subscribe(uint256 usdgAmount) external { if (settled) revert AlreadySettled(); if (block.timestamp >= deadline) revert DeadlinePassed(); if (usdgAmount == 0) revert Nothing(); _requireWindowOpen(); require(usdg.transferFrom(msg.sender, address(this), usdgAmount), "usdg in"); uint256 scaled = usdgAmount * usdgScale; depositedUsdg[msg.sender] += scaled; totalUsdg += scaled; bool first = address(moo) != address(0) && moo.balanceOf(msg.sender) >= allocMin; if (first) { depositedUsdgPriority[msg.sender] += scaled; totalUsdgPriority += scaled; } emit Subscribed(msg.sender, usdgAmount, first); } /// Sell-side commit. Stock comes in, its 1e18-scaled amount is remembered. function fill(uint256 stockAmount) external { if (settled) revert AlreadySettled(); if (block.timestamp >= deadline) revert DeadlinePassed(); if (stockAmount == 0) revert Nothing(); _requireWindowOpen(); require(stock.transferFrom(msg.sender, address(this), stockAmount), "stock in"); uint256 scaled = stockAmount * stockScale; depositedStock[msg.sender] += scaled; totalStock += scaled; emit Filled(msg.sender, stockAmount); } /// Lock the price at the first oracle round strictly newer than the one seen at deploy. /// Anyone can call this once such a round exists. Fills both sides at that price and stops. function settle() external { if (settled) revert AlreadySettled(); (uint80 rId, int256 ans, , uint256 updated, ) = feed.latestRoundData(); if (rId <= deployRoundId) revert NoNewPrint(); if (ans <= 0) revert BadPrice(); uint256 print1e18 = uint256(ans) * feedScale; // the buy side pays the print plus the underwriting premium; the sell side receives it. uint256 price1e18 = (print1e18 * (10000 + premiumBps)) / 10000; uint256 stockDemanded = (totalUsdg * 1e18) / price1e18; uint256 fStock = stockDemanded < totalStock ? stockDemanded : totalStock; uint256 fUsdg = (fStock * price1e18) / 1e18; settleRoundId = rId; settlePrice = price1e18; printPrice = print1e18; settleAt = updated; filledStock = fStock; filledUsdg = fUsdg; // The first tranche is served out of the fill before anyone else sees it. When the book // is not oversubscribed there is enough for everybody and the split changes nothing. filledUsdgPriority = fUsdg < totalUsdgPriority ? fUsdg : totalUsdgPriority; settled = true; emit Settled(rId, price1e18, fStock, fUsdg); } /// After settle, subscribers get stock (and any refunded USDG), underwriters get USDG /// (and any refunded stock). Same call for both sides; users often are only one. function claim() external { if (!settled) revert NotSettled(); (uint256 stockOut, uint256 usdgOut) = pending(msg.sender); if (stockOut == 0 && usdgOut == 0) revert Nothing(); depositedUsdg[msg.sender] = 0; depositedStock[msg.sender] = 0; depositedUsdgPriority[msg.sender] = 0; if (stockOut > 0) require(stock.transfer(msg.sender, stockOut), "stock out"); if (usdgOut > 0) require(usdg.transfer(msg.sender, usdgOut), "usdg out"); emit Claimed(msg.sender, stockOut, usdgOut); } /// Read-only preview of what claim would send. Divides the fill pro-rata across each side. function pending(address who) public view returns (uint256 stockOut, uint256 usdgOut) { if (!settled) return (0, 0); uint256 myUsdg = depositedUsdg[who]; uint256 myStock = depositedStock[who]; // subscriber side: pro-rata within your own tranche, refund of unspent usdg if (myUsdg > 0 && totalUsdg > 0) { uint256 mine = depositedUsdgPriority[who]; uint256 spent = mine > 0 ? (mine * filledUsdgPriority) / totalUsdgPriority : 0; uint256 rest = myUsdg - mine; if (rest > 0) { uint256 openTotal = totalUsdg - totalUsdgPriority; if (openTotal > 0) spent += (rest * (filledUsdg - filledUsdgPriority)) / openTotal; } uint256 gotStock1e18 = (spent * 1e18) / settlePrice; uint256 refundScaled = myUsdg - spent; stockOut += gotStock1e18 / stockScale; usdgOut += refundScaled / usdgScale; } // underwriter side: pro-rata slice of filledUsdg, refund of unsold stock if (myStock > 0 && totalStock > 0) { uint256 soldStock = (myStock * filledStock) / totalStock; uint256 gotUsdg = (soldStock * settlePrice) / 1e18; uint256 refundStock = myStock - soldStock; usdgOut += gotUsdg / usdgScale; stockOut += refundStock / stockScale; } } /// If the deadline passes and no eligible print ever arrived, everyone gets exactly what /// they put in. This is also the fuse for a listing that was announced and never happened. function withdraw() external { if (settled) revert AlreadySettled(); if (block.timestamp < deadline) revert DeadlineNotReached(); uint256 u = depositedUsdg[msg.sender]; uint256 s = depositedStock[msg.sender]; if (u == 0 && s == 0) revert Nothing(); uint256 p = depositedUsdgPriority[msg.sender]; depositedUsdg[msg.sender] = 0; depositedStock[msg.sender] = 0; depositedUsdgPriority[msg.sender] = 0; totalUsdg -= u; totalStock -= s; totalUsdgPriority -= p; if (u > 0) require(usdg.transfer(msg.sender, u / usdgScale), "usdg refund"); if (s > 0) require(stock.transfer(msg.sender, s / stockScale), "stock refund"); } }