// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; interface IERC20 { function transferFrom(address from, address to, uint value) external returns (bool); function transfer(address to, uint value) external returns (bool); } /** * @title PredictionMarketV5 + XP System * * ORIGINAL V5 contract — all data preserved unchanged: * rounds, pools, upBets, downBets, currentRound, roundDuration, platformFee * * ADDED — XP system: * - 1,000,000 XP distributed over 17 weeks (~4 months) * - ~58,823 XP every Sunday 24:00 UTC (Monday transition) * - Proportional to each player weekly bet volume * - getPlayersWithXp(offset, limit) — list of all wallets + XP for developer */ contract PredictionMarketV5_XP { // ═══════════════════════════════════════════════════════════════ // ORIGINAL STRUCTS (V5 — unchanged) // ═══════════════════════════════════════════════════════════════ struct Pool { uint256 upPool; uint256 downPool; } struct Round { uint256 startTime; uint256 endTime; uint256 startPrice; uint256 endPrice; bool settled; bool outcome; } // ═══════════════════════════════════════════════════════════════ // ORIGINAL STATE (V5 — unchanged) // ═══════════════════════════════════════════════════════════════ address public owner; uint256 public currentRound; uint256 public roundDuration = 300; // 5 minutes uint256 public platformFee = 200; // 2% uint256 constant DENOM = 10000; mapping(uint256 => Round) public rounds; mapping(uint256 => mapping(address => Pool)) public pools; mapping(uint256 => mapping(address => mapping(address => uint256))) public upBets; mapping(uint256 => mapping(address => mapping(address => uint256))) public downBets; // ═══════════════════════════════════════════════════════════════ // XP SYSTEM (new) // ═══════════════════════════════════════════════════════════════ uint256 public constant XP_TOTAL = 1_000_000e18; // 1,000,000 XP (18 decimals) uint256 public constant XP_WEEKS = 17; // 17 weeks ~4 months uint256 public constant XP_PER_WEEK = XP_TOTAL / XP_WEEKS; // ~58,823 XP per week uint256 public constant WEEK = 7 days; /// Unix-timestamp of first Monday 00:00 UTC after deploy uint256 public xpStart; /// How many epochs distributed (0..17) uint256 public xpEpochsDone; /// Current active epoch (week) uint256 public xpEpochNow; /// XP balance per player mapping(address => uint256) public xpBalance; /// Total XP given out uint256 public xpGiven; /// All unique players (for iteration) address[] public players; mapping(address => bool) private _known; /// Player bet volume per week: epochVolume[epoch][player] mapping(uint256 => mapping(address => uint256)) public epochVolume; /// Total volume per week: epochTotal[epoch] mapping(uint256 => uint256) public epochTotal; // ═══════════════════════════════════════════════════════════════ // EVENTS // ═══════════════════════════════════════════════════════════════ event BetPlaced(uint256 roundId, address token, address user, bool direction, uint256 amount); event RoundStarted(uint256 roundId, uint256 startPrice); event RoundSettled(uint256 roundId, bool outcome, uint256 endPrice); event Claimed(uint256 roundId, address token, address user, uint256 payout); event XpAwarded(uint256 indexed epoch, address indexed player, uint256 amount); event XpEpochDone(uint256 indexed epoch, uint256 totalXp, uint256 numPlayers); event PlayerJoined(address indexed player); // ═══════════════════════════════════════════════════════════════ // MODIFIERS // ═══════════════════════════════════════════════════════════════ modifier onlyOwner() { require(msg.sender == owner, "Not owner"); _; } // ═══════════════════════════════════════════════════════════════ // CONSTRUCTOR // ═══════════════════════════════════════════════════════════════ /** * @param _startPrice Initial BTC/USD price for round #1 * @param _xpStart Timestamp of first Monday 00:00 UTC. * Pass 0 — contract auto-calculates next Monday. */ constructor(uint256 _startPrice, uint256 _xpStart) { owner = msg.sender; if (_xpStart > 0) { xpStart = _xpStart; } else { // Calculate next Monday 00:00 UTC automatically uint256 dow = (block.timestamp / 86400 + 4) % 7; uint256 daysLeft = dow == 0 ? 1 : (8 - dow) % 7; if (daysLeft == 0) daysLeft = 7; xpStart = (block.timestamp / 86400) * 86400 + daysLeft * 86400; } _startNewRound(_startPrice); } // ═══════════════════════════════════════════════════════════════ // BETS — ETH (original V5, with _track added) // ═══════════════════════════════════════════════════════════════ function betUp() external payable { require(block.timestamp < rounds[currentRound].endTime, "Round ended"); _track(msg.sender, msg.value); pools[currentRound][address(0)].upPool += msg.value; upBets[currentRound][address(0)][msg.sender] += msg.value; emit BetPlaced(currentRound, address(0), msg.sender, true, msg.value); } function betDown() external payable { require(block.timestamp < rounds[currentRound].endTime, "Round ended"); _track(msg.sender, msg.value); pools[currentRound][address(0)].downPool += msg.value; downBets[currentRound][address(0)][msg.sender] += msg.value; emit BetPlaced(currentRound, address(0), msg.sender, false, msg.value); } // ═══════════════════════════════════════════════════════════════ // BETS — ERC20 (original V5, with _track added) // ═══════════════════════════════════════════════════════════════ function betUpToken(address token, uint256 amount) external { require(block.timestamp < rounds[currentRound].endTime, "Round ended"); _track(msg.sender, amount); IERC20(token).transferFrom(msg.sender, address(this), amount); pools[currentRound][token].upPool += amount; upBets[currentRound][token][msg.sender] += amount; emit BetPlaced(currentRound, token, msg.sender, true, amount); } function betDownToken(address token, uint256 amount) external { require(block.timestamp < rounds[currentRound].endTime, "Round ended"); _track(msg.sender, amount); IERC20(token).transferFrom(msg.sender, address(this), amount); pools[currentRound][token].downPool += amount; downBets[currentRound][token][msg.sender] += amount; emit BetPlaced(currentRound, token, msg.sender, false, amount); } // ═══════════════════════════════════════════════════════════════ // SETTLE (original V5 — unchanged) // ═══════════════════════════════════════════════════════════════ function settle(uint256 endPrice) external onlyOwner { Round storage r = rounds[currentRound]; require(!r.settled, "Already settled"); require(block.timestamp >= r.endTime, "Round not ended"); r.settled = true; r.endPrice = endPrice; r.outcome = endPrice > r.startPrice; emit RoundSettled(currentRound, r.outcome, endPrice); _tickEpoch(); _startNewRound(endPrice); } // ═══════════════════════════════════════════════════════════════ // CLAIM (original V5 — unchanged) // ═══════════════════════════════════════════════════════════════ function claim(uint256 roundId, address token) external { Round storage r = rounds[roundId]; require(r.settled, "Round not settled"); Pool storage p = pools[roundId][token]; uint256 userBet; uint256 reward; if (r.outcome) { userBet = upBets[roundId][token][msg.sender]; require(userBet > 0, "No win"); reward = (userBet * (p.upPool + p.downPool)) / p.upPool; upBets[roundId][token][msg.sender] = 0; } else { userBet = downBets[roundId][token][msg.sender]; require(userBet > 0, "No win"); reward = (userBet * (p.upPool + p.downPool)) / p.downPool; downBets[roundId][token][msg.sender] = 0; } uint256 fee = (reward * platformFee) / DENOM; uint256 payout = reward - fee; if (token == address(0)) { _safeETH(msg.sender, payout); _safeETH(owner, fee); } else { IERC20(token).transfer(msg.sender, payout); IERC20(token).transfer(owner, fee); } emit Claimed(roundId, token, msg.sender, payout); } // ═══════════════════════════════════════════════════════════════ // XP — DISTRIBUTION // ═══════════════════════════════════════════════════════════════ /** * @notice Distribute XP for the past week. * Call after Sunday 24:00 UTC. * Anyone can call — contract enforces the schedule. */ function distributeXp() external { _tickEpoch(); require(xpEpochsDone < xpEpochNow, "Not Sunday yet"); require(xpEpochsDone < XP_WEEKS, "All XP distributed"); uint256 ep = xpEpochsDone; uint256 tot = epochTotal[ep]; if (tot == 0) { xpEpochsDone++; emit XpEpochDone(ep, 0, 0); return; } uint256 count = 0; for (uint256 i = 0; i < players.length; i++) { address p = players[i]; uint256 vol = epochVolume[ep][p]; if (vol == 0) continue; uint256 share = (XP_PER_WEEK * vol) / tot; xpBalance[p] += share; xpGiven += share; count++; emit XpAwarded(ep, p, share); } xpEpochsDone++; emit XpEpochDone(ep, XP_PER_WEEK, count); } /** * @notice Batch distribution for large player sets (gas-safe). * @param ep Epoch number (== xpEpochsDone) * @param from Start index in players array * @param to End index (exclusive) * @param finalize true = finalize epoch (pass in last batch) */ function distributeXpBatch(uint256 ep, uint256 from, uint256 to, bool finalize) external { _tickEpoch(); require(ep == xpEpochsDone, "Wrong epoch"); require(ep < xpEpochNow, "Epoch not closed"); require(ep < XP_WEEKS, "All XP distributed"); require(to <= players.length, "Out of bounds"); uint256 tot = epochTotal[ep]; if (tot == 0) { if (finalize) { xpEpochsDone++; emit XpEpochDone(ep, 0, 0); } return; } uint256 count = 0; for (uint256 i = from; i < to; i++) { address p = players[i]; uint256 vol = epochVolume[ep][p]; if (vol == 0) continue; uint256 share = (XP_PER_WEEK * vol) / tot; xpBalance[p] += share; xpGiven += share; count++; emit XpAwarded(ep, p, share); } if (finalize) { xpEpochsDone++; emit XpEpochDone(ep, XP_PER_WEEK, count); } } // ═══════════════════════════════════════════════════════════════ // XP — VIEW (for developer) // ═══════════════════════════════════════════════════════════════ /// @notice Total unique players function playerCount() external view returns (uint256) { return players.length; } /** * @notice All players with XP balances — for dev dashboard. * @param offset Start index * @param limit How many records to return * @return addrs Wallet addresses * @return xp XP balances (divide by 1e18 for display) */ function getPlayersWithXp(uint256 offset, uint256 limit) external view returns (address[] memory addrs, uint256[] memory xp) { uint256 total = players.length; if (offset >= total) return (new address[](0), new uint256[](0)); uint256 end = offset + limit > total ? total : offset + limit; uint256 size = end - offset; addrs = new address[](size); xp = new uint256[](size); for (uint256 i = 0; i < size; i++) { addrs[i] = players[offset + i]; xp[i] = xpBalance[players[offset + i]]; } } /** * @notice XP schedule info for frontend countdown. * @return nextDrop Timestamp of next Sunday 24:00 UTC * @return weeksLeft Weeks of distribution remaining * @return xpRemaining XP not yet distributed */ function xpInfo() external view returns ( uint256 nextDrop, uint256 weeksLeft, uint256 xpRemaining ) { nextDrop = xpStart + (xpEpochNow + 1) * WEEK; weeksLeft = xpEpochsDone < XP_WEEKS ? XP_WEEKS - xpEpochsDone : 0; xpRemaining = XP_TOTAL > xpGiven ? XP_TOTAL - xpGiven : 0; } /// @notice Betting volume for a player in a given epoch function playerEpochVolume(uint256 ep, address player) external view returns (uint256) { return epochVolume[ep][player]; } // ═══════════════════════════════════════════════════════════════ // INTERNAL FUNCTIONS // ═══════════════════════════════════════════════════════════════ function _track(address player, uint256 amount) internal { if (!_known[player]) { _known[player] = true; players.push(player); emit PlayerJoined(player); } epochVolume[xpEpochNow][player] += amount; epochTotal[xpEpochNow] += amount; } function _tickEpoch() internal { if (block.timestamp < xpStart) return; uint256 elapsed = (block.timestamp - xpStart) / WEEK; if (elapsed > xpEpochNow) xpEpochNow = elapsed; } function _startNewRound(uint256 price) internal { currentRound++; rounds[currentRound] = Round({ startTime: block.timestamp, endTime: block.timestamp + roundDuration, startPrice: price, endPrice: 0, settled: false, outcome: false }); emit RoundStarted(currentRound, price); } function _safeETH(address to, uint256 amount) internal { (bool ok, ) = to.call{value: amount}(""); require(ok, "ETH transfer failed"); } // ═══════════════════════════════════════════════════════════════ // ADMIN // ═══════════════════════════════════════════════════════════════ function setRoundDuration(uint256 _d) external onlyOwner { require(_d >= 60, "Min 60s"); roundDuration = _d; } function setPlatformFee(uint256 _f) external onlyOwner { require(_f <= 500, "Max 5%"); platformFee = _f; } receive() external payable {} }