Arena

Description:

Multi-signature wallet contract requiring multiple confirmations for transaction execution.

Blockchain: Ethereum

Source Code: View Code On The Blockchain

Solidity Source Code:

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

/// @title Arena
/// @notice Commit-reveal RPS wagering with authorized matching
contract Arena {
    enum WeaponType {
        Rock,
        Paper,
        Scissors
    }

    struct Entry {
        uint96 wager;
        bytes32 commitHash;
        uint64 commitBlock;
        address opponent;
        uint64 matchBlock;
        bool revealed; // AW-C-01: Track reveal status
    }

    mapping(address => Entry) public queue;
    mapping(address => uint256) public credits;
    mapping(address => uint256) public lastCreditedAt; // AW-L-01: Track credit timestamps

    address public owner;
    address public matcher;
    address public feeProtocol;
    address public feeSeasonCurrent;
    address public feeSeasonNext;

    uint64 public constant REVEAL_DEADLINE_BLOCKS = 1_800;
    uint256 public constant CREDIT_RECOVERY_DELAY = 365 days; // AW-L-01: Stale credit recovery delay
    uint256 private locked = 1;

    event Entered(address indexed player, uint96 wager, bytes32 commitHash);
    event Left(address indexed player);
    event Matched(address indexed a, address indexed b, uint64 matchBlock);
    event Resolved(address indexed a, address indexed b, address winner, bool gunA, bool gunB, bool tiebreak);
    event Forfeited(address indexed winner, address indexed loser);
    event Credited(address indexed to, uint256 amount);
    event CreditsRecovered(address indexed from, address indexed to, uint256 amount); // AW-L-01: Credit recovery event
    event IndividualReveal(address indexed player, address indexed opponent);
    event FeeRecipientsSet(address protocol, address seasonCurrent, address seasonNext);
    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
    event MatcherSet(address indexed previousMatcher, address indexed newMatcher);

    error ActiveEntry();
    error InactiveEntry();
    error ValueMismatch();
    error TierInvalid();
    error OpponentRequired();
    error NotAfterCommit();
    error PastDeadline();
    error BadCommit();
    error NotMatched();
    error WagerMismatch();
    error NoCredits();
    error TransferFailed();
    error Unauthorized();
    error InvalidAddress();
    error ReentrancyGuard();
    error InvalidWeapon();
    error AlreadyMatched();
    error NotPastDeadline();
    error LoserAlreadyRevealed(); // AW-C-01: Prevent forfeit of revealed player
    error RecoveryTooEarly(); // AW-L-01: Recovery delay not met

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

    modifier onlyMatcher() {
        if (msg.sender != matcher) revert Unauthorized();
        _;
    }

    modifier nonReentrant() {
        if (locked != 1) revert ReentrancyGuard();
        locked = 2;
        _;
        locked = 1;
    }

    constructor() {
        owner = msg.sender;
        matcher = msg.sender;
        feeProtocol = msg.sender;
        feeSeasonCurrent = msg.sender;
        feeSeasonNext = msg.sender;
        emit OwnershipTransferred(address(0), msg.sender);
    }

    function enterQueue(uint8 tier, bytes32 commitHash) external payable nonReentrant {
        if (tier > 3) revert TierInvalid();
        if (queue[msg.sender].wager != 0) revert ActiveEntry();

        uint96 requiredWager = wagerForTier(tier);
        if (msg.value != requiredWager) revert ValueMismatch();

        queue[msg.sender] = Entry({
            wager: requiredWager,
            commitHash: commitHash,
            commitBlock: uint64(block.number),
            opponent: address(0),
            matchBlock: 0,
            revealed: false
        });

        emit Entered(msg.sender, requiredWager, commitHash);
    }

    function leaveQueue() external nonReentrant {
        Entry storage entry = queue[msg.sender];
        if (entry.wager == 0) revert InactiveEntry();
        if (entry.opponent != address(0)) revert AlreadyMatched();

        uint96 refundAmount = entry.wager;
        delete queue[msg.sender];

        _safeSend(msg.sender, refundAmount);
        emit Left(msg.sender);
    }

    /// @notice Allow individual reveal to protect against griefing
    /// @dev AW-C-01: Players can independently reveal their commits onchain
    /// @param weapons The player's weapon choices
    /// @param salt The player's salt used in commitment
    function revealIndividual(WeaponType[3] calldata weapons, bytes32 salt) external nonReentrant {
        Entry storage entry = queue[msg.sender];
        if (entry.opponent == address(0)) revert NotMatched();
        if (entry.wager == 0) revert InactiveEntry();

        uint64 matchBlock = entry.matchBlock;
        if (block.number <= matchBlock + 1) revert NotAfterCommit();
        if (block.number > matchBlock + REVEAL_DEADLINE_BLOCKS) revert PastDeadline();

        // Validate the player's commitment
        _validateWeapons(weapons);
        _validateCommit(msg.sender, weapons, salt, entry.commitHash);

        // Mark as revealed
        entry.revealed = true;
        emit IndividualReveal(msg.sender, entry.opponent);
    }

    function matchPlayers(address a, address b) external onlyMatcher nonReentrant {
        if (a == b) revert OpponentRequired();

        Entry storage entryA = queue[a];
        Entry storage entryB = queue[b];

        if (entryA.wager == 0 || entryB.wager == 0) revert InactiveEntry();
        if (entryA.wager != entryB.wager) revert WagerMismatch();
        if (entryA.opponent != address(0) || entryB.opponent != address(0)) revert AlreadyMatched();

        uint64 currentBlock = uint64(block.number);
        entryA.opponent = b;
        entryA.matchBlock = currentBlock;
        entryB.opponent = a;
        entryB.matchBlock = currentBlock;

        emit Matched(a, b, currentBlock);
    }

    function revealMatchResult(
        address a,
        address b,
        WeaponType[3] calldata aW,
        bytes32 aSalt,
        WeaponType[3] calldata bW,
        bytes32 bSalt
    ) external nonReentrant {
        if (a == b) revert OpponentRequired();

        Entry storage entryA = queue[a];
        Entry storage entryB = queue[b];

        if (entryA.opponent != b || entryB.opponent != a) revert NotMatched();
        if (entryA.wager != entryB.wager) revert WagerMismatch();

        uint64 matchBlock = entryA.matchBlock;
        if (block.number <= matchBlock + 1) revert NotAfterCommit();
        if (block.number > matchBlock + REVEAL_DEADLINE_BLOCKS) revert PastDeadline();

        _validateWeapons(aW);
        _validateWeapons(bW);
        _validateCommit(a, aW, aSalt, entryA.commitHash);
        _validateCommit(b, bW, bSalt, entryB.commitHash);

        // AW-C-01: Mark both players as revealed
        entryA.revealed = true;
        entryB.revealed = true;

        bytes32 seedBlock = blockhash(matchBlock + 1);
        if (seedBlock == 0 && block.number > matchBlock + 257) {
            seedBlock = keccak256(abi.encodePacked(matchBlock));
        }

        address lo = a < b ? a : b;
        address hi = a < b ? b : a;
        bytes32 seed = keccak256(
            abi.encodePacked(
                lo,
                hi,
                lo == a ? entryA.commitHash : entryB.commitHash,
                lo == a ? entryB.commitHash : entryA.commitHash,
                lo == a ? aSalt : bSalt,
                lo == a ? bSalt : aSalt,
                seedBlock
            )
        );

        address winner = _determineWinner(a, b, aW, bW, seed);
        uint256 pot = uint256(entryA.wager) + uint256(entryB.wager);

        delete queue[a];
        delete queue[b];

        _distributePayout(winner, pot);

        bool gunA = uint256(keccak256(abi.encodePacked(seed, "A"))) % 100 == 0;
        bool gunB = uint256(keccak256(abi.encodePacked(seed, "B"))) % 100 == 0;
        bool tiebreak = !gunA && !gunB && _scoreBestOfThree(aW, bW) == 0;

        emit Resolved(a, b, winner, gunA, gunB, tiebreak);
    }

    function forfeitUnrevealed(address winner, address loser, WeaponType[3] calldata winnerWeapons, bytes32 winnerSalt)
        external
        nonReentrant
    {
        if (winner == loser) revert OpponentRequired();

        Entry storage entryW = queue[winner];
        Entry storage entryL = queue[loser];

        if (entryW.opponent != loser || entryL.opponent != winner) revert NotMatched();
        if (entryW.wager != entryL.wager) revert WagerMismatch();

        uint64 matchBlock = entryW.matchBlock;
        if (block.number <= matchBlock + REVEAL_DEADLINE_BLOCKS) revert NotPastDeadline();

        // AW-C-01: Prevent forfeit if loser has already revealed
        if (entryL.revealed) revert LoserAlreadyRevealed();

        _validateWeapons(winnerWeapons);
        _validateCommit(winner, winnerWeapons, winnerSalt, entryW.commitHash);

        uint256 pot = uint256(entryW.wager) + uint256(entryL.wager);

        delete queue[winner];
        delete queue[loser];

        _distributePayout(winner, pot);
        emit Forfeited(winner, loser);
    }

    function claim() external nonReentrant {
        uint256 amount = credits[msg.sender];
        if (amount == 0) revert NoCredits();

        credits[msg.sender] = 0;

        (bool success,) = msg.sender.call{ value: amount }("");
        if (!success) revert TransferFailed();
    }

    function setFeeRecipients(address protocol, address seasonCurr, address seasonNext) external onlyOwner {
        if (protocol == address(0) || seasonCurr == address(0) || seasonNext == address(0)) {
            revert InvalidAddress();
        }
        if (protocol == address(this) || seasonCurr == address(this) || seasonNext == address(this)) {
            revert InvalidAddress();
        }
        feeProtocol = protocol;
        feeSeasonCurrent = seasonCurr;
        feeSeasonNext = seasonNext;
        emit FeeRecipientsSet(protocol, seasonCurr, seasonNext);
    }

    function setMatcher(address newMatcher) external onlyOwner {
        if (newMatcher == address(0)) revert InvalidAddress();
        address oldMatcher = matcher;
        matcher = newMatcher;
        emit MatcherSet(oldMatcher, newMatcher);
    }

    function transferOwnership(address newOwner) external onlyOwner {
        if (newOwner == address(0)) revert InvalidAddress();
        address oldOwner = owner;
        owner = newOwner;
        emit OwnershipTransferred(oldOwner, newOwner);
    }

    function wagerForTier(uint8 tier) public pure returns (uint96) {
        if (tier == 0) return 0.005 ether;
        if (tier == 1) return 0.05 ether;
        if (tier == 2) return 0.5 ether;
        if (tier == 3) return 2.5 ether;
        revert TierInvalid();
    }

    function _validateWeapons(WeaponType[3] calldata weapons) private pure {
        for (uint256 i = 0; i < 3; i++) {
            if (uint8(weapons[i]) > 2) revert InvalidWeapon();
        }
    }

    function _validateCommit(address player, WeaponType[3] calldata weapons, bytes32 salt, bytes32 expectedHash)
        private
        view
    {
        bytes32 actualHash = keccak256(
            abi.encodePacked(
                uint8(weapons[0]), uint8(weapons[1]), uint8(weapons[2]), salt, player, address(this), block.chainid
            )
        );
        if (actualHash != expectedHash) revert BadCommit();
    }

    function _determineWinner(address a, address b, WeaponType[3] calldata aW, WeaponType[3] calldata bW, bytes32 seed)
        private
        pure
        returns (address)
    {
        bool gunA = uint256(keccak256(abi.encodePacked(seed, "A"))) % 100 == 0;
        bool gunB = uint256(keccak256(abi.encodePacked(seed, "B"))) % 100 == 0;

        if (gunA && !gunB) return a;
        if (!gunA && gunB) return b;

        int8 score = _scoreBestOfThree(aW, bW);
        if (score > 0) return a;
        if (score < 0) return b;

        return (uint256(keccak256(abi.encodePacked(seed, "T"))) & 1) == 0 ? a : b;
    }

    function _scoreBestOfThree(WeaponType[3] calldata aW, WeaponType[3] calldata bW) private pure returns (int8) {
        int8 score = 0;
        for (uint256 i = 0; i < 3; i++) {
            score += _playRPS(aW[i], bW[i]);
        }
        return score;
    }

    function _playRPS(WeaponType a, WeaponType b) private pure returns (int8) {
        if (a == b) return 0;
        if (a == WeaponType.Rock && b == WeaponType.Scissors) return 1;
        if (a == WeaponType.Scissors && b == WeaponType.Paper) return 1;
        if (a == WeaponType.Paper && b == WeaponType.Rock) return 1;
        return -1;
    }

    function _distributePayout(address winner, uint256 pot) private {
        uint256 feeP = pot * 200 / 10_000;
        uint256 feeSC = pot * 100 / 10_000;
        uint256 feeSN = pot * 100 / 10_000;
        uint256 winnerAmount = pot - feeP - feeSC - feeSN;

        _safeSend(feeProtocol, feeP);
        _safeSend(feeSeasonCurrent, feeSC);
        _safeSend(feeSeasonNext, feeSN);
        _safeSend(winner, winnerAmount);
    }

    function _safeSend(address to, uint256 amount) private {
        if (amount == 0) return;

        (bool success,) = to.call{ value: amount, gas: 100000 }(""); // AW-L-02: Increased gas for smart wallets
        if (!success) {
            credits[to] += amount;
            lastCreditedAt[to] = block.timestamp; // AW-L-01: Track credit timestamp
            emit Credited(to, amount);
        }
    }

    /// @notice Recover stale credits that cannot be claimed
    /// @dev AW-L-01: Owner-only recovery after conservative delay
    /// @param from Address with unclaimed credits
    /// @param to Recovery recipient address
    function recoverStaleCredits(address from, address to) external onlyOwner nonReentrant {
        uint256 amount = credits[from];
        if (amount == 0) revert NoCredits();

        // Enforce recovery delay since last credit
        if (block.timestamp < lastCreditedAt[from] + CREDIT_RECOVERY_DELAY) {
            revert RecoveryTooEarly();
        }

        credits[from] = 0;
        emit CreditsRecovered(from, to, amount);

        (bool success,) = to.call{ value: amount }("");
        if (!success) revert TransferFailed();
    }

    receive() external payable { }
}
"
    }
  },
  "settings": {
    "remappings": [
      "forge-std/=lib/forge-std/src/",
      "@openzeppelin/=lib/openzeppelin-contracts/"
    ],
    "optimizer": {
      "enabled": true,
      "runs": 200
    },
    "metadata": {
      "useLiteralContent": false,
      "bytecodeHash": "ipfs",
      "appendCBOR": true
    },
    "outputSelection": {
      "*": {
        "*": [
          "evm.bytecode",
          "evm.deployedBytecode",
          "devdoc",
          "userdoc",
          "metadata",
          "abi"
        ]
      }
    },
    "evmVersion": "cancun",
    "viaIR": true
  }
}}

Tags:
Multisig, Multi-Signature, Factory|addr:0x5f5fdf6a9b2a8d004458c86624285681f9f9a79c|verified:true|block:23633835|tx:0x03d90b360612a1245a24889d56131249cf08972db38418045fbc93bf178d81da|first_check:1761291021

Submitted on: 2025-10-24 09:30:24

Comments

Log in to comment.

No comments yet.