// 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 TroveCoin /// @notice A coin that is its own market and keeps what it trades. /// /// Every buy and every sell pays a fee in ETH. The fee is swapped on Uniswap v3 — ETH → USDG → one /// tokenized stock — and the stock stays in this contract: the coin's trove. Any holder can burn /// coins for exactly their share of the trove. /// /// The treasury 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, and the next trade (or anyone) tries again. /// /// 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 TroveFactory. contract TroveCoin { // ------------------------------------------------------------------ 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; /// @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 treasury 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 treasury buy may land. uint256 public constant MAX_SLIP_BPS = 200; ISwapRouter02 public immutable router; address public immutable weth; address public immutable usdg; address public immutable factory; /// @notice The WETH/USDG pool every treasury buy goes through first, and its fee. address public immutable ethPool; uint24 public immutable ethPoolFee; // ------------------------------------------------------------------ set once at launch address public creator; address public stock; /// @notice The USDG/stock pool the treasury buys through, and its fee tier. address public stockPool; uint24 public stockPoolFee; 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; // ------------------------------------------------------------------ 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 the stock. uint256 public pendingEth; /// @notice Lifetime fee ETH taken, stock bought with it, coins burned for the trove, trades. uint256 public totalFeesEth; uint256 public totalStockBought; 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 TreasuryBuy(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 TreasuryBuyDeferred(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(); 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 } function initialize( string calldata name_, string calldata symbol_, address metaPointer_, address creator_, address stock_, address stockPool_, uint24 stockPoolFee_, uint16 feeBps_, uint256 virtualEth_ ) external { if (msg.sender != factory) revert OnlyFactory(); if (launchedAt != 0) revert AlreadyInitialized(); name = name_; symbol = symbol_; metaPointer = metaPointer_; creator = creator_; stock = stock_; stockPool = stockPool_; stockPoolFee = stockPoolFee_; 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 trove. 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 trove. 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 trove function _accrue(uint256 fee) internal { totalFeesEth += fee; pendingEth += fee; if (pendingEth >= MIN_CONVERT) _convert(pendingEth); } /// @dev Swaps `amt` of the pending fee ETH into the 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(); uint256 fair = fairStockOut(amt); if (fair == 0) { emit TreasuryBuyDeferred(pendingEth, 1); return; } uint256 minOut = Math.mulDiv(fair, (1e6 - ethPoolFee - stockPoolFee) * (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, stockPoolFee, stock), recipient: address(this), amountIn: amt, amountOutMinimum: minOut }) ) returns (uint256 out) { totalStockBought += out; emit TreasuryBuy(amt, out, fair); } catch { pendingEth += amt; emit TreasuryBuyDeferred(pendingEth, 2); } } /// @notice Anyone can push pending fee ETH into the stock — 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 stock units 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(uint256 ethIn) public view returns (uint256) { (bool ok1, int24 t1) = _meanTick(ethPool); if (!ok1) return 0; (bool ok2, int24 t2) = _meanTick(stockPool); if (!ok2) return 0; return _quoteAtTick(t2, _quoteAtTick(t1, ethIn, weth, usdg), usdg, 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); } function troveStock() public view returns (uint256) { return IERC20Min(stock).balanceOf(address(this)); } /// @notice What burning `coins` would pay out right now: that fraction of the stock and of the /// pending fee ETH. function quoteRedeem(uint256 coins) public view returns (uint256 stockOut, uint256 ethOut) { uint256 s = totalSupply; if (s == 0) return (0, 0); stockOut = troveStock() * coins / s; ethOut = pendingEth * coins / s; } /// @notice Burn coins for their share of the trove. The share is over the WHOLE supply, /// including coins still in the curve, so nobody can take more than their fraction. function redeem(uint256 coins, uint256 minStockOut, address to) external nonReentrant returns (uint256 stockOut, uint256 ethOut) { if (coins == 0) revert ZeroAmount(); (stockOut, ethOut) = quoteRedeem(coins); if (stockOut < minStockOut) 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; if (stockOut > 0 && !_callOk(stock, abi.encodeCall(IERC20Min.transfer, (to, stockOut)))) { 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; address stock; address stockPool; uint24 stockPoolFee; uint16 feeBps; uint64 launchedAt; uint256 totalSupply; uint256 ethReserve; uint256 coinReserve; uint256 realEth; uint256 pendingEth; uint256 troveStock; uint256 totalFeesEth; uint256 totalStockBought; uint256 totalRedeemed; uint256 tradeCount; } function info() external view returns (Info memory i) { (uint256 x, uint256 y) = reserves(); i = Info( name, symbol, creator, stock, stockPool, stockPoolFee, feeBps, launchedAt, totalSupply, x, y, realEth, pendingEth, troveStock(), totalFeesEth, totalStockBought, totalRedeemed, tradeCount ); } 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(); } }