CoinflipResolver

Description:

Decentralized Finance (DeFi) protocol contract providing Swap, Staking, Yield, Factory functionality.

Blockchain: Ethereum

Source Code: View Code On The Blockchain

Solidity Source Code:

{{
  "language": "Solidity",
  "sources": {
    "src/CoinflipResolver.sol": {
      "content": "// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

/* ───────────────────────────── PM minimal interface ───────────────────────────── */

interface IPM {
    function createMarket(
        string calldata description,
        address resolver,
        uint72 close,
        bool canClose
    ) external returns (uint256 marketId, uint256 noId);

    function resolve(uint256 marketId, bool outcome) external;
    function setResolverFeeBps(uint16 bps) external;

    function getMarket(uint256 marketId)
        external
        view
        returns (
            uint256 yesSupply,
            uint256 noSupply,
            address resolver,
            bool resolved,
            bool outcome,
            uint256 pot,
            uint256 payoutPerShare,
            string memory desc
        );

    function getNoId(uint256 marketId) external pure returns (uint256);
    function balanceOf(address owner, uint256 id) external view returns (uint256);
}

/* ───────────────────────────── CoinflipResolver ───────────────────────────── */
/**
 * @title CoinflipResolver
 * @notice Spawns perpetual YES/NO pari-mutuel markets on PM and resolves each epoch
 *         from a *pre-committed* pair of future blocks. Outcome is:
 *
 *           YES  iff  LSB( keccak256(blockhash(tgt), blockhash(tgt+1), marketId) ) == 1
 *
 *         where `tgt` is computed at market creation to occur strictly *after* trading
 *         closes, using a conservative seconds→blocks conversion plus a post-close buffer.
 *
 * Core behavior
 * - Trading window: `closeDelay` seconds from creation (default ~55m).
 * - Target selection: `tgt = currentBlock + ceil(closeDelay/10s) + resolveDelayBlocks`
 *   so the target is *after* close; `resolveDelayBlocks` is the post-close buffer.
 * - Resolve window: callable only after close and strictly after block `tgt+1`
 *   (both hashes available), and no later than `tgt + maxResolveLagBlocks`
 *   (blockhash availability window ≤ 256).
 * - Dead markets: if both sides have zero supply, the market is *skipped immediately*
 *   (even pre-close) and the resolver rolls forward to the next epoch. PM remains
 *   unresolved by design in this path.
 * - Emergency fallback: if the normal window is missed, owner may resolve after an
 *   additional `emergencyDelayBlocks` using parity of `keccak256(seedAtCreation, marketId)`,
 *   where `seedAtCreation` is `block.prevrandao` captured at creation.
 * - Incentives: a fixed ETH tip (`tipPerResolve`) is paid to the successful caller.
 * - Auto-roll: every terminal path (resolved, skipped, or emergency) immediately starts
 *   the next market.
 *
 * Security notes
 * - The randomness is bound at creation; callers cannot cherry-pick blocks.
 * - Mixing two consecutive block hashes meaningfully reduces single-proposer bias,
 *   but does not eliminate builder/proposer influence entirely; suitable for
 *   low-stakes / PoC settings.
 * - The tip is paid via a call to the caller; if the call fails, resolution reverts.
 * - Uses a lightweight reentrancy guard.
 */
contract CoinflipResolver {
    /* ───────────────────────────── config/constants ───────────────────────────── */

    IPM public constant PM = IPM(0x0000000000F8d9F51f0765a9dAd6a9487ba85f1e);

    // Convert seconds -> blocks conservatively so target is strictly AFTER close.
    // Using 10s per block slightly overestimates (Ethereum ≈12s) and biases later.
    uint256 constant SECONDS_PER_BLOCK_BIAS = 10;

    address constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0;
    address constant ZSTETH = 0x000000000077B216105413Dc45Dc6F6256577c7B;

    // Keeper incentive
    uint256 public tipPerResolve = 0.001 ether;

    // Timing knobs
    uint256 public closeDelay = 55 minutes; // seconds from creation to trading close
    uint256 public resolveDelayBlocks = 25; // EXTRA blocks AFTER close before the target block (post-close buffer)
    uint256 public maxResolveLagBlocks = 200; // must resolve within this many blocks after target (<= 255)
    uint256 public emergencyDelayBlocks = 600; // additional blocks after miss before owner fallback

    // Ownership
    event OwnershipTransferred(address indexed from, address indexed to);

    address public owner;

    error Unauthorized();

    modifier onlyOwner() {
        if (msg.sender != owner) revert Unauthorized();
        _;
    }

    function transferOwnership(address _owner) public payable onlyOwner {
        emit OwnershipTransferred(msg.sender, owner = _owner);
    }

    /// @dev Staking function into Lido wstETH for ETH inside this contract.
    function stakeETH(uint256 amount) public payable onlyOwner {
        assembly ("memory-safe") {
            pop(call(gas(), WSTETH, amount, codesize(), 0x00, codesize(), 0x00))
        }
    }

    /// @dev Governed exact-in swap to redeem yield from Lido staking.
    function swapExactWSTETHtoETH(address to, uint256 amount, uint256 minOut)
        public
        payable
        onlyOwner
    {
        CoinflipResolver(payable(ZSTETH)).swapExactWSTETHtoETH(to, amount, minOut);
    }

    /// @dev Governed exact-out swap to redeem yield from Lido staking.
    function swapWSTETHtoExactETH(address to, uint256 exactOut, uint256 maxIn)
        public
        payable
        onlyOwner
    {
        CoinflipResolver(payable(ZSTETH)).swapWSTETHtoExactETH(to, exactOut, maxIn);
    }

    // Soledge reentrancy guard:
    // (https://github.com/Vectorized/soledge/blob/main/src/utils/ReentrancyGuard.sol)

    error Reentrancy();

    uint256 constant REENTRANCY_GUARD_SLOT = 0x929eee149b4bd21268;

    modifier nonReentrant() {
        assembly ("memory-safe") {
            if tload(REENTRANCY_GUARD_SLOT) {
                mstore(0x00, 0xab143c06) // `Reentrancy()`
                revert(0x1c, 0x04)
            }
            tstore(REENTRANCY_GUARD_SLOT, address())
        }
        _;
        assembly ("memory-safe") {
            tstore(REENTRANCY_GUARD_SLOT, 0)
        }
    }

    /* ───────────────────────────── market state ───────────────────────────── */

    struct Epoch {
        uint72 closeAt; // market closes for trading at this timestamp
        uint64 targetBlock; // block number committed at creation for randomness
        uint256 seed; // precommitted seed (block.prevrandao at creation) for emergency fallback
        bool resolved; // local marker to prevent double work
    }

    uint256 public currentMarketId;
    mapping(uint256 => Epoch) public epochs;

    /* ───────────────────────────── events ───────────────────────────── */

    event MarketStarted(
        uint256 indexed marketId, uint72 closeAt, uint64 targetBlock, string description
    );

    event MarketResolved(
        uint256 indexed marketId,
        bool outcome,
        uint64 targetBlock,
        bytes32 usedHash,
        uint256 tipPaid
    );

    event DeadMarketSkipped(uint256 indexed marketId, uint256 tipPaid);
    event Rolled(uint256 indexed fromMarket, uint256 indexed toMarket);

    event TipPerResolveSet(uint256 newTip);
    event TimeParamsSet(
        uint256 closeDelay, uint256 resolveDelayBlocks, uint256 maxLag, uint256 emergencyLag
    );
    event EmergencyResolved(uint256 indexed marketId, bool outcome, uint256 when);
    event PMResolverFeeSet(uint16 bps);

    /* ───────────────────────────── errors ───────────────────────────── */

    error ActiveMarketLive();
    error NoActiveMarket();
    error AlreadyResolved();
    error MarketTooSoon(); // block.timestamp < closeAt
    error TooEarlyForBlock(); // block.number <= targetBlock
    error ResolveWindowExceeded(); // block.number > targetBlock + maxResolveLagBlocks
    error TipPaymentFailed();
    error BadParams();
    error InvalidHash();

    /* ───────────────────────────── ctor/owner ───────────────────────────── */

    constructor() payable {
        emit OwnershipTransferred(address(0), owner = msg.sender);
    }

    /* ───────────────────────────── admin knobs ───────────────────────────── */

    function setTipPerResolve(uint256 newTip) public payable onlyOwner {
        tipPerResolve = newTip;
        emit TipPerResolveSet(newTip);
    }

    function setPMResolverFeeBps(uint16 bps) public payable onlyOwner {
        // PM enforces bps <= 1000 (10%) internally; we can be lenient here:
        PM.setResolverFeeBps(bps);
        emit PMResolverFeeSet(bps);
    }

    function setTimeParams(
        uint256 _closeDelay,
        uint256 _resolveDelayBlocks,
        uint256 _maxResolveLagBlocks,
        uint256 _emergencyDelayBlocks
    ) public payable onlyOwner {
        if (_closeDelay < 5 minutes) revert BadParams();
        if (_resolveDelayBlocks == 0) revert BadParams();
        if (_maxResolveLagBlocks < 2 || _maxResolveLagBlocks > 255) revert BadParams();
        if (_emergencyDelayBlocks < 60 || _emergencyDelayBlocks > 100_000) revert BadParams();

        // prevent uint72 overflow at _startNextMarket()
        unchecked {
            if (_closeDelay > type(uint72).max - block.timestamp) revert BadParams();
        }

        closeDelay = _closeDelay;
        resolveDelayBlocks = _resolveDelayBlocks;
        maxResolveLagBlocks = _maxResolveLagBlocks;
        emergencyDelayBlocks = _emergencyDelayBlocks;

        emit TimeParamsSet(
            _closeDelay, _resolveDelayBlocks, _maxResolveLagBlocks, _emergencyDelayBlocks
        );
    }

    /* ───────────────────────────── funding (tips) ───────────────────────────── */

    event Funded(address indexed from, uint256 amount);

    receive() external payable {
        emit Funded(msg.sender, msg.value);
    }

    function fundTips() public payable {
        emit Funded(msg.sender, msg.value);
    }

    /* ───────────────────────────── lifecycle ───────────────────────────── */

    /// @notice Start the first (or next) market. Reverts if a live one exists.
    function startNewMarket() public payable onlyOwner {
        uint256 _current = currentMarketId;
        if (_current != 0 && !epochs[_current].resolved) revert ActiveMarketLive();
        _startNextMarket();
    }

    /// @notice Resolve current market using the pre-committed target block; then roll.
    function resolve() public payable nonReentrant {
        uint256 mId = currentMarketId;
        if (mId == 0) revert NoActiveMarket();
        Epoch storage e = epochs[mId];
        if (e.resolved) revert AlreadyResolved();

        // Check dead-market: both sides 0 → mark + tip + roll:
        (uint256 y, uint256 n,, bool pmResolved,,,,) = PM.getMarket(mId);
        if (pmResolved) {
            e.resolved = true;
            _roll(mId);
            return;
        } // defensive
        if (y == 0 && n == 0) {
            e.resolved = true;
            uint256 paid = _payTip();
            emit DeadMarketSkipped(mId, paid);
            _roll(mId);
            return;
        }

        // Must be after close
        if (block.timestamp < e.closeAt) revert MarketTooSoon();

        // Use *committed* blockhash for randomness:
        uint64 tgt = e.targetBlock;
        if (block.number <= uint256(tgt) + 1) revert TooEarlyForBlock();
        uint256 maxBlock = uint256(tgt) + maxResolveLagBlocks;
        if (block.number > maxBlock) revert ResolveWindowExceeded();

        bytes32 h1 = blockhash(uint256(tgt));
        bytes32 h2 = blockhash(uint256(tgt + 1));
        require(h1 != 0 && h2 != 0, InvalidHash());
        bool outcome = (uint256(keccak256(abi.encodePacked(h1, h2, mId))) & 1) == 1;

        // Resolve in PM (PM enforces close, refunds if one side = 0):
        PM.resolve(mId, outcome);
        e.resolved = true;

        uint256 tipPaid = _payTip();
        bytes32 used = keccak256(abi.encodePacked(h1, h2, mId));
        emit MarketResolved(mId, outcome, tgt, used, tipPaid);

        _roll(mId);
    }

    /// @notice Owner-only emergency resolve after the window has been missed.
    ///         Uses deterministic fallback: parity(keccak256(seed, marketId)).
    function emergencyResolve() public payable onlyOwner nonReentrant {
        uint256 mId = currentMarketId;
        if (mId == 0) revert NoActiveMarket();
        Epoch storage e = epochs[mId];
        if (e.resolved) revert AlreadyResolved();

        uint256 unlockBlk = uint256(e.targetBlock) + maxResolveLagBlocks + emergencyDelayBlocks;
        if (block.number <= unlockBlk) revert ResolveWindowExceeded();

        (uint256 y, uint256 n,, bool pmResolved,,,,) = PM.getMarket(mId);
        if (pmResolved) {
            e.resolved = true;
            _roll(mId);
            return;
        }

        if (y == 0 && n == 0) {
            e.resolved = true;
            emit DeadMarketSkipped(mId, _payTip());
            _roll(mId);
            return;
        }

        // Deterministic fallback independent of the caller
        bool outcome = (uint256(keccak256(abi.encodePacked(e.seed, mId))) & 1) == 1;

        // Keep a small safety auto-flip so winners exist if one side is empty (PM would refund anyway):
        if (outcome && y == 0 && n > 0) outcome = false;
        if (!outcome && n == 0 && y > 0) outcome = true;

        PM.resolve(mId, outcome);
        e.resolved = true;

        emit EmergencyResolved(mId, outcome, block.timestamp);
        _roll(mId);
    }

    /* ───────────────────────────── views ───────────────────────────── */

    function currentInfo()
        public
        view
        returns (uint256 marketId, uint72 closeAt, uint64 targetBlock, bool isResolved)
    {
        marketId = currentMarketId;
        if (marketId != 0) {
            Epoch storage e = epochs[marketId];
            closeAt = e.closeAt;
            targetBlock = e.targetBlock;
            isResolved = e.resolved;
        }
    }

    /// Helper for bots/keepers.
    function canResolveNow() public view returns (bool ready, bool shouldSkip) {
        uint256 mId = currentMarketId;
        if (mId == 0) return (false, false);
        Epoch storage e = epochs[mId];
        if (e.resolved) return (false, false);

        (uint256 y, uint256 n,, bool pmResolved,,,,) = PM.getMarket(mId);
        if (pmResolved) return (true, true); // already done in PM → roll
        if (y == 0 && n == 0) return (true, true); // allow pre-close dead skip

        if (block.timestamp < e.closeAt) return (false, false);
        if (block.number <= uint256(e.targetBlock) + 1) return (false, false); // need h1 and h2 available
        if (block.number > uint256(e.targetBlock) + maxResolveLagBlocks) return (false, false);
        if (blockhash(uint256(e.targetBlock)) == bytes32(0)) return (false, false);
        if (blockhash(uint256(e.targetBlock) + 1) == bytes32(0)) return (false, false);
        return (true, false);
    }

    /* ───────────────────────────── internals ───────────────────────────── */

    function _startNextMarket() internal returns (uint256 newMarketId) {
        // 1) Compute close time
        uint72 closeAt = uint72(block.timestamp + closeDelay);

        // 2) Convert the close delay (seconds) into blocks conservatively (ceil),
        //    then add a post-close buffer (resolveDelayBlocks).
        //    This guarantees targetBlock is strictly AFTER close.
        uint64 blocksUntilClose =
            uint64((closeDelay + (SECONDS_PER_BLOCK_BIAS - 1)) / SECONDS_PER_BLOCK_BIAS);
        uint64 targetBlock = uint64(block.number) + blocksUntilClose + uint64(resolveDelayBlocks);

        // 3) Capture a creation-time seed for emergency fallback
        uint256 seed = uint256(block.prevrandao);

        // 4) Human-friendly description. Since you now mix two blocks in resolve(),
        //    reflect that so UIs/keepers can sanity-check:
        string memory desc = string(
            abi.encodePacked(
                "Coinflip: YES if parity(keccak(blockhash(",
                _u2s(targetBlock),
                "), blockhash(",
                _u2s(uint256(targetBlock) + 1),
                "), marketId)) == 1. Trading closes at unix=",
                _u2s(closeAt),
                "."
            )
        );

        // 5) Create PM market
        (newMarketId,) = PM.createMarket(desc, address(this), closeAt, /*canClose=*/ false);

        // 6) Persist epoch state
        epochs[newMarketId] =
            Epoch({closeAt: closeAt, targetBlock: targetBlock, seed: seed, resolved: false});

        currentMarketId = newMarketId;
        emit MarketStarted(newMarketId, closeAt, targetBlock, desc);
    }

    function _roll(uint256 fromMarket) internal {
        uint256 nextId = _startNextMarket();
        emit Rolled(fromMarket, nextId);
    }

    function _payTip() internal returns (uint256 paid) {
        uint256 tip = tipPerResolve;
        if (tip == 0) return 0;
        paid = address(this).balance < tip ? address(this).balance : tip;
        if (paid == 0) return 0;
        (bool ok,) = msg.sender.call{value: paid}("");
        if (!ok) revert TipPaymentFailed();
    }

    /* ───────────────────────────── utils ───────────────────────────── */

    function _u2s(uint256 x) internal pure returns (string memory s) {
        if (x == 0) return "0";
        uint256 j = x;
        uint256 len;
        while (j != 0) {
            len++;
            j /= 10;
        }
        bytes memory b = new bytes(len);
        uint256 k = len;
        while (x != 0) {
            k--;
            b[k] = bytes1(uint8(48 + x % 10));
            x /= 10;
        }
        s = string(b);
    }
}
"
    }
  },
  "settings": {
    "remappings": [
      "@solady/=lib/solady/",
      "@soledge/=lib/soledge/",
      "@forge/=lib/forge-std/src/",
      "forge-std/=lib/forge-std/src/",
      "solady/=lib/solady/src/"
    ],
    "optimizer": {
      "enabled": true,
      "runs": 9999999
    },
    "metadata": {
      "useLiteralContent": false,
      "bytecodeHash": "ipfs",
      "appendCBOR": true
    },
    "outputSelection": {
      "*": {
        "*": [
          "evm.bytecode",
          "evm.deployedBytecode",
          "devdoc",
          "userdoc",
          "metadata",
          "abi"
        ]
      }
    },
    "evmVersion": "prague",
    "viaIR": true
  }
}}

Tags:
DeFi, Swap, Staking, Yield, Factory|addr:0x07e53dd08d9579e90928636068835d4eadc253a6|verified:true|block:23561863|tx:0xa8a106e0d943b9ddb3bc98f243f10eee40ea1c0706856570386b32dd2f2b6130|first_check:1760295090

Submitted on: 2025-10-12 20:51:30

Comments

Log in to comment.

No comments yet.