// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.26; // PosyPots — group gifts in tokenized stocks on Robinhood Chain. // // Anyone opens a pot for a recipient: up to six Robinhood stock tokens with weights, an optional USDG goal, a // deadline and a slippage limit. Anyone chips in USDG. When the pot closes — the organiser may close it at any // time, anyone may close it after the deadline — this contract buys every stock through Uniswap's SwapRouter02 // and the shares go straight to the recipient. If the organiser cancels instead (or nobody closes it within 30 // days of the deadline), every contributor takes back exactly what they put in. // // There is no owner, no fee, no upgrade and no admin key. Every address below is a constant, so the contract has // no constructor arguments and lands at the same CREATE2 address for whoever deploys it first. // // The price guard: the caller of close() passes a minimum for every leg (the page takes it from Uniswap's // QuoterV2). The contract refuses any minimum below what the pool's own 30-minute time-weighted price says the // leg is worth, less the slippage the organiser chose when opening the pot. A price pushed around inside the // closing transaction does not move a 30-minute average, so a closer cannot sandwich the pot. // // TickMath and FullMath below are adapted from Uniswap v3-core (GPL-2.0-or-later) for Solidity 0.8. interface IERC20 { function transfer(address to, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool); function approve(address spender, uint256 amount) external returns (bool); } interface ISwapRouter02 { struct ExactInputSingleParams { address tokenIn; address tokenOut; uint24 fee; address recipient; uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; } function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); } interface IUniswapV3Factory { function getPool(address a, address b, uint24 fee) external view returns (address); } contract PosyPots { // ------------------------------------------------------------------ the chain's own contracts address public constant USDG = 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168; address public constant ROUTER = 0xCaf681a66D020601342297493863E78C959E5cb2; // Uniswap SwapRouter02 address public constant FACTORY = 0x1f7d7550B1b028f7571E69A784071F0205FD2EfA; // Uniswap v3 factory address private constant ARBSYS = 0x0000000000000000000000000000000000000064; uint32 public constant TWAP_WINDOW = 1800; // seconds uint16 public constant MAX_SLIPPAGE_BPS = 1500; // 15% uint256 public constant MAX_STOCKS = 6; uint256 public constant MAX_DURATION = 365 days; uint256 public constant GRACE = 30 days; // after deadline + GRACE, anyone may cancel an unclosed pot uint256 public constant MAX_TEXT = 64; // bytes, for a pot's title and a contributor's note enum State { None, Open, Closed, Cancelled } struct Pot { address organiser; uint64 deadline; State state; uint8 n; uint16 slippageBps; address recipient; uint128 goal; uint128 raised; address[6] stocks; uint24[6] fees; uint16[6] weights; // basis points, adding up to 10,000 } /// Robinhood Chain block number at deployment: where a reader starts looking for this contract's events. uint256 public born; uint256 public count; mapping(uint256 => Pot) private _pots; /// USDG each address has put into each pot and not taken back. mapping(uint256 => mapping(address => uint256)) public contributed; uint256 private _entered; // 0 = not entered; no initialiser, so no constructor write is needed for the lock /// One stock in a pot: the token, the Uniswap fee tier of the USDG pool to buy it in, and its share in basis points. struct Leg { address stock; uint24 fee; uint16 weight; } event Opened(uint256 indexed id, address indexed organiser, address indexed recipient, uint64 deadline, uint128 goal, uint16 slippageBps, Leg[] stems, string title); event Chipped(uint256 indexed id, address indexed from, uint256 amount, string note); event Bought(uint256 indexed id, address indexed stock, uint256 usdgIn, uint256 sharesOut); event Closed(uint256 indexed id, address indexed by, uint256 raised); event Cancelled(uint256 indexed id, address indexed by); event Refunded(uint256 indexed id, address indexed to, uint256 amount); error BadRecipient(); error BadStocks(); error BadWeights(); error BadFee(uint256 leg); error NoPool(address stock); error BadDeadline(); error BadSlippage(); error TextTooLong(); error NotOpen(); error NotOrganiser(); error TooEarly(); error DeadlinePassed(); error ZeroAmount(); error TooMuch(); error Empty(); error BadMinimums(); error MinimumTooLow(uint256 leg, uint256 floor); error TwapUnavailable(address pool); error NotCancelled(); error NothingToRefund(); error TransferFailed(); error Reentrant(); modifier lock() { if (_entered != 0) revert Reentrant(); _entered = 1; _; _entered = 0; } constructor() { born = _arbBlock(); } // ------------------------------------------------------------------ open function open(address recipient, uint64 deadline, uint128 goal, uint16 slippageBps, Leg[] calldata stems, string calldata title) external returns (uint256 id) { uint256 n = stems.length; if (recipient == address(0) || recipient == address(this)) revert BadRecipient(); if (n == 0 || n > MAX_STOCKS) revert BadStocks(); if (deadline <= block.timestamp || deadline > block.timestamp + MAX_DURATION) revert BadDeadline(); if (slippageBps == 0 || slippageBps > MAX_SLIPPAGE_BPS) revert BadSlippage(); if (bytes(title).length > MAX_TEXT) revert TextTooLong(); id = ++count; Pot storage p = _pots[id]; uint256 total; for (uint256 i; i < n; ++i) { Leg calldata l = stems[i]; if (l.stock == address(0) || l.stock == USDG) revert BadStocks(); for (uint256 j; j < i; ++j) if (stems[j].stock == l.stock) revert BadStocks(); if (l.fee != 100 && l.fee != 500 && l.fee != 3000 && l.fee != 10000) revert BadFee(i); if (IUniswapV3Factory(FACTORY).getPool(USDG, l.stock, l.fee) == address(0)) revert NoPool(l.stock); if (l.weight == 0) revert BadWeights(); total += l.weight; p.stocks[i] = l.stock; p.fees[i] = l.fee; p.weights[i] = l.weight; } if (total != 10_000) revert BadWeights(); p.organiser = msg.sender; p.deadline = deadline; p.state = State.Open; p.n = uint8(n); p.slippageBps = slippageBps; p.recipient = recipient; p.goal = goal; emit Opened(id, msg.sender, recipient, deadline, goal, slippageBps, stems, title); } // ------------------------------------------------------------------ chip in function contribute(uint256 id, uint256 amount, string calldata note) external lock { Pot storage p = _pots[id]; if (p.state != State.Open) revert NotOpen(); if (block.timestamp > p.deadline) revert DeadlinePassed(); if (amount == 0) revert ZeroAmount(); if (amount > type(uint128).max) revert TooMuch(); // an explicit uint128(...) cast would truncate, not revert if (bytes(note).length > MAX_TEXT) revert TextTooLong(); p.raised += uint128(amount); // checked: reverts if the pot's total would pass 2^128 contributed[id][msg.sender] += amount; _pull(msg.sender, amount); emit Chipped(id, msg.sender, amount, note); } // ------------------------------------------------------------------ close: buy every stock for the recipient /// @param minOut the least number of raw stock units each leg must deliver, in the pot's stock order. function close(uint256 id, uint256[] calldata minOut) external lock { Pot storage p = _pots[id]; if (p.state != State.Open) revert NotOpen(); if (msg.sender != p.organiser && block.timestamp <= p.deadline) revert TooEarly(); uint256 n = p.n; if (minOut.length != n) revert BadMinimums(); uint256 raised = p.raised; if (raised == 0) revert Empty(); p.state = State.Closed; (uint256[] memory amounts, uint256[] memory floors) = _legs(p, raised); _approve(ROUTER, raised); for (uint256 i; i < n; ++i) { if (amounts[i] == 0) continue; if (minOut[i] < floors[i]) revert MinimumTooLow(i, floors[i]); uint256 out = ISwapRouter02(ROUTER).exactInputSingle( ISwapRouter02.ExactInputSingleParams({ tokenIn: USDG, tokenOut: p.stocks[i], fee: p.fees[i], recipient: p.recipient, amountIn: amounts[i], amountOutMinimum: minOut[i], sqrtPriceLimitX96: 0 }) ); emit Bought(id, p.stocks[i], amounts[i], out); } emit Closed(id, msg.sender, raised); } // ------------------------------------------------------------------ cancel and refund function cancel(uint256 id) external { Pot storage p = _pots[id]; if (p.state != State.Open) revert NotOpen(); if (msg.sender != p.organiser && block.timestamp <= uint256(p.deadline) + GRACE) revert NotOrganiser(); p.state = State.Cancelled; emit Cancelled(id, msg.sender); } function refund(uint256 id) external lock { if (_pots[id].state != State.Cancelled) revert NotCancelled(); uint256 amount = contributed[id][msg.sender]; if (amount == 0) revert NothingToRefund(); contributed[id][msg.sender] = 0; _pots[id].raised -= uint128(amount); _send(msg.sender, amount); emit Refunded(id, msg.sender, amount); } // ------------------------------------------------------------------ reads function pot(uint256 id) external view returns (Pot memory) { return _pots[id]; } /// What close() would spend on each leg right now, and the least each leg's minimum may be. function legs(uint256 id) external view returns (uint256[] memory amounts, uint256[] memory floors) { Pot storage p = _pots[id]; if (p.state == State.None) revert NotOpen(); return _legs(p, p.raised); } // ------------------------------------------------------------------ internals function _legs(Pot storage p, uint256 raised) internal view returns (uint256[] memory amounts, uint256[] memory floors) { uint256 n = p.n; amounts = new uint256[](n); floors = new uint256[](n); uint256 spent; for (uint256 i; i < n; ++i) { uint256 a = i == n - 1 ? raised - spent : (raised * p.weights[i]) / 10_000; spent += a; amounts[i] = a; if (a == 0) continue; address pool = IUniswapV3Factory(FACTORY).getPool(USDG, p.stocks[i], p.fees[i]); uint256 fair = _quoteAtTick(_twapTick(pool), uint128(a), USDG, p.stocks[i]); floors[i] = (fair * (10_000 - p.slippageBps)) / 10_000; } } /// The pool's arithmetic-mean tick over the last TWAP_WINDOW seconds, rounded toward negative infinity. function _twapTick(address pool) internal view returns (int24) { uint32[] memory ago = new uint32[](2); ago[0] = TWAP_WINDOW; (bool ok, bytes memory ret) = pool.staticcall(abi.encodeWithSignature("observe(uint32[])", ago)); if (!ok || ret.length < 64) revert TwapUnavailable(pool); (int56[] memory cum, ) = abi.decode(ret, (int56[], uint160[])); int56 delta = cum[1] - cum[0]; int56 w = int56(uint56(TWAP_WINDOW)); int24 tick = int24(delta / w); if (delta < 0 && (delta % w != 0)) tick--; return tick; } /// Uniswap's OracleLibrary.getQuoteAtTick: how much `quote` `baseAmount` of `base` is worth at `tick`. function _quoteAtTick(int24 tick, uint128 baseAmount, address base, address quote) internal pure returns (uint256) { uint160 sqrtRatioX96 = _sqrtRatioAtTick(tick); if (sqrtRatioX96 <= type(uint128).max) { uint256 ratioX192 = uint256(sqrtRatioX96) * sqrtRatioX96; return base < quote ? _mulDiv(ratioX192, baseAmount, 1 << 192) : _mulDiv(1 << 192, baseAmount, ratioX192); } uint256 ratioX128 = _mulDiv(sqrtRatioX96, sqrtRatioX96, 1 << 64); return base < quote ? _mulDiv(ratioX128, baseAmount, 1 << 128) : _mulDiv(1 << 128, baseAmount, ratioX128); } function _sqrtRatioAtTick(int24 tick) internal pure returns (uint160) { unchecked { uint256 absTick = tick < 0 ? uint256(-int256(tick)) : uint256(int256(tick)); require(absTick <= 887272, "T"); uint256 ratio = absTick & 0x1 != 0 ? 0xfffcb933bd6fad37aa2d162d1a594001 : 0x100000000000000000000000000000000; if (absTick & 0x2 != 0) ratio = (ratio * 0xfff97272373d413259a46990580e213a) >> 128; if (absTick & 0x4 != 0) ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdcc) >> 128; if (absTick & 0x8 != 0) ratio = (ratio * 0xffe5caca7e10e4e61c3624eaa0941cd0) >> 128; if (absTick & 0x10 != 0) ratio = (ratio * 0xffcb9843d60f6159c9db58835c926644) >> 128; if (absTick & 0x20 != 0) ratio = (ratio * 0xff973b41fa98c081472e6896dfb254c0) >> 128; if (absTick & 0x40 != 0) ratio = (ratio * 0xff2ea16466c96a3843ec78b326b52861) >> 128; if (absTick & 0x80 != 0) ratio = (ratio * 0xfe5dee046a99a2a811c461f1969c3053) >> 128; if (absTick & 0x100 != 0) ratio = (ratio * 0xfcbe86c7900a88aedcffc83b479aa3a4) >> 128; if (absTick & 0x200 != 0) ratio = (ratio * 0xf987a7253ac413176f2b074cf7815e54) >> 128; if (absTick & 0x400 != 0) ratio = (ratio * 0xf3392b0822b70005940c7a398e4b70f3) >> 128; if (absTick & 0x800 != 0) ratio = (ratio * 0xe7159475a2c29b7443b29c7fa6e889d9) >> 128; if (absTick & 0x1000 != 0) ratio = (ratio * 0xd097f3bdfd2022b8845ad8f792aa5825) >> 128; if (absTick & 0x2000 != 0) ratio = (ratio * 0xa9f746462d870fdf8a65dc1f90e061e5) >> 128; if (absTick & 0x4000 != 0) ratio = (ratio * 0x70d869a156d2a1b890bb3df62baf32f7) >> 128; if (absTick & 0x8000 != 0) ratio = (ratio * 0x31be135f97d08fd981231505542fcfa6) >> 128; if (absTick & 0x10000 != 0) ratio = (ratio * 0x9aa508b5b7a84e1c677de54f3e99bc9) >> 128; if (absTick & 0x20000 != 0) ratio = (ratio * 0x5d6af8dedb81196699c329225ee604) >> 128; if (absTick & 0x40000 != 0) ratio = (ratio * 0x2216e584f5fa1ea926041bedfe98) >> 128; if (absTick & 0x80000 != 0) ratio = (ratio * 0x48a170391f7dc42444e8fa2) >> 128; if (tick > 0) ratio = type(uint256).max / ratio; return uint160((ratio >> 32) + (ratio % (1 << 32) == 0 ? 0 : 1)); } } /// floor(a * b / d) with a 512-bit intermediate (Remco Bloemen's algorithm, as in Uniswap's FullMath). function _mulDiv(uint256 a, uint256 b, uint256 d) internal pure returns (uint256 result) { unchecked { uint256 prod0; uint256 prod1; assembly { let mm := mulmod(a, b, not(0)) prod0 := mul(a, b) prod1 := sub(sub(mm, prod0), lt(mm, prod0)) } if (prod1 == 0) { require(d > 0); return prod0 / d; } require(d > prod1); uint256 remainder; assembly { remainder := mulmod(a, b, d) prod1 := sub(prod1, gt(remainder, prod0)) prod0 := sub(prod0, remainder) } uint256 twos = d & (~d + 1); assembly { d := div(d, twos) prod0 := div(prod0, twos) twos := add(div(sub(0, twos), twos), 1) } prod0 |= prod1 * twos; uint256 inv = (3 * d) ^ 2; inv *= 2 - d * inv; inv *= 2 - d * inv; inv *= 2 - d * inv; inv *= 2 - d * inv; inv *= 2 - d * inv; inv *= 2 - d * inv; result = prod0 * inv; } } function _arbBlock() internal view returns (uint256) { (bool ok, bytes memory ret) = ARBSYS.staticcall(abi.encodeWithSignature("arbBlockNumber()")); return ok && ret.length == 32 ? abi.decode(ret, (uint256)) : block.number; } function _pull(address from, uint256 amount) internal { (bool ok, bytes memory ret) = USDG.call(abi.encodeCall(IERC20.transferFrom, (from, address(this), amount))); if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TransferFailed(); } function _send(address to, uint256 amount) internal { (bool ok, bytes memory ret) = USDG.call(abi.encodeCall(IERC20.transfer, (to, amount))); if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TransferFailed(); } function _approve(address spender, uint256 amount) internal { (bool ok, bytes memory ret) = USDG.call(abi.encodeCall(IERC20.approve, (spender, amount))); if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TransferFailed(); } }