// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.26; import {TickMath} from "./TickMath.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; interface IUniswapV3PoolOracle { function observe(uint32[] calldata secondsAgos) external view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s); } interface ISwapRouter02 { struct ExactInputParams { bytes path; address recipient; uint256 amountIn; uint256 amountOutMinimum; } function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); } interface IERC20Min { function balanceOf(address) external view returns (uint256); function transfer(address to, uint256 value) external returns (bool); } /// @title StookCoin /// @notice A coin that is its own market and gathers a basket of stocks. /// /// Every buy and every sell pays a fee in ETH. The fee is swapped on Uniswap v3 — ETH → USDG → one of /// up to four tokenized stocks — and the stock stays in this contract: the coin's stook. The launcher /// fixes the basket and its weights once; each swap buys whichever stock has had the least ETH spent /// on it for its weight, so over time the ETH spent follows the weights exactly. Any holder can burn /// coins for exactly their share of every stock in the stook. /// /// The stook never buys at a manipulated price: each swap must return at least what the two pools' /// own time-weighted average prices say it should, less the pools' fees and 2%. A swap that cannot /// meet that is not made — the ETH waits, that stock is set aside for ten minutes, and the next /// trade (or anyone) buys the next one. /// /// The market is a constant-product curve against a virtual ETH reserve, so there is liquidity from /// the first block and nothing to route around: the only reserve of this coin is inside this /// contract. No owner, no pause, no upgrade, no fee switch. The launcher gets nothing. /// /// Deployed once as an implementation and cloned (EIP-1167) for every launch by StookFactory. contract StookCoin { // ------------------------------------------------------------------ ERC-20 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; event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value); // ------------------------------------------------------------------ constants uint256 public constant SUPPLY = 1_000_000_000e18; uint256 public constant MAX_LEGS = 4; /// @notice Fee ETH below this waits in `pendingEth` until a later trade tops it up (~5 cents). uint256 public constant MIN_CONVERT = 0.00002 ether; /// @notice Gas the stook swap is given (a two-hop swap into a stock uses ~300k here). uint256 public constant CONVERT_GAS = 600_000; /// @notice Gas kept back for reading the two price oracles before the swap. uint256 public constant ORACLE_GAS = 400_000; /// @notice Gas for one pool's price history read (~80k measured on the busiest pools here). uint256 public constant OBSERVE_GAS = 150_000; /// @notice How far below the average price, after pool fees, a stook buy may land. uint256 public constant MAX_SLIP_BPS = 200; /// @notice A stock whose swap was refused is passed over for this long, so one thin pool /// cannot hold up the rest of the basket. uint256 public constant REST = 600; ISwapRouter02 public immutable router; address public immutable weth; address public immutable usdg; address public immutable factory; /// @notice The WETH/USDG pool every stook buy goes through first, and its fee. address public immutable ethPool; uint24 public immutable ethPoolFee; // ------------------------------------------------------------------ set once at launch struct Leg { address stock; /// @dev the USDG/stock pool this leg buys through, and its fee tier address pool; uint24 poolFee; uint16 weightBps; /// @dev lifetime fee ETH spent on this leg, and the stock it bought uint256 ethSpent; uint256 bought; /// @dev when this leg's last swap was refused uint64 restingSince; } address public creator; uint16 public feeBps; uint64 public launchedAt; uint256 public virtualEth; /// @notice Picture, description and links, stored as contract code (SSTORE2) by the factory. address public metaPointer; Leg[] internal _legs; // ------------------------------------------------------------------ state /// @notice Real ETH held by the curve (the fee ETH is not in it). uint256 public realEth; /// @notice Fee ETH not yet swapped into a stock. uint256 public pendingEth; /// @notice Lifetime fee ETH taken, coins burned for the stook, trades. uint256 public totalFeesEth; uint256 public totalRedeemed; uint256 public tradeCount; uint256 private _locked; event Trade( address indexed trader, bool isBuy, uint256 ethAmount, uint256 coinAmount, uint256 feeEth, uint256 ethReserve, uint256 coinReserve ); /// @param fairOut what the average prices said `ethIn` was worth, in stock units event StookBuy(uint8 indexed leg, address indexed stock, uint256 ethIn, uint256 stockOut, uint256 fairOut); /// @param reason 1 = no average price could be read, 2 = the swap would have paid too much or failed event StookBuyDeferred(uint8 indexed leg, uint256 ethPending, uint8 reason); event Redeem(address indexed holder, address indexed to, uint256 coinsBurned, uint256[] stockOut, uint256 ethOut); error AlreadyInitialized(); error OnlyFactory(); error Reentrancy(); error ZeroAmount(); error Slippage(); error EthTransferFailed(); error StockTransferFailed(); error InsufficientBalance(); error InsufficientAllowance(); error NeedsMoreGas(); error BadLegs(); modifier nonReentrant() { if (_locked == 1) revert Reentrancy(); _locked = 1; _; _locked = 0; } constructor(address router_, address weth_, address usdg_, address ethPool_, uint24 ethPoolFee_, address factory_) { router = ISwapRouter02(router_); weth = weth_; usdg = usdg_; ethPool = ethPool_; ethPoolFee = ethPoolFee_; factory = factory_; launchedAt = type(uint64).max; // the implementation itself can never be initialised } /// @dev The factory has already checked every leg: its pool exists, has a price history and the /// weights add up to 10,000. function initialize( string calldata name_, string calldata symbol_, address metaPointer_, address creator_, address[] calldata stocks, address[] calldata pools, uint24[] calldata poolFees, uint16[] calldata weights, uint16 feeBps_, uint256 virtualEth_ ) external { if (msg.sender != factory) revert OnlyFactory(); if (launchedAt != 0) revert AlreadyInitialized(); uint256 n = stocks.length; if (n == 0 || n > MAX_LEGS || pools.length != n || poolFees.length != n || weights.length != n) revert BadLegs(); name = name_; symbol = symbol_; metaPointer = metaPointer_; creator = creator_; for (uint256 i; i < n; i++) { _legs.push(Leg(stocks[i], pools[i], poolFees[i], weights[i], 0, 0, 0)); } feeBps = feeBps_; virtualEth = virtualEth_; launchedAt = uint64(block.timestamp); totalSupply = SUPPLY; balanceOf[address(this)] = SUPPLY; emit Transfer(address(0), address(this), SUPPLY); } // ------------------------------------------------------------------ ERC-20 logic function transfer(address to, uint256 value) external returns (bool) { _transfer(msg.sender, to, value); return true; } function approve(address spender, uint256 value) external returns (bool) { allowance[msg.sender][spender] = value; emit Approval(msg.sender, spender, value); return true; } function transferFrom(address from, address to, uint256 value) external returns (bool) { uint256 a = allowance[from][msg.sender]; if (a != type(uint256).max) { if (a < value) revert InsufficientAllowance(); allowance[from][msg.sender] = a - value; } _transfer(from, to, value); return true; } function _transfer(address from, address to, uint256 value) internal { uint256 b = balanceOf[from]; if (b < value) revert InsufficientBalance(); unchecked { balanceOf[from] = b - value; balanceOf[to] += value; } emit Transfer(from, to, value); } // ------------------------------------------------------------------ market function reserves() public view returns (uint256 ethReserve, uint256 coinReserve) { return (virtualEth + realEth, balanceOf[address(this)]); } /// @notice Coins out for `ethIn` sent to buy, and the part of it that goes to the stook. function quoteBuy(uint256 ethIn) public view returns (uint256 coinsOut, uint256 fee) { fee = ethIn * feeBps / 10_000; uint256 net = ethIn - fee; (uint256 x, uint256 y) = reserves(); coinsOut = y * net / (x + net); } /// @notice ETH paid out for selling `coinsIn`, and the part of it that goes to the stook. function quoteSell(uint256 coinsIn) public view returns (uint256 ethOut, uint256 fee) { (uint256 x, uint256 y) = reserves(); uint256 gross = x * coinsIn / (y + coinsIn); if (gross > realEth) gross = realEth; fee = gross * feeBps / 10_000; ethOut = gross - fee; } function buy(uint256 minCoinsOut, address to) public payable nonReentrant returns (uint256 coinsOut) { if (msg.value == 0) revert ZeroAmount(); uint256 fee; (coinsOut, fee) = quoteBuy(msg.value); if (coinsOut == 0 || coinsOut < minCoinsOut) revert Slippage(); realEth += msg.value - fee; _transfer(address(this), to, coinsOut); tradeCount++; (uint256 x, uint256 y) = reserves(); emit Trade(to, true, msg.value, coinsOut, fee, x, y); _accrue(fee); } function sell(uint256 coinsIn, uint256 minEthOut, address to) external nonReentrant returns (uint256 ethOut) { if (coinsIn == 0) revert ZeroAmount(); uint256 fee; (ethOut, fee) = quoteSell(coinsIn); if (ethOut == 0 || ethOut < minEthOut) revert Slippage(); _transfer(msg.sender, address(this), coinsIn); realEth -= ethOut + fee; tradeCount++; (uint256 x, uint256 y) = reserves(); emit Trade(msg.sender, false, ethOut, coinsIn, fee, x, y); _accrue(fee); _sendEth(to, ethOut); } // ------------------------------------------------------------------ the stook function _accrue(uint256 fee) internal { totalFeesEth += fee; pendingEth += fee; if (pendingEth >= MIN_CONVERT) _convert(pendingEth); } /// @notice The leg the next swap buys: the one with the least ETH spent on it for its weight, /// passing over any leg whose last swap was refused less than `REST` seconds ago — unless every /// leg is resting, in which case the least-bought one is tried anyway. function nextLeg() public view returns (uint8 best) { uint256 n = _legs.length; bool found; for (uint256 pass; pass < 2 && !found; pass++) { for (uint256 i; i < n; i++) { Leg storage l = _legs[i]; if (pass == 0 && l.restingSince != 0 && block.timestamp < l.restingSince + REST) continue; if (!found) { best = uint8(i); found = true; continue; } Leg storage b = _legs[best]; // l.ethSpent / l.weight < b.ethSpent / b.weight, without dividing if (l.ethSpent * b.weightBps < b.ethSpent * l.weightBps) best = uint8(i); } } } /// @dev Swaps `amt` of the pending fee ETH into the next leg's stock, or leaves it pending. It /// never makes a trade fail — except for too little gas, which it refuses outright: a wallet /// estimates the smallest gas at which a transaction does not revert, and without this refusal /// that estimate would starve the swap inside the try and every fee would be deferred. function _convert(uint256 amt) internal { if (gasleft() < CONVERT_GAS + CONVERT_GAS / 63 + ORACLE_GAS) revert NeedsMoreGas(); uint8 k = nextLeg(); Leg storage l = _legs[k]; uint256 fair = fairStockOut(k, amt); if (fair == 0) { l.restingSince = uint64(block.timestamp); emit StookBuyDeferred(k, pendingEth, 1); return; } uint256 minOut = Math.mulDiv(fair, (1e6 - uint256(ethPoolFee) - l.poolFee) * (10_000 - MAX_SLIP_BPS), 1e10); if (minOut == 0) minOut = 1; pendingEth -= amt; try router.exactInput{value: amt, gas: CONVERT_GAS}( ISwapRouter02.ExactInputParams({ path: abi.encodePacked(weth, ethPoolFee, usdg, l.poolFee, l.stock), recipient: address(this), amountIn: amt, amountOutMinimum: minOut }) ) returns (uint256 out) { l.ethSpent += amt; l.bought += out; l.restingSince = 0; emit StookBuy(k, l.stock, amt, out, fair); } catch { pendingEth += amt; l.restingSince = uint64(block.timestamp); emit StookBuyDeferred(k, pendingEth, 2); } } /// @notice Anyone can push pending fee ETH into the basket — all of it, or at most `maxEth` of it /// (a large backlog in a thin pool may only clear in pieces). function convert(uint256 maxEth) external nonReentrant { uint256 amt = pendingEth < maxEth ? pendingEth : maxEth; if (amt == 0) revert ZeroAmount(); _convert(amt); } /// @notice What `ethIn` is worth in leg `k`'s stock at the two pools' time-weighted average prices /// (ETH→USDG, then USDG→stock), before fees. Zero when either average cannot be read. /// Tries a 30-minute window, then 10 minutes, then 2: a very busy pool can have overwritten /// the older observations. Any window excludes a price pushed within the current block. function fairStockOut(uint8 k, uint256 ethIn) public view returns (uint256) { Leg storage l = _legs[k]; (bool ok1, int24 t1) = _meanTick(ethPool); if (!ok1) return 0; (bool ok2, int24 t2) = _meanTick(l.pool); if (!ok2) return 0; return _quoteAtTick(t2, _quoteAtTick(t1, ethIn, weth, usdg), usdg, l.stock); } function _meanTick(address pool) internal view returns (bool, int24) { uint32[] memory ago = new uint32[](2); for (uint256 i; i < 3; i++) { uint32 w = i == 0 ? 1800 : i == 1 ? 600 : 120; ago[0] = w; try IUniswapV3PoolOracle(pool).observe{gas: OBSERVE_GAS}(ago) returns (int56[] memory tc, uint160[] memory) { int56 d = tc[1] - tc[0]; int24 t = int24(d / int56(uint56(w))); if (d < 0 && d % int56(uint56(w)) != 0) t--; // round toward negative infinity, as Uniswap does return (true, t); } catch {} } return (false, 0); } /// @dev Uniswap's OracleLibrary.getQuoteAtTick, with a full-width base amount. function _quoteAtTick(int24 tick, uint256 baseAmount, address baseToken, address quoteToken) internal pure returns (uint256) { uint160 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(tick); if (sqrtRatioX96 <= type(uint128).max) { uint256 ratioX192 = uint256(sqrtRatioX96) * sqrtRatioX96; return baseToken < quoteToken ? Math.mulDiv(ratioX192, baseAmount, 1 << 192) : Math.mulDiv(1 << 192, baseAmount, ratioX192); } uint256 ratioX128 = Math.mulDiv(sqrtRatioX96, sqrtRatioX96, 1 << 64); return baseToken < quoteToken ? Math.mulDiv(ratioX128, baseAmount, 1 << 128) : Math.mulDiv(1 << 128, baseAmount, ratioX128); } /// @notice What the stook holds of each leg's stock. function holdings() public view returns (uint256[] memory out) { uint256 n = _legs.length; out = new uint256[](n); for (uint256 i; i < n; i++) out[i] = IERC20Min(_legs[i].stock).balanceOf(address(this)); } function legCount() external view returns (uint256) { return _legs.length; } function legs() external view returns (Leg[] memory) { return _legs; } /// @notice What burning `coins` would pay out right now: that fraction of every stock and of the /// pending fee ETH. function quoteRedeem(uint256 coins) public view returns (uint256[] memory stockOut, uint256 ethOut) { stockOut = holdings(); uint256 s = totalSupply; for (uint256 i; i < stockOut.length; i++) stockOut[i] = s == 0 ? 0 : stockOut[i] * coins / s; ethOut = s == 0 ? 0 : pendingEth * coins / s; } /// @notice Burn coins for their share of the stook. The share is over the WHOLE supply, /// including coins still in the curve, so nobody can take more than their fraction. /// @param minStockOut empty, or one minimum per leg function redeem(uint256 coins, uint256[] calldata minStockOut, address to) external nonReentrant returns (uint256[] memory stockOut, uint256 ethOut) { if (coins == 0) revert ZeroAmount(); (stockOut, ethOut) = quoteRedeem(coins); if (minStockOut.length != 0) { if (minStockOut.length != stockOut.length) revert BadLegs(); for (uint256 i; i < stockOut.length; i++) if (stockOut[i] < minStockOut[i]) revert Slippage(); } uint256 b = balanceOf[msg.sender]; if (b < coins) revert InsufficientBalance(); unchecked { balanceOf[msg.sender] = b - coins; totalSupply -= coins; } emit Transfer(msg.sender, address(0), coins); totalRedeemed += coins; pendingEth -= ethOut; for (uint256 i; i < stockOut.length; i++) { if (stockOut[i] > 0 && !_callOk(_legs[i].stock, abi.encodeCall(IERC20Min.transfer, (to, stockOut[i])))) { revert StockTransferFailed(); } } if (ethOut > 0) _sendEth(to, ethOut); emit Redeem(msg.sender, to, coins, stockOut, ethOut); } // ------------------------------------------------------------------ for the app /// @notice ABI-encoded (string image, string description, string website, string x, string telegram). function meta() public view returns (bytes memory data) { address p = metaPointer; if (p == address(0)) return data; uint256 size = p.code.length; if (size <= 1) return data; data = new bytes(size - 1); assembly ("memory-safe") { extcodecopy(p, add(data, 32), 1, sub(size, 1)) } } struct Info { string name; string symbol; address creator; uint16 feeBps; uint64 launchedAt; uint256 totalSupply; uint256 ethReserve; uint256 coinReserve; uint256 realEth; uint256 pendingEth; uint256 totalFeesEth; uint256 totalRedeemed; uint256 tradeCount; Leg[] legs; uint256[] holdings; } function info() external view returns (Info memory i) { (uint256 x, uint256 y) = reserves(); i = Info( name, symbol, creator, feeBps, launchedAt, totalSupply, x, y, realEth, pendingEth, totalFeesEth, totalRedeemed, tradeCount, _legs, holdings() ); } function _callOk(address target, bytes memory data) internal returns (bool) { (bool ok, bytes memory ret) = target.call(data); return ok && (ret.length == 0 || (ret.length >= 32 && abi.decode(ret, (bool)))); } function _sendEth(address to, uint256 amt) internal { (bool ok,) = to.call{value: amt}(""); if (!ok) revert EthTransferFailed(); } /// @dev Only the router may send ETH here (a refund). A plain transfer is refused, so no ETH can /// end up outside the accounting. receive() external payable { if (msg.sender != address(router)) revert(); } }