// SPDX-License-Identifier: MIT pragma solidity 0.8.26; /// @title Vane — up-or-down pools on Robinhood Chain's own price feeds /// @notice Anyone may open a pool on any price feed for any window [lock, close]. Until `lock` people put USDG on /// UP (the price at `close` is above the price at `lock`) or DOWN. After `close` anyone settles it by naming, for each /// of the two instants, the feed round that was the latest one at that instant; the contract checks the claim itself. /// Winners share the whole pool in proportion to what they put in. A tie, a pool with nobody on one side, or a pool /// nobody settles within seven days of `close` gives everyone their stake back. /// There is no owner, no fee, no upgrade and no pause. The contract holds USDG and reads feeds; nothing else. contract Vane { // ------------------------------------------------------------------ types struct Pool { uint128 up; // USDG on UP uint128 down; // USDG on DOWN uint8 state; // 0 open, 1 UP won, 2 DOWN won, 3 everyone refunded int256 lockPrice; // set when settled on prices int256 closePrice; } uint8 internal constant OPEN = 0; uint8 internal constant UP = 1; uint8 internal constant DOWN = 2; uint8 internal constant REFUND = 3; uint256 public constant MIN_STAKE = 1e6; // 1 USDG uint256 public constant MIN_SPAN = 1 hours; // shortest window between lock and close uint256 public constant MAX_SPAN = 90 days; // longest uint256 public constant MAX_LEAD = 90 days; // how far ahead of now a lock may be uint256 public constant GRACE = 7 days; // after close + GRACE an unsettled pool can be refunded address public immutable usdg; mapping(bytes32 => Pool) internal _pools; mapping(bytes32 => mapping(address => uint256)) internal _up; mapping(bytes32 => mapping(address => uint256)) internal _down; // ------------------------------------------------------------------ events event Opened(bytes32 indexed key, address indexed feed, uint64 lock, uint64 close); event Predicted(bytes32 indexed key, address indexed who, bool up, uint256 amount); event Settled(bytes32 indexed key, uint8 outcome, int256 lockPrice, int256 closePrice, uint80 lockRound, uint80 closeRound); event Voided(bytes32 indexed key); event Paid(bytes32 indexed key, address indexed who, uint256 amount); // ------------------------------------------------------------------ errors error TooLate(); // betting closed at lock error BadWindow(); // lock too far ahead, or close not 1 h – 90 d after lock error StakeTooSmall(); error NotAFeed(); error TooBig(); error NotClosed(); // close has not passed error AlreadySettled(); error NoPool(); error NotEffective(); // the named round was not the latest one at that instant error BadAnswer(); // the feed's answer is not a positive price error NotSettled(); error NothingToClaim(); error TooEarly(); // the grace period has not run out error TransferFailed(); constructor(address usdg_) { usdg = usdg_; } // ------------------------------------------------------------------ reading function keyOf(address feed, uint64 lock, uint64 close) public pure returns (bytes32) { return keccak256(abi.encode(feed, lock, close)); } function pool(bytes32 key) external view returns (Pool memory) { return _pools[key]; } function stakeOf(bytes32 key, address who) external view returns (uint256 up, uint256 down) { return (_up[key][who], _down[key][who]); } /// @notice What `who` would be paid by `claim` now (0 if unsettled or nothing to collect). function owed(bytes32 key, address who) public view returns (uint256) { Pool storage p = _pools[key]; uint256 u = _up[key][who]; uint256 d = _down[key][who]; if (p.state == REFUND) return u + d; uint256 total = uint256(p.up) + uint256(p.down); if (p.state == UP) return u == 0 ? 0 : (u * total) / p.up; if (p.state == DOWN) return d == 0 ? 0 : (d * total) / p.down; return 0; } /// @notice The feed's answer at instant `t`, given the round that was the latest one at `t`. Reverts /// `NotEffective` unless round `r` had landed by `t` and the round after it had not. function priceAt(address feed, uint256 t, uint80 r) public view returns (int256) { (bool ok, int256 answer, uint256 at) = _round(feed, r); if (!ok || at == 0 || at > t) revert NotEffective(); if (answer <= 0) revert BadAnswer(); (bool okNext, , uint256 nextAt) = _round(feed, r + 1); if (okNext && nextAt != 0) { if (nextAt <= t) revert NotEffective(); } else { // r is the last round of its phase. If its phase is not the feed's current one, the round after it is // round 1 of the next phase, and that one must have landed after t. (bool okLatest, bytes memory ret) = feed.staticcall(abi.encodeWithSelector(0xfeaf968c)); // latestRoundData() if (!okLatest || ret.length < 160) revert NotEffective(); uint80 latest = abi.decode(ret, (uint80)); uint256 phase = uint256(r) >> 64; uint256 current = uint256(latest) >> 64; if (phase != current) { if (phase > current) revert NotEffective(); (bool okFirst, , uint256 firstAt) = _round(feed, uint80(((phase + 1) << 64) | 1)); if (!okFirst || firstAt <= t) revert NotEffective(); } } return answer; } // ------------------------------------------------------------------ writing /// @notice Put `amount` USDG on UP (`up` true) or DOWN in the pool (feed, lock, close), opening it if new. function predict(address feed, uint64 lock, uint64 close, bool up, uint256 amount) external returns (bytes32 key) { if (block.timestamp >= lock) revert TooLate(); if (lock > block.timestamp + MAX_LEAD || close < uint256(lock) + MIN_SPAN || close > uint256(lock) + MAX_SPAN) revert BadWindow(); if (amount < MIN_STAKE) revert StakeTooSmall(); key = keyOf(feed, lock, close); Pool storage p = _pools[key]; if (p.up == 0 && p.down == 0) { // The first stake opens the pool: the feed must answer like a price feed, with a positive price, today. (bool ok, bytes memory ret) = feed.staticcall(abi.encodeWithSelector(0xfeaf968c)); if (!ok || ret.length < 160) revert NotAFeed(); (, int256 answer, , uint256 at, ) = abi.decode(ret, (uint80, int256, uint256, uint256, uint80)); if (answer <= 0 || at == 0) revert NotAFeed(); emit Opened(key, feed, lock, close); } if (up) { uint256 n = uint256(p.up) + amount; if (n + p.down > type(uint128).max) revert TooBig(); p.up = uint128(n); _up[key][msg.sender] += amount; } else { uint256 n = uint256(p.down) + amount; if (n + p.up > type(uint128).max) revert TooBig(); p.down = uint128(n); _down[key][msg.sender] += amount; } emit Predicted(key, msg.sender, up, amount); _pull(msg.sender, amount); } /// @notice Settle a closed pool. `lockRound` / `closeRound` are the feed rounds that were the latest ones at /// `lock` and `close`; the contract checks both. A pool with nobody on one side refunds without reading the feed. function settle(address feed, uint64 lock, uint64 close, uint80 lockRound, uint80 closeRound) external { bytes32 key = keyOf(feed, lock, close); Pool storage p = _pools[key]; if (p.up == 0 && p.down == 0) revert NoPool(); if (p.state != OPEN) revert AlreadySettled(); if (block.timestamp <= close) revert NotClosed(); if (p.up == 0 || p.down == 0) { p.state = REFUND; emit Settled(key, REFUND, 0, 0, 0, 0); return; } int256 a = priceAt(feed, lock, lockRound); int256 b = priceAt(feed, close, closeRound); uint8 outcome = b > a ? UP : (b < a ? DOWN : REFUND); p.state = outcome; p.lockPrice = a; p.closePrice = b; emit Settled(key, outcome, a, b, lockRound, closeRound); } /// @notice Refund everyone in the pool (feed, lock, close) if nobody settled it within GRACE of `close`. function abandon(address feed, uint64 lock, uint64 close) external { bytes32 key = keyOf(feed, lock, close); Pool storage p = _pools[key]; if (p.up == 0 && p.down == 0) revert NoPool(); if (p.state != OPEN) revert AlreadySettled(); if (block.timestamp <= uint256(close) + GRACE) revert TooEarly(); p.state = REFUND; emit Voided(key); } /// @notice Pay `who` what a settled pool owes them. Anyone may deliver it; it always goes to `who`. function claim(bytes32 key, address who) public returns (uint256 amount) { Pool storage p = _pools[key]; if (p.state == OPEN) revert NotSettled(); amount = owed(key, who); if (amount == 0) revert NothingToClaim(); _up[key][who] = 0; _down[key][who] = 0; emit Paid(key, who, amount); _push(who, amount); } /// @notice Use a USDG permit signed by the caller for this contract — so a first stake needs no approval. function permitUsdg(uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external { (bool ok, ) = usdg.call(abi.encodeWithSelector(0xd505accf, msg.sender, address(this), value, deadline, v, r, s)); // A permit somebody already used (front-run from the mempool) leaves the allowance in place; let the next // call decide, rather than failing a stake the allowance already covers. ok; } /// @notice Several of this contract's own calls in one transaction (each runs as the caller). function multicall(bytes[] calldata calls) external returns (bytes[] memory results) { results = new bytes[](calls.length); for (uint256 i; i < calls.length; ++i) { (bool ok, bytes memory ret) = address(this).delegatecall(calls[i]); if (!ok) { assembly { revert(add(ret, 32), mload(ret)) } } results[i] = ret; } } // ------------------------------------------------------------------ internals function _round(address feed, uint80 r) internal view returns (bool ok, int256 answer, uint256 at) { (bool success, bytes memory ret) = feed.staticcall(abi.encodeWithSelector(0x9a6fc8f5, r)); // getRoundData(uint80) if (!success || ret.length < 160) return (false, 0, 0); (uint80 id, int256 a, , uint256 u, ) = abi.decode(ret, (uint80, int256, uint256, uint256, uint80)); if (id != r) return (false, 0, 0); return (true, a, u); } function _pull(address from, uint256 amount) internal { (bool ok, bytes memory ret) = usdg.call(abi.encodeWithSelector(0x23b872dd, from, address(this), amount)); if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TransferFailed(); } function _push(address to, uint256 amount) internal { (bool ok, bytes memory ret) = usdg.call(abi.encodeWithSelector(0xa9059cbb, to, amount)); if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TransferFailed(); } }