ClashEscrow

0xc1857ad0d01bec12421fb02c577fa5e1829d648a

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
5,218 bytes
ABI entries
52
16 read · 7 write
License
none

Contract information

Address
0xc1857ad0d01bec12421fb02c577fa5e1829d648a
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0x308636c8fc…64d63aa5d7

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (16)

BPS()uint16
BRONZE_WAGER()uint256
DEPOSIT_STALE()uint256
GOLD_WAGER()uint256
SILVER_WAGER()uint256
VAULT_FEE_BPS()uint16
deposits(uint256)address, uint8, uint256, uint256, uint64, bool
eip712Domain()bytes1, string, string, uint256, address, bytes32, uint256[]
nextDepositId()uint256
operator()address
owner()address
payoutFor(uint8)uint256, uint256
settledMatches(bytes32)bool
treasury()address
uru()address
wagerFor(uint8)uint256

Events (7)

DepositCreatedDepositRefundedEIP712DomainChangedMatchSettledOperatorChangedOwnershipTransferredTreasuryChanged

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "uru_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "operator_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "treasury_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "initialOwner",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "DepositAlreadySpent",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "DepositNotStale",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "DepositUnknown",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ECDSAInvalidSignature",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "length",
        "type": "uint256"
      }
    ],
    "name": "ECDSAInvalidSignatureLength",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "bytes32",
        "name": "s",
        "type": "bytes32"
      }
    ],
    "name": "ECDSAInvalidSignatureS",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidShortString",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidSignature",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidTier",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "LoserNotDepositor",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "MatchAlreadySettled",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "MismatchedWager",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "OwnableInvalidOwner",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "OwnableUnauthorizedAccount",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "PayoutMismatch",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "SignatureExpired",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "string",
        "name": "str",
        "type": "string"
      }
    ],
    "name": "StringTooLong",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "WinnerNotDepositor",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAddress",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "depositId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "player",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "enum ClashEscrow.Tier",
        "name": "tier",
        "type": "uint8"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "wager",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "DepositCreated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "depositId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "player",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "wager",
        "type": "uint256"
      }
    ],
    "name": "DepositRefunded",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [],
    "name": "EIP712DomainChanged",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "bytes32",
        "name": "matchId",
        "type": "bytes32"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "winner",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "payout",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "vaultCut",
        "type": "uint256"
      }
    ],
    "name": "MatchSettled",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "operator",
        "type": "address"
      }
    ],
    "name": "OperatorChanged",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "treasury",
        "type": "address"
      }
    ],
    "name": "TreasuryChanged",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "BRONZE_WAGER",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "DEPOSIT_STALE",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "GOLD_WAGER",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "SILVER_WAGER",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "VAULT_FEE_BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "enum ClashEscrow.Tier",
        "name": "tier",
        "type": "uint8"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "deposit",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "depositId",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "deposits",
    "outputs": [
      {
        "internalType": "address",
        "name": "player",
        "type": "address"
      },
      {
        "internalType": "enum ClashEscrow.Tier",
        "name": "tier",
        "type": "uint8"
      },
      {
        "internalType": "uint256",
        "name": "wager",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "uint64",
        "name": "createdAt",
        "type": "uint64"
      },
      {
        "internalType": "bool",
        "name": "spent",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "eip712Domain",
    "outputs": [
      {
        "internalType": "bytes1",
        "name": "fields",
        "type": "bytes1"
      },
      {
        "internalType": "string",
        "name": "name",
        "type": "string"
      },
      {
        "internalType": "string",
        "name": "version",
        "type": "string"
      },
      {
        "internalType": "uint256",
        "name": "chainId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "verifyingContract",
        "type": "address"
      },
      {
        "internalType": "bytes32",
        "name": "salt",
        "type": "bytes32"
      },
      {
        "internalType": "uint256[]",
        "name": "extensions",
        "type": "uint256[]"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "nextDepositId",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "operator",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "enum ClashEscrow.Tier",
        "name": "tier",
        "type": "uint8"
      }
    ],
    "name": "payoutFor",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "winnerPayout",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "vaultCut",
        "type": "uint256"
      }
    ],
    "stateMutability": "pure",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "depositId",
        "type": "uint256"
      }
    ],
    "name": "refund",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "renounceOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOperator",
        "type": "address"
      }
    ],
    "name": "setOperator",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newTreasury",
        "type": "address"
      }
    ],
    "name": "setTreasury",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "components": [
          {
            "internalType": "bytes32",
            "name": "matchId",
            "type": "bytes32"
          },
          {
            "internalType": "address",
            "name": "winner",
            "type": "address"
          },
          {
            "internalType": "address",
            "name": "loser",
            "type": "address"
          },
          {
            "internalType": "uint256",
            "name": "winnerDepositId",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "loserDepositId",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "winnerPayout",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "nonce",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "deadline",
            "type": "uint256"
          }
        ],
        "internalType": "struct ClashEscrow.MatchResult",
        "name": "r",
        "type": "tuple"
      },
      {
        "internalType": "bytes",
        "name": "sig",
        "type": "bytes"
      }
    ],
    "name": "settleMatch",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes32",
        "name": "",
        "type": "bytes32"
      }
    ],
    "name": "settledMatches",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "treasury",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "uru",
    "outputs": [
      {
        "internalType": "contract IERC20",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "enum ClashEscrow.Tier",
        "name": "tier",
        "type": "uint8"
      }
    ],
    "name": "wagerFor",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "pure",
    "type": "function"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

