SimpleWager

Description:

Governance contract for decentralized decision-making.

Blockchain: Ethereum

Source Code: View Code On The Blockchain

Solidity Source Code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

/// @notice Minimal ERC20 interface
interface IERC20 {
    function transfer(address to, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

/// @title Simple claimable two-party wager with asymmetric odds and third-party judge
/// @notice Anyone can claim PartyA and PartyB. One side can go first; the other must match
///         according to a fixed multiplier (e.g., 10:1). Funds are escrowed until the judge resolves
///         the wager (A wins, B wins, split) or the bet cancels. Unilateral “bow out” is allowed
///         before the counterparty joins; otherwise cancellation requires mutual consent or a timeout.
contract SimpleWager {
    // ====== Events ======
    event ClaimedPartyA(address indexed who, uint256 deposit);
    event ClaimedPartyB(address indexed who, uint256 deposit);
    event Deposited(address indexed who, uint256 amount);
    event Resolved(Outcome outcome, address winner, uint256 paidA, uint256 paidB);
    event Refunded(address indexed to, uint256 amount);
    event Cancelled();
    event JudgeChanged(address indexed oldJudge, address indexed newJudge);

    // ====== Types ======
    enum Outcome { A_Wins, B_Wins, Split }
    enum AssetType { ETH, ERC20 }

    // ====== Storage ======
    address public judge;                 // third-party arbitrator
    address public partyA;                // bigger side (deposits multiplier * baseStake)
    address public partyB;                // smaller side (deposits baseStake)

    // Human-readable claim statements (optional convenience)
    string public claimA;                 // e.g., "Claim #1 and #2 are false"
    string public claimB;                 // e.g., "Claim #1 and/or #2 are true"

    // Odds: partyA must deposit multiplier * baseStake
    uint256 public immutable multiplier;  // e.g., 10 for 10:1

    // Escrowed amounts (actual funds held)
    uint256 public stakeA;                // escrowed from partyA
    uint256 public stakeB;                // escrowed from partyB

    // Stake coordination
    uint256 public baseStake;             // agreed base stake once both joined
    uint256 public proposedBaseStake;     // A's proposed base if A goes first (no B funds yet)

    // Asset configuration
    AssetType public immutable assetType;
    address public immutable asset;       // token address for ERC20; ignored for ETH (set to address(0))

    // Timers
    uint256 public immutable joinDeadline; // last timestamp the second party can join (0 = no deadline)
    uint256 public immutable resolveWindow; // seconds after full funding before anyone can auto-split
    uint256 public fundedAt;               // timestamp when both sides fully funded

    // State flags
    bool public resolved;
    bool private _locked; // reentrancy guard

    // ====== Modifiers ======
    modifier onlyJudge() { require(msg.sender == judge, "not judge"); _; }
    modifier nonReentrant() {
        require(!_locked, "reentrancy");
        _locked = true;
        _;
        _locked = false;
    }

    // ====== Constructor ======
    /// @param _asset address(0) for ETH; ERC20 token address otherwise
    /// @param _isERC20 true if using ERC20, false for ETH
    /// @param _multiplier partyA must deposit multiplier * stakeB (e.g., 10)
    /// @param _judge arbitrator address
    /// @param _joinDeadline UNIX timestamp after which unfilled wagers can be cancelled (0 = none)
    /// @param _resolveWindow Seconds after funding where anyone can auto-split if judge is idle (0 = disabled)
    /// @param _claimA Optional text for A's position
    /// @param _claimB Optional text for B's position
    constructor(
        address _asset,
        bool _isERC20,
        uint256 _multiplier,
        address _judge,
        uint256 _joinDeadline,
        uint256 _resolveWindow,
        string memory _claimA,
        string memory _claimB
    ) {
        require(_multiplier > 0, "multiplier=0");
        require(_judge != address(0), "judge=0");
        if (_isERC20) {
            require(_asset != address(0), "token=0");
            assetType = AssetType.ERC20;
        } else {
            require(_asset == address(0), "asset must be 0 for ETH");
            assetType = AssetType.ETH;
        }
        asset = _asset;
        judge = _judge;
        multiplier = _multiplier;
        joinDeadline = _joinDeadline;
        resolveWindow = _resolveWindow; // can be 0 to disable auto-split
        claimA = _claimA;
        claimB = _claimB;
    }

    // ====== Role claiming & deposit ======
    /// @notice Claim the small side (PartyB) and deposit the base stake.
    ///         For ETH, send value with the call. For ERC20, approve first then pass amount.
    function claimPartyB(uint256 amount) external payable nonReentrant {
        require(!resolved, "resolved");
        require(partyB == address(0), "B taken");
        require(amount > 0, "amount=0");

        if (partyA != address(0) && proposedBaseStake > 0) {
            // B is the second joiner → must be before deadline and not the same address as A
            require(joinDeadline == 0 || block.timestamp <= joinDeadline, "deadline");
            require(msg.sender != partyA, "same addr");
            require(amount == proposedBaseStake, "must match proposal");
            _takeFunds(msg.sender, amount);
            partyB = msg.sender;
            stakeB = amount;
            baseStake = amount;
            proposedBaseStake = 0;
            emit ClaimedPartyB(msg.sender, amount);
            emit Deposited(msg.sender, amount);
            _maybeMarkFunded();
        } else {
            // B goes first: establish baseStake
            _takeFunds(msg.sender, amount);
            partyB = msg.sender;
            stakeB = amount;
            baseStake = amount;
            emit ClaimedPartyB(msg.sender, amount);
            emit Deposited(msg.sender, amount);
        }
    }

    /// @notice Claim the big side (PartyA). Deposit must equal multiplier * current stakeB
    ///         If PartyB hasn't joined yet, you may propose a base stake via `proposedBaseStake`.
    function claimPartyA(uint256 _proposedBaseStake) external payable nonReentrant {
        require(!resolved, "resolved");
        require(partyA == address(0), "A taken");

        if (partyB == address(0)) {
            // A goes first
            require(_proposedBaseStake > 0, "proposed base=0");
            uint256 required = _proposedBaseStake * multiplier;
            _takeFunds(msg.sender, required);
            partyA = msg.sender;
            stakeA = required;
            proposedBaseStake = _proposedBaseStake;
            emit ClaimedPartyA(msg.sender, required);
            emit Deposited(msg.sender, required);
        } else {
            // A is the second joiner → must be before deadline and not the same address as B
            require(joinDeadline == 0 || block.timestamp <= joinDeadline, "deadline");
            require(msg.sender != partyB, "same addr");
            require(baseStake > 0, "no base");
            uint256 required = baseStake * multiplier;
            _takeFunds(msg.sender, required);
            partyA = msg.sender;
            stakeA = required;
            emit ClaimedPartyA(msg.sender, required);
            emit Deposited(msg.sender, required);
            _maybeMarkFunded();
        }
    }

    /// @notice Unilateral bow-out BEFORE a counterparty joins. Your funds are fully refunded.
    function bowOutSolo() external nonReentrant {
        require(!resolved, "resolved");
        require(joinDeadline == 0 || block.timestamp <= joinDeadline, "past deadline");
        if (msg.sender == partyA) {
            require(partyB == address(0), "B already joined");
            _payout(partyA, stakeA);
            emit Refunded(partyA, stakeA);
            stakeA = 0; partyA = address(0); proposedBaseStake = 0;
        } else if (msg.sender == partyB) {
            require(partyA == address(0), "A already joined");
            _payout(partyB, stakeB);
            emit Refunded(partyB, stakeB);
            stakeB = 0; partyB = address(0); baseStake = 0;
        } else {
            revert("not a party");
        }
    }

    /// @notice Mutual cancel before funding completion: both parties must call once.
    bool private aCancel;
    bool private bCancel;
    function requestCancel() external nonReentrant {
        require(!resolved, "resolved");
        require(!fullyFunded(), "funded");
        if (msg.sender == partyA) aCancel = true;
        else if (msg.sender == partyB) bCancel = true;
        else revert("not a party");
        if (aCancel && bCancel) {
            _refundAll();
            resolved = true;
            emit Cancelled();
        }
    }

    /// @notice Cancel & refund if second party didn't join in time.
    function cancelIfUnfilled() external nonReentrant {
        require(!resolved, "resolved");
        require(joinDeadline != 0 && block.timestamp > joinDeadline, "no timeout");
        require(!_bothFunded(), "funded");
        _refundAll();
        resolved = true;
        emit Cancelled();
    }

    // ====== Resolution ======
    function resolve(Outcome outcome) external onlyJudge nonReentrant {
        require(!resolved, "resolved");
        require(_bothFunded(), "not funded");
        resolved = true;
        uint256 pot = stakeA + stakeB;
        if (outcome == Outcome.A_Wins) {
            _payout(partyA, pot);
            emit Resolved(outcome, partyA, pot, 0);
        } else if (outcome == Outcome.B_Wins) {
            _payout(partyB, pot);
            emit Resolved(outcome, partyB, 0, pot);
        } else {
            uint256 half = pot / 2;
            _payout(partyA, half);
            _payout(partyB, pot - half);
            emit Resolved(outcome, address(0), half, pot - half);
        }
        _zeroOut();
    }

    /// @notice Either party may concede once fully funded; pot goes to the opponent.
    function concede() external nonReentrant {
        require(!resolved, "resolved");
        require(_bothFunded(), "not funded");
        resolved = true;
        uint256 pot = stakeA + stakeB;
        if (msg.sender == partyA) {
            _payout(partyB, pot);
            emit Resolved(Outcome.B_Wins, partyB, 0, pot);
        } else if (msg.sender == partyB) {
            _payout(partyA, pot);
            emit Resolved(Outcome.A_Wins, partyA, pot, 0);
        } else {
            revert("not a party");
        }
        _zeroOut();
    }

    /// @notice Anyone can auto-split if judge is unresponsive past resolveWindow.
    function autoSplitIfExpired() external nonReentrant {
        require(!resolved, "resolved");
        require(resolveWindow > 0, "disabled");
        require(_bothFunded(), "not funded");
        require(fundedAt != 0 && block.timestamp >= fundedAt + resolveWindow, "not expired");
        resolved = true;
        uint256 pot = stakeA + stakeB;
        uint256 half = pot / 2;
        _payout(partyA, half);
        _payout(partyB, pot - half);
        emit Resolved(Outcome.Split, address(0), half, pot - half);
        _zeroOut();
    }

    // ====== Admin ======
    function changeJudge(address newJudge) external onlyJudge {
        require(newJudge != address(0), "judge=0");
        emit JudgeChanged(judge, newJudge);
        judge = newJudge;
    }

    // ====== Views ======
    function fullyFunded() public view returns (bool) {
        return _bothFunded();
    }

    function potSize() external view returns (uint256) {
        return stakeA + stakeB;
    }

    function _bothFunded() internal view returns (bool) {
        return baseStake > 0 && stakeA == baseStake * multiplier && stakeB == baseStake;
    }

    function _maybeMarkFunded() internal {
        if (fundedAt == 0 && _bothFunded()) {
            fundedAt = block.timestamp;
        }
    }

    // ====== Internal money movement ======
    function _takeFunds(address from, uint256 amount) internal {
        if (assetType == AssetType.ETH) {
            require(msg.value == amount, "bad msg.value");
        } else {
            require(msg.value == 0, "no ETH");
            _safeTransferFromWithCheck(asset, from, address(this), amount);
        }
    }

    function _payout(address to, uint256 amount) internal {
        if (amount == 0) return;
        if (assetType == AssetType.ETH) {
            (bool ok, ) = to.call{ value: amount }("");
            require(ok, "ETH payout fail");
        } else {
            _safeTransferWithCheck(asset, to, amount);
        }
    }

    function _refundAll() internal {
        if (stakeA > 0 && partyA != address(0)) {
            _payout(partyA, stakeA);
            emit Refunded(partyA, stakeA);
            stakeA = 0; partyA = address(0);
            proposedBaseStake = 0;
        }
        if (stakeB > 0 && partyB != address(0)) {
            _payout(partyB, stakeB);
            emit Refunded(partyB, stakeB);
            stakeB = 0; partyB = address(0);
            baseStake = 0;
        }
        fundedAt = 0;
    }

    function _zeroOut() internal {
        stakeA = 0; stakeB = 0; baseStake = 0; proposedBaseStake = 0; fundedAt = 0;
    }

    // ====== Low-level ERC20 helpers (supports non-standard tokens that don't return bool)
    // Added balance-delta checks to protect against fee-on-transfer / rebasing tokens
    function _safeTransferWithCheck(address token, address to, uint256 value) internal {
        uint256 beforeBal = _balanceOf(token, to);
        (bool ok, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, value));
        require(ok && (data.length == 0 || abi.decode(data, (bool))), "ERC20 transfer fail");
        uint256 afterBal = _balanceOf(token, to);
        require(afterBal - beforeBal == value, "ERC20 fee-on-transfer not supported");
    }

    function _safeTransferFromWithCheck(address token, address from, address to, uint256 value) internal {
        uint256 beforeBal = _balanceOf(token, to);
        (bool ok, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value));
        require(ok && (data.length == 0 || abi.decode(data, (bool))), "ERC20 transferFrom fail");
        uint256 afterBal = _balanceOf(token, to);
        require(afterBal - beforeBal == value, "ERC20 fee-on-transfer not supported");
    }

    function _balanceOf(address token, address who) private view returns (uint256 bal) {
        // bytes4(keccak256("balanceOf(address)")) == 0x70a08231
        (bool ok, bytes memory data) = token.staticcall(abi.encodeWithSelector(0x70a08231, who));
        require(ok && data.length >= 32, "balanceOf fail");
        bal = abi.decode(data, (uint256));
    }

    // ====== Receive ETH ======
    receive() external payable { revert("direct ETH not allowed"); }
}

Tags:
Governance, Voting|addr:0xf2a7a975099048e521b1e58d07372fbd1196ca4e|verified:true|block:23405287|tx:0x07424eaefaa4bf80cfe2e5528156556ae9bac62c6505f4a60c7359b705288032|first_check:1758383719

Submitted on: 2025-09-20 17:55:21

Comments

Log in to comment.

No comments yet.