// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; /** * @title MsgDuel v2.1 — audited * @notice 1v1 Web3 Battle Arena — commit-reveal combat with USDC stakes on Base * @author Observity (observity.xyz) * * Fixes vs v2.0: * [CRIT-1] Deadlock forfeit: added roundDeadline (set on acceptDuel) so either * fighter can trigger forfeit even if neither submits a commit. * [CRIT-2] activeDuelsByFighter DoS: replaced with duelCountByFighter counter; * full list via events, not on-chain array. * [CRIT-3] Tie double-push failure: switched to pull-payment (pendingWithdrawals) * so partial failure can't trap funds. * [MED-4] claimVictory mid-round: requires no active commit before claiming. * [MED-6] Ownership: 2-step transfer (propose + accept). * [LOW-8] submitMove: added nonReentrant. * [LOW-10] MAX_STAKE constant added. */ interface IERC20 { function transferFrom(address from, address to, uint256 amount) external returns (bool); function transfer(address to, uint256 amount) external returns (bool); function allowance(address owner, address spender) external view returns (uint256); function balanceOf(address account) external view returns (uint256); } abstract contract ReentrancyGuard { uint256 private constant _NOT_ENTERED = 1; uint256 private constant _ENTERED = 2; uint256 private _status; constructor() { _status = _NOT_ENTERED; } modifier nonReentrant() { require(_status != _ENTERED, "ReentrancyGuard: reentrant call"); _status = _ENTERED; _; _status = _NOT_ENTERED; } } contract MsgDuel is ReentrancyGuard { // ─── Constants ──────────────────────────────────────────────────────────── IERC20 public immutable USDC; uint256 public constant PROTOCOL_FEE_BPS = 250; // 2.5% uint256 public constant TOTAL_ROUNDS = 5; uint256 public constant WIN_THRESHOLD = 3; uint256 public constant REVEAL_TIMEOUT = 10 minutes; // per-commit reveal window uint256 public constant ROUND_TIMEOUT = 24 hours; // [FIX-1] full-round idle forfeit uint256 public constant ACCEPT_TIMEOUT = 72 hours; uint256 public constant MIN_STAKE = 100_000; // 0.1 USDC (6 dec) uint256 public constant MAX_STAKE = 10_000_000_000; // 10,000 USDC [FIX-10] // ─── Types ──────────────────────────────────────────────────────────────── enum DuelStatus { Pending, Active, Resolved, Cancelled } enum Move { None, Strike, Guard, Counter } struct RoundResult { Move challengerMove; Move opponentMove; address winner; } struct Duel { address challenger; address opponent; uint256 stakeAmount; DuelStatus status; uint8 currentRound; uint8 challengerWins; uint8 opponentWins; bytes32 challengerCommit; bytes32 opponentCommit; uint256 commitDeadline; // set when first commit lands uint256 roundDeadline; // [FIX-1] set when duel goes Active; resets each round uint256 createdAt; RoundResult[5] rounds; } struct FighterStats { uint256 wins; uint256 losses; uint256 ties; uint256 totalEarned; } // ─── State ──────────────────────────────────────────────────────────────── uint256 public duelCounter; mapping(uint256 => Duel) public duels; mapping(address => FighterStats) public stats; mapping(address => uint256) public duelCountByFighter; // [FIX-2] counter only // [FIX-3] Pull-payment: pending withdrawals instead of push-transfer mapping(address => uint256) public pendingWithdrawals; uint256 public protocolFees; uint256 public totalVolume; address public owner; address public pendingOwner; // [FIX-6] 2-step ownership bool public paused; // ─── Events ─────────────────────────────────────────────────────────────── event DuelCreated (uint256 indexed duelId, address indexed challenger, address indexed opponent, uint256 stakeAmount); event DuelAccepted (uint256 indexed duelId, address indexed opponent); event DuelCancelled (uint256 indexed duelId); event MoveSubmitted (uint256 indexed duelId, address indexed fighter, uint256 round); event MoveRevealed (uint256 indexed duelId, address indexed fighter, uint256 round, Move move); event RoundResolved (uint256 indexed duelId, uint256 round, address winner, Move challengerMove, Move opponentMove); event DuelResolved (uint256 indexed duelId, address indexed winner, uint256 payout); event ForfeitClaimed (uint256 indexed duelId, address indexed claimant, uint256 round); event WithdrawalCredited (address indexed fighter, uint256 amount); event Withdrawn (address indexed fighter, uint256 amount); event OwnershipProposed (address indexed proposed); event OwnershipTransferred(address indexed previous, address indexed next); // ─── Modifiers ──────────────────────────────────────────────────────────── modifier onlyOwner() { require(msg.sender == owner, "Not owner"); _; } modifier notPaused() { require(!paused, "Paused"); _; } modifier duelExists(uint256 id) { require(id < duelCounter, "No such duel"); _; } modifier onlyFighter(uint256 id) { require(msg.sender == duels[id].challenger || msg.sender == duels[id].opponent, "Not a fighter"); _; } // ─── Constructor ────────────────────────────────────────────────────────── constructor(address _usdc) { require(_usdc != address(0), "Zero address"); USDC = IERC20(_usdc); owner = msg.sender; } // ─── Core: Create / Accept / Cancel ─────────────────────────────────────── function createDuel(address opponent, uint256 stakeAmount) external nonReentrant notPaused returns (uint256 duelId) { require(opponent != address(0) && opponent != msg.sender, "Bad opponent"); require(stakeAmount >= MIN_STAKE && stakeAmount <= MAX_STAKE, "Stake out of range"); require(USDC.allowance(msg.sender, address(this)) >= stakeAmount, "Insufficient allowance"); require(USDC.transferFrom(msg.sender, address(this), stakeAmount), "Transfer failed"); duelId = duelCounter++; Duel storage d = duels[duelId]; d.challenger = msg.sender; d.opponent = opponent; d.stakeAmount = stakeAmount; d.status = DuelStatus.Pending; d.currentRound = 1; d.createdAt = block.timestamp; duelCountByFighter[msg.sender]++; // [FIX-2] duelCountByFighter[opponent]++; totalVolume += stakeAmount; emit DuelCreated(duelId, msg.sender, opponent, stakeAmount); } function acceptDuel(uint256 duelId) external nonReentrant notPaused duelExists(duelId) { Duel storage d = duels[duelId]; require(msg.sender == d.opponent, "Not the opponent"); require(d.status == DuelStatus.Pending, "Not pending"); require(block.timestamp <= d.createdAt + ACCEPT_TIMEOUT, "Challenge expired"); require(USDC.allowance(msg.sender, address(this)) >= d.stakeAmount, "Insufficient allowance"); require(USDC.transferFrom(msg.sender, address(this), d.stakeAmount), "Transfer failed"); d.status = DuelStatus.Active; d.roundDeadline = block.timestamp + ROUND_TIMEOUT; // [FIX-1] totalVolume += d.stakeAmount; emit DuelAccepted(duelId, msg.sender); } function cancelDuel(uint256 duelId) external nonReentrant duelExists(duelId) { Duel storage d = duels[duelId]; require(d.status == DuelStatus.Pending, "Not pending"); bool challengerCancels = (msg.sender == d.challenger); bool expired = block.timestamp > d.createdAt + ACCEPT_TIMEOUT; require(challengerCancels || expired, "Cannot cancel"); d.status = DuelStatus.Cancelled; require(USDC.transfer(d.challenger, d.stakeAmount), "Refund failed"); emit DuelCancelled(duelId); } // ─── Core: Commit / Reveal ──────────────────────────────────────────────── /** * @notice Commit your move hash for the current round. * @param moveHash keccak256(abi.encodePacked(uint8(move), bytes32(salt), uint256(duelId))) */ function submitMove(uint256 duelId, bytes32 moveHash) external nonReentrant notPaused duelExists(duelId) onlyFighter(duelId) // [FIX-8] { Duel storage d = duels[duelId]; require(d.status == DuelStatus.Active, "Not active"); require(d.currentRound <= TOTAL_ROUNDS, "All rounds done"); bool isChallenger = (msg.sender == d.challenger); if (isChallenger) { require(d.challengerCommit == bytes32(0), "Already committed"); d.challengerCommit = moveHash; } else { require(d.opponentCommit == bytes32(0), "Already committed"); d.opponentCommit = moveHash; } if (d.commitDeadline == 0) { d.commitDeadline = block.timestamp + REVEAL_TIMEOUT; } emit MoveSubmitted(duelId, msg.sender, d.currentRound); } /** * @notice Reveal your committed move. Round auto-resolves when both reveal. * @param move 1=Strike, 2=Guard, 3=Counter * @param salt Random salt used in commit hash */ function revealMove(uint256 duelId, uint8 move, bytes32 salt) external nonReentrant notPaused duelExists(duelId) onlyFighter(duelId) { Duel storage d = duels[duelId]; require(d.status == DuelStatus.Active, "Not active"); require(move >= 1 && move <= 3, "Invalid move"); bool isChallenger = (msg.sender == d.challenger); bytes32 commit = isChallenger ? d.challengerCommit : d.opponentCommit; require(commit != bytes32(0), "No commit this round"); bytes32 expected = keccak256(abi.encodePacked(move, salt, duelId)); require(expected == commit, "Hash mismatch"); uint256 round = d.currentRound; RoundResult storage rr = d.rounds[round - 1]; Move m = Move(move); if (isChallenger) { require(rr.challengerMove == Move.None, "Already revealed"); rr.challengerMove = m; } else { require(rr.opponentMove == Move.None, "Already revealed"); rr.opponentMove = m; } emit MoveRevealed(duelId, msg.sender, round, m); if (rr.challengerMove != Move.None && rr.opponentMove != Move.None) { _resolveRound(duelId, round); } } // ─── Core: Claim / Forfeit ──────────────────────────────────────────────── /** * @notice Claim victory once duel is over (first to WIN_THRESHOLD or all rounds done). * [FIX-4] Cannot claim while a round is still in progress. */ function claimVictory(uint256 duelId) external nonReentrant duelExists(duelId) onlyFighter(duelId) { Duel storage d = duels[duelId]; require(d.status == DuelStatus.Active, "Not active"); // [FIX-4] No active commits — round must be fully resolved require( d.challengerCommit == bytes32(0) && d.opponentCommit == bytes32(0), "Round in progress" ); bool majorityReached = d.challengerWins >= WIN_THRESHOLD || d.opponentWins >= WIN_THRESHOLD; bool allRoundsDone = d.currentRound > TOTAL_ROUNDS; require(majorityReached || allRoundsDone, "Duel not concluded"); if (d.challengerWins > d.opponentWins) { _payoutWinner(duelId, d.challenger); } else if (d.opponentWins > d.challengerWins) { _payoutWinner(duelId, d.opponent); } else { _resolveTie(duelId); } } /** * @notice Claim forfeit if: * (a) opponent committed but didn't reveal within REVEAL_TIMEOUT, OR * (b) [FIX-1] neither fighter committed within ROUND_TIMEOUT */ function claimForfeit(uint256 duelId) external nonReentrant duelExists(duelId) onlyFighter(duelId) { Duel storage d = duels[duelId]; require(d.status == DuelStatus.Active, "Not active"); bool isChallenger = (msg.sender == d.challenger); RoundResult storage rr = d.rounds[d.currentRound - 1]; // Case A: commit-reveal timeout (one committed, other didn't reveal) if (d.commitDeadline > 0 && block.timestamp > d.commitDeadline) { if (isChallenger) { require(rr.challengerMove != Move.None, "You haven't revealed"); require(rr.opponentMove == Move.None, "Opponent already revealed"); } else { require(rr.opponentMove != Move.None, "You haven't revealed"); require(rr.challengerMove == Move.None, "Challenger already revealed"); } emit ForfeitClaimed(duelId, msg.sender, d.currentRound); _payoutWinner(duelId, msg.sender); return; } // [FIX-1] Case B: neither fighter committed within ROUND_TIMEOUT require(block.timestamp > d.roundDeadline, "Round timeout not reached"); require(d.challengerCommit == bytes32(0) && d.opponentCommit == bytes32(0), "Commits present"); emit ForfeitClaimed(duelId, msg.sender, d.currentRound); _payoutWinner(duelId, msg.sender); } // ─── [FIX-3] Pull-payment withdrawal ───────────────────────────────────── /** * @notice Withdraw any pending USDC credited to your address. * Used for tie refunds (and optionally for payouts). */ function withdraw() external nonReentrant { uint256 amount = pendingWithdrawals[msg.sender]; require(amount > 0, "Nothing to withdraw"); pendingWithdrawals[msg.sender] = 0; require(USDC.transfer(msg.sender, amount), "Transfer failed"); emit Withdrawn(msg.sender, amount); } // ─── Internal ───────────────────────────────────────────────────────────── function _resolveRound(uint256 duelId, uint256 round) internal { Duel storage d = duels[duelId]; RoundResult storage rr = d.rounds[round - 1]; Move c = rr.challengerMove; Move o = rr.opponentMove; address roundWinner; if (c == o) { roundWinner = address(0); } else if ( (c == Move.Strike && o == Move.Guard) || (c == Move.Guard && o == Move.Counter) || (c == Move.Counter && o == Move.Strike) ) { roundWinner = d.challenger; d.challengerWins++; } else { roundWinner = d.opponent; d.opponentWins++; } rr.winner = roundWinner; emit RoundResolved(duelId, round, roundWinner, c, o); d.challengerCommit = bytes32(0); d.opponentCommit = bytes32(0); d.commitDeadline = 0; d.roundDeadline = block.timestamp + ROUND_TIMEOUT; // [FIX-1] reset for next round d.currentRound++; } function _payoutWinner(uint256 duelId, address winner) internal { Duel storage d = duels[duelId]; d.status = DuelStatus.Resolved; uint256 pot = d.stakeAmount * 2; uint256 fee = (pot * PROTOCOL_FEE_BPS) / 10_000; uint256 payout = pot - fee; protocolFees += fee; address loser = (winner == d.challenger) ? d.opponent : d.challenger; stats[winner].wins++; stats[winner].totalEarned += payout; stats[loser].losses++; require(USDC.transfer(winner, payout), "Payout failed"); emit DuelResolved(duelId, winner, payout); } // [FIX-3] Tie uses pull-payment to avoid partial-failure trap function _resolveTie(uint256 duelId) internal { Duel storage d = duels[duelId]; d.status = DuelStatus.Resolved; stats[d.challenger].ties++; stats[d.opponent].ties++; pendingWithdrawals[d.challenger] += d.stakeAmount; pendingWithdrawals[d.opponent] += d.stakeAmount; emit WithdrawalCredited(d.challenger, d.stakeAmount); emit WithdrawalCredited(d.opponent, d.stakeAmount); emit DuelResolved(duelId, address(0), 0); } // ─── Views ──────────────────────────────────────────────────────────────── function getDuel(uint256 duelId) external view duelExists(duelId) returns ( address challenger, address opponent, uint256 stakeAmount, DuelStatus status, uint8 currentRound, uint8 challengerWins, uint8 opponentWins, uint256 createdAt ) { Duel storage d = duels[duelId]; return (d.challenger, d.opponent, d.stakeAmount, d.status, d.currentRound, d.challengerWins, d.opponentWins, d.createdAt); } function getRound(uint256 duelId, uint256 round) external view duelExists(duelId) returns (Move challengerMove, Move opponentMove, address winner) { require(round >= 1 && round <= TOTAL_ROUNDS, "Invalid round"); RoundResult storage rr = duels[duelId].rounds[round - 1]; return (rr.challengerMove, rr.opponentMove, rr.winner); } function hasCommitted(uint256 duelId, address fighter) external view duelExists(duelId) returns (bool) { Duel storage d = duels[duelId]; if (fighter == d.challenger) return d.challengerCommit != bytes32(0); if (fighter == d.opponent) return d.opponentCommit != bytes32(0); return false; } function getStats(address fighter) external view returns (uint256 wins, uint256 losses, uint256 ties, uint256 totalEarned) { FighterStats storage s = stats[fighter]; return (s.wins, s.losses, s.ties, s.totalEarned); } function totalDuels() external view returns (uint256) { return duelCounter; } // ─── Admin ──────────────────────────────────────────────────────────────── function withdrawFees(address to) external onlyOwner nonReentrant { require(to != address(0), "Zero address"); uint256 amount = protocolFees; require(amount > 0, "No fees"); protocolFees = 0; require(USDC.transfer(to, amount), "Withdraw failed"); } function setPaused(bool _paused) external onlyOwner { paused = _paused; } // [FIX-6] 2-step ownership transfer function proposeOwnership(address newOwner) external onlyOwner { require(newOwner != address(0), "Zero address"); pendingOwner = newOwner; emit OwnershipProposed(newOwner); } function acceptOwnership() external { require(msg.sender == pendingOwner, "Not pending owner"); emit OwnershipTransferred(owner, pendingOwner); owner = pendingOwner; pendingOwner = address(0); } }