/// @title ClashEscrow
/// @notice URU escrow for Fleece & Fangs PvP wagers. Both players deposit
///         at a chosen tier; when the server certifies the winner (EIP-712
///         signed MatchResult from a pinned `operator` key), the winner
///         withdraws the pot net of a vault fee.
/// @dev    ── Vault fee routing (v1) ──
///         The vault fee is transferred to `treasury` — expected to be the
///         project Safe multisig at deploy. The Safe accumulates URU and
///         directs it into emissions manually (e.g. topping up a next
///         YieldReserve epoch, or a buy-and-burn cycle). This indirection
///         exists because `ChibiLiquidityVault.pushSubsidy` currently
///         restricts msg.sender to `yieldReserve`. If a future vault
///         version exposes an `authorizedPusher` role, ClashEscrow can
///         push directly by pointing `treasury` at a `SubsidyRouter`
///         helper — no changes required here.
contract ClashEscrow is Ownable, ReentrancyGuard, EIP712 {
    using SafeERC20 for IERC20;

    // ─── Tier config ────────────────────────────────────────────────
    enum Tier { BRONZE, SILVER, GOLD }
    uint256 public constant BRONZE_WAGER = 1_000 ether;
    uint256 public constant SILVER_WAGER = 5_000 ether;
    uint256 public constant GOLD_WAGER = 10_000 ether;

    /// @dev 500 bps = 5% of the 2× pot flows to the emissions vault.
    uint16 public constant VAULT_FEE_BPS = 500;
    uint16 public constant BPS = 10_000;

    /// @dev How long a deposit must sit before `refund()` can pull it back.
    ///      Set generously (4 h) because a live match's deposit is NOT
    ///      distinguished on-chain from an unmatched one: a shorter window
    ///      would let a losing player refund mid-match and strand the
    ///      winner's pot. v2 can add a signed `commit(depositIds, matchId)`
    ///      from the operator so matched deposits are frozen against refund
    ///      for the full match duration; until then, 4 h is the safety margin.
    uint256 public constant DEPOSIT_STALE = 4 hours;

    IERC20 public immutable uru;
    address public operator;
    address public treasury;

    struct DepositRecord {
        address player;
        Tier tier;
        uint256 wager;
        uint256 tokenId;
        uint64 createdAt;
        bool spent; // true after settleMatch or refund
    }
    mapping(uint256 => DepositRecord) public deposits;
    mapping(bytes32 => bool) public settledMatches;
    uint256 public nextDepositId = 1;

    // ─── EIP-712 ────────────────────────────────────────────────────
    bytes32 private constant MATCH_RESULT_TYPEHASH = keccak256(
        "MatchResult(bytes32 matchId,address winner,address loser,uint256 winnerDepositId,uint256 loserDepositId,uint256 winnerPayout,uint256 nonce,uint256 deadline)"
    );

    struct MatchResult {
        bytes32 matchId;
        address winner;
        address loser;
        uint256 winnerDepositId;
        uint256 loserDepositId;
        uint256 winnerPayout;
        uint256 nonce;
        uint256 deadline;
    }

    // ─── Events ─────────────────────────────────────────────────────
    event DepositCreated(uint256 indexed depositId, address indexed player, Tier tier, uint256 wager, uint256 tokenId);
    event MatchSettled(bytes32 indexed matchId, address indexed winner, uint256 payout, uint256 vaultCut);
    event DepositRefunded(uint256 indexed depositId, address indexed player, uint256 wager);
    event OperatorChanged(address indexed operator);
    event TreasuryChanged(address indexed treasury);

    // ─── Errors ─────────────────────────────────────────────────────
    error ZeroAddress();
    error InvalidTier();
    error DepositAlreadySpent();
    error DepositUnknown();
    error DepositNotStale();
    error MatchAlreadySettled();
    error InvalidSignature();
    error SignatureExpired();
    error MismatchedWager();
    error WinnerNotDepositor();
    error LoserNotDepositor();
    error PayoutMismatch();

    constructor(address uru_, address operator_, address treasury_, address initialOwner)
        Ownable(initialOwner)
        EIP712("ClashEscrow", "1")
    {
        if (uru_ == address(0) || operator_ == address(0) || treasury_ == address(0) || initialOwner == address(0)) {
            revert ZeroAddress();
        }
        uru = IERC20(uru_);
        operator = operator_;
        treasury = treasury_;
    }

    // ─── Admin ──────────────────────────────────────────────────────
    function setOperator(address newOperator) external onlyOwner {
        if (newOperator == address(0)) revert ZeroAddress();
        operator = newOperator;
        emit OperatorChanged(newOperator);
    }

    function setTreasury(address newTreasury) external onlyOwner {
        if (newTreasury == address(0)) revert ZeroAddress();
        treasury = newTreasury;
        emit TreasuryChanged(newTreasury);
    }

    // ─── Views ──────────────────────────────────────────────────────
    function wagerFor(Tier tier) public pure returns (uint256) {
        if (tier == Tier.BRONZE) return BRONZE_WAGER;
        if (tier == Tier.SILVER) return SILVER_WAGER;
        if (tier == Tier.GOLD) return GOLD_WAGER;
        revert InvalidTier();
    }

    function payoutFor(Tier tier) public pure returns (uint256 winnerPayout, uint256 vaultCut) {
        uint256 pot = wagerFor(tier) * 2;
        vaultCut = (pot * VAULT_FEE_BPS) / BPS;
        winnerPayout = pot - vaultCut;
    }

    // ─── Player actions ─────────────────────────────────────────────

    /// @notice Deposit URU at the given tier and receive a depositId to hand
    ///         to the matchmaker. Caller must approve URU beforehand.
    function deposit(Tier tier, uint256 tokenId) external nonReentrant returns (uint256 depositId) {
        uint256 wager = wagerFor(tier);
        depositId = nextDepositId++;
        deposits[depositId] = DepositRecord({
            player: msg.sender,
            tier: tier,
            wager: wager,
            tokenId: tokenId,
            createdAt: uint64(block.timestamp),
            spent: false
        });
        uru.safeTransferFrom(msg.sender, address(this), wager);
        emit DepositCreated(depositId, msg.sender, tier, wager, tokenId);
    }

    /// @notice Settle a completed match. The operator-signed MatchResult
    ///         authorizes the pot payout to the winner. `settledMatches`
    ///         ensures a signature can be replayed at most once.
    function settleMatch(MatchResult calldata r, bytes calldata sig) external nonReentrant {
        if (settledMatches[r.matchId]) revert MatchAlreadySettled();
        if (block.timestamp > r.deadline) revert SignatureExpired();

        bytes32 structHash = keccak256(
            abi.encode(
                MATCH_RESULT_TYPEHASH,
                r.matchId,
                r.winner,
                r.loser,
                r.winnerDepositId,
                r.loserDepositId,
                r.winnerPayout,
                r.nonce,
                r.deadline
            )
        );
        bytes32 digest = _hashTypedDataV4(structHash);
        if (ECDSA.recover(digest, sig) != operator) revert InvalidSignature();

        DepositRecord storage w = deposits[r.winnerDepositId];
        DepositRecord storage l = deposits[r.loserDepositId];
        if (w.player == address(0) || l.player == address(0)) revert DepositUnknown();
        if (w.spent || l.spent) revert DepositAlreadySpent();
        if (w.player != r.winner) revert WinnerNotDepositor();
        if (l.player != r.loser) revert LoserNotDepositor();
        if (w.wager != l.wager) revert MismatchedWager();

        uint256 pot = w.wager * 2;
        uint256 fee = (pot * VAULT_FEE_BPS) / BPS;
        uint256 expected = pot - fee;
        if (r.winnerPayout != expected) revert PayoutMismatch();

        w.spent = true;
        l.spent = true;
        settledMatches[r.matchId] = true;

        // Vault emissions fee → treasury (Safe). Post-launch, retarget to a
        // SubsidyRouter contract that can push into the live vault.
        if (fee > 0) uru.safeTransfer(treasury, fee);
        uru.safeTransfer(r.winner, expected);

        emit MatchSettled(r.matchId, r.winner, expected, fee);
    }

    /// @notice Refund an unmatched deposit after DEPOSIT_STALE. Permissionless
    ///         — URU always returns to the depositor.
    function refund(uint256 depositId) external nonReentrant {
        DepositRecord storage d = deposits[depositId];
        if (d.player == address(0)) revert DepositUnknown();
        if (d.spent) revert DepositAlreadySpent();
        if (block.timestamp < d.createdAt + DEPOSIT_STALE) revert DepositNotStale();
        d.spent = true;
        uru.safeTransfer(d.player, d.wager);
        emit DepositRefunded(depositId, d.player, d.wager);
    }
}
Chain explorer4874msChain node85ms