// SPDX-License-Identifier: MIT pragma solidity 0.8.30; /// @title HODL2Win — timed rounds with a shared early-exit penalty pool. /// @notice Experimental, unaudited. Deploy to a test network only. /// Fixed 1% withdrawal fee. The house cannot change rules or access player funds. contract Hodl2Win { struct Round { uint64 startedAt; uint64 entryEndsAt; uint64 endsAt; uint16 penaltyBps; uint8 tier; bool finalized; uint256 principal; uint256 pool; uint256 players; } uint256 public constant HOUSE_FEE_BPS = 100; address public immutable house; uint256 public accruedHouseFees; bool public retired; constructor() { house = msg.sender; } uint256 public roundCount; mapping(uint256 => Round) public rounds; mapping(uint8 => uint256) public activeRound; mapping(uint256 => mapping(address => uint256)) public stakes; mapping(uint256 => mapping(address => bool)) public hasJoined; bool private entered; event RoundCreated(uint256 indexed roundId, uint8 tier, uint256 entryEndsAt, uint256 endsAt); event Joined(uint256 indexed roundId, address indexed player, uint256 amount); event Exited(uint256 indexed roundId, address indexed player, uint256 payout, uint256 penalty); event Finalized(uint256 indexed roundId, uint256 principal, uint256 pool); event Claimed(uint256 indexed roundId, address indexed player, uint256 payout); event HouseFeeAccrued(uint256 indexed roundId, address indexed player, uint256 fee); event HouseFeesWithdrawn(address indexed recipient, uint256 amount); event Retired(address indexed house); modifier nonReentrant() { require(!entered, "Reentrant call"); entered = true; _; entered = false; } /// @notice Permanently close new play without changing outstanding round rules. function retire() external nonReentrant { require(msg.sender == house, "Only house"); require(!retired, "Already retired"); retired = true; emit Retired(msg.sender); } function createRound(uint8 tier) external nonReentrant returns (uint256 id) { require(!retired, "Game retired"); require(tier < 3, "Invalid tier"); uint256 previous = activeRound[tier]; require(previous == 0 || rounds[previous].finalized || block.timestamp >= rounds[previous].endsAt, "Round still active"); uint64 duration = tier == 0 ? 5 minutes : tier == 1 ? 15 minutes : 60 minutes; uint64 entry = tier == 0 ? 60 : tier == 1 ? 180 : 600; id = ++roundCount; rounds[id] = Round(uint64(block.timestamp), uint64(block.timestamp) + entry, uint64(block.timestamp) + duration, tier == 0 ? 500 : tier == 1 ? 400 : 300, tier, false, 0, 0, 0); activeRound[tier] = id; emit RoundCreated(id, tier, block.timestamp + entry, block.timestamp + duration); } function join(uint256 id) external payable nonReentrant { require(!retired, "Game retired"); Round storage r = rounds[id]; require(r.startedAt != 0 && !r.finalized, "Invalid round"); require(block.timestamp < r.entryEndsAt, "Entry closed"); require(!hasJoined[id][msg.sender], "Already joined"); require(msg.value >= 0.001 ether && msg.value <= 1 ether, "Stake 0.001 to 1 ETH"); hasJoined[id][msg.sender] = true; stakes[id][msg.sender] = msg.value; r.principal += msg.value; r.players++; emit Joined(id, msg.sender, msg.value); } function exitEarly(uint256 id, address payable recipient) external nonReentrant { Round storage r = rounds[id]; require(!r.finalized && block.timestamp < r.endsAt, "Round ended"); uint256 stake = stakes[id][msg.sender]; require(stake != 0, "No stake"); require(recipient != address(0) && recipient != address(this), "Invalid recipient"); uint256 penalty = stake * r.penaltyBps / 10_000; uint256 payout = stake - penalty; stakes[id][msg.sender] = 0; r.principal -= stake; r.players--; r.pool += penalty; // If everyone exits, the final player receives the remaining pool. // This avoids stranded funds when no holders remain. if (r.principal == 0) { payout += r.pool; r.pool = 0; r.finalized = true; emit Finalized(id, 0, 0); } payout = _accrueFee(id, payout); emit Exited(id, msg.sender, payout, penalty); (bool success,) = recipient.call{value: payout}(""); require(success, "Transfer failed"); } function finalize(uint256 id) external nonReentrant { _finalize(id); } function _finalize(uint256 id) private { Round storage r = rounds[id]; require(r.startedAt != 0, "Invalid round"); require(block.timestamp >= r.endsAt, "Round not ended"); if (!r.finalized) { r.finalized = true; emit Finalized(id, r.principal, r.pool); } } function claim(uint256 id, address payable recipient) external nonReentrant { _finalize(id); Round storage r = rounds[id]; uint256 stake = stakes[id][msg.sender]; require(stake != 0, "No stake"); require(recipient != address(0) && recipient != address(this), "Invalid recipient"); // Shrinking accounting distributes integer dust to the last claimant. uint256 reward = r.pool * stake / r.principal; uint256 payout = stake + reward; stakes[id][msg.sender] = 0; r.principal -= stake; r.pool -= reward; r.players--; payout = _accrueFee(id, payout); emit Claimed(id, msg.sender, payout); (bool success,) = recipient.call{value: payout}(""); require(success, "Transfer failed"); } function _accrueFee(uint256 id, uint256 gross) private returns (uint256) { uint256 fee = gross * HOUSE_FEE_BPS / 10_000; accruedHouseFees += fee; emit HouseFeeAccrued(id, msg.sender, fee); return gross - fee; } /// @notice Withdraw only accrued fees. Player principal and pools are untouched. function withdrawHouseFees(address payable recipient) external nonReentrant { require(msg.sender == house, "Only house"); require(recipient != address(0) && recipient != address(this), "Invalid recipient"); uint256 amount = accruedHouseFees; require(amount != 0, "No house fees"); accruedHouseFees = 0; emit HouseFeesWithdrawn(recipient, amount); (bool success,) = recipient.call{value: amount}(""); require(success, "Transfer failed"); } function preview(uint256 id, address player) external view returns (uint256 payout, uint256 penalty, uint256 fee) { Round storage r = rounds[id]; uint256 stake = stakes[id][player]; if (stake == 0) return (0, 0, 0); uint256 gross; if (block.timestamp >= r.endsAt || r.players == 1) { gross = stake + r.pool * stake / r.principal; } else { penalty = stake * r.penaltyBps / 10_000; gross = stake - penalty; } fee = gross * HOUSE_FEE_BPS / 10_000; return (gross - fee, penalty, fee); } }