// SPDX-License-Identifier: MIT pragma solidity 0.8.24; import "./interfaces/ILotteryDependencies.sol"; /// @notice One immutable round. Local-trial code, not independently audited or production approved. /// @dev Exact-transfer, non-rebasing tokens are required. No owner, proxy, rescue or replacement oracle. contract UsdtLotteryRound is IRandomnessReceiver { struct Config { address stable; address verifier; address randomness; address treasury; uint256 saleDeadline; uint256 drawDeadline; } struct Award { uint256 ticket; address winner; uint256 tier; uint256 amount; } uint256 public constant TICKET_PRICE = 1 ether; uint256 public constant MAX_TICKETS = 1000; uint256 public constant MAX_PER_WALLET = 5; uint256 public constant SALE_DURATION = 1 days; uint256 public constant PRIZE_AMOUNT = 900 ether; uint256 public constant SERVICE_FEE = 100 ether; // Only positive prizes need on-chain awards; the other 200 tickets receive zero. uint256 public constant WINNERS = 800; uint256 public constant MAX_SETTLE_ATTEMPTS = 256; IERC20Exact public immutable stable; IAgenticPurchaseVerifier public immutable verifier; IRandomnessProvider public immutable randomness; address public immutable treasury; uint256 public immutable saleStart; uint256 public immutable saleDeadline; uint256 public immutable drawDeadline; uint256 public sold; uint256 public requestId; uint256 public prizePool; uint256 public awarded; uint256 public entropyCursor; uint256 public allocatedPrizes; bool public randomReady; bool public settled; bool public feeClaimed; uint256 public randomWord; uint256 private lock = 1; mapping(address => uint256) public ticketsBought; mapping(address => uint256) public nonces; mapping(address => uint256) public claimable; mapping(address => bool) public refunded; mapping(uint256 => address) public ticketOwner; mapping(uint256 => Award) public awards; // Sparse Fisher-Yates array: zero means the original index; nonzero encodes value + 1. mapping(uint256 => uint256) private shuffled; error InvalidConfig(); error InvalidState(); error InvalidPurchase(); error InvalidProof(); error InvalidRandomness(); error InexactTransfer(); error TokenCallFailed(); error Reentrancy(); event Purchased(address indexed buyer, uint256 firstTicket, uint256 quantity, uint256 nonce); event RandomnessRequested(uint256 indexed requestId); event RandomnessRequestFailed(); event RandomnessReceived(uint256 indexed requestId, uint256 word); event Awarded(uint256 indexed index, uint256 indexed ticket, address indexed winner, uint256 tier, uint256 amount); event Settled(uint256 prizePool); event Refunded(address indexed buyer, uint256 amount); event Claimed(address indexed winner, uint256 amount); event FeePaid(address indexed treasury, uint256 amount); modifier nonReentrant() { if (lock != 1) revert Reentrancy(); lock = 2; _; lock = 1; } constructor(Config memory c) { if (c.stable.code.length == 0 || c.verifier.code.length == 0 || c.randomness.code.length == 0 || c.treasury == address(0) || c.treasury == address(this) || c.saleDeadline < SALE_DURATION || c.saleDeadline - SALE_DURATION < block.timestamp || c.drawDeadline <= c.saleDeadline || IERC20Exact(c.stable).decimals() != 18) revert InvalidConfig(); stable = IERC20Exact(c.stable); verifier = IAgenticPurchaseVerifier(c.verifier); randomness = IRandomnessProvider(c.randomness); treasury = c.treasury; saleStart = c.saleDeadline - SALE_DURATION; saleDeadline = c.saleDeadline; drawDeadline = c.drawDeadline; } function buy(uint256 quantity, uint256 nonce, uint256 proofDeadline, bytes calldata proof) external nonReentrant { if (block.timestamp < saleStart || block.timestamp >= saleDeadline || requestId != 0 || quantity == 0 || quantity > MAX_PER_WALLET || ticketsBought[msg.sender] + quantity > MAX_PER_WALLET || sold + quantity > MAX_TICKETS || nonce != nonces[msg.sender]) revert InvalidPurchase(); // The immutable verifier defines the evidence format; membership uses empty bytes. if (block.timestamp > proofDeadline) revert InvalidProof(); IAgenticPurchaseVerifier.Purchase memory purchase = IAgenticPurchaseVerifier.Purchase({ buyer: msg.sender, chainId: block.chainid, round: address(this), quantity: quantity, unitPrice: TICKET_PRICE, nonce: nonce, deadline: proofDeadline }); // Reverting/malformed verifiers fail the whole transaction; there is no fallback entry path. if (!verifier.verify(purchase, proof)) revert InvalidProof(); uint256 first = sold; nonces[msg.sender] = nonce + 1; ticketsBought[msg.sender] += quantity; sold += quantity; for (uint256 i; i < quantity; i++) ticketOwner[first + i] = msg.sender; uint256 amount = quantity * TICKET_PRICE; uint256 beforeHere = stable.balanceOf(address(this)); uint256 beforeBuyer = stable.balanceOf(msg.sender); _tokenCall(address(stable), abi.encodeCall(IERC20Exact.transferFrom, (msg.sender, address(this), amount))); if (stable.balanceOf(address(this)) != beforeHere + amount || stable.balanceOf(msg.sender) != beforeBuyer - amount) revert InexactTransfer(); emit Purchased(msg.sender, first, quantity, nonce); } /// @notice Permissionless after sellout; never initiated automatically by the last buyer. function requestRandomness() external nonReentrant { if (sold != MAX_TICKETS || requestId != 0 || block.timestamp >= drawDeadline || stable.balanceOf(address(this)) < PRIZE_AMOUNT + SERVICE_FEE) revert InvalidState(); try randomness.requestRandomness() returns (uint256 id) { if (id == 0) { emit RandomnessRequestFailed(); return; } requestId = id; prizePool = PRIZE_AMOUNT; emit RandomnessRequested(id); } catch { emit RandomnessRequestFailed(); } } /// @notice Store-only callback. Provider must deliver asynchronously after its request returns. function receiveRandomness(uint256 id, uint256 word) external nonReentrant { if (msg.sender != address(randomness) || requestId == 0 || id != requestId || randomReady) revert InvalidRandomness(); randomWord = word; randomReady = true; emit RandomnessReceived(id, word); } /// @param maxAttempts Includes rejected entropy draws; 1..256 bounds work even on rejection. function settle(uint256 maxAttempts) external nonReentrant { if (!randomReady || settled || maxAttempts == 0 || maxAttempts > MAX_SETTLE_ATTEMPTS) revert InvalidState(); for (uint256 attempt; attempt < maxAttempts && awarded < WINNERS; attempt++) { uint256 remaining = MAX_TICKETS - awarded; uint256 sample = uint256(keccak256(abi.encode(randomWord, address(this), block.chainid, entropyCursor++))); // 2^256 mod remaining, without overflowing uint256. The accepted range has exactly // a multiple of remaining values. Rejected words consume the persisted entropy cursor. uint256 threshold = (type(uint256).max % remaining + 1) % remaining; if (sample < threshold) continue; uint256 slot = sample % remaining; uint256 ticket = shuffled[slot] == 0 ? slot : shuffled[slot] - 1; uint256 last = remaining - 1; shuffled[slot] = shuffled[last] == 0 ? last + 1 : shuffled[last]; uint256 index = awarded; (uint256 tier, uint256 amount) = _awardAmount(index); address winner = ticketOwner[ticket]; awards[index] = Award(ticket, winner, tier, amount); claimable[winner] += amount; allocatedPrizes += amount; awarded = index + 1; emit Awarded(index, ticket, winner, tier, amount); } if (awarded == WINNERS) { settled = true; emit Settled(prizePool); } } function _awardAmount(uint256 index) private view returns (uint256 tier, uint256 amount) { if (index == WINNERS - 1) return (3, prizePool - allocatedPrizes); uint256 budget; uint256 count; if (index < 1) { tier = 0; budget = 50; count = 1; } else if (index < 6) { tier = 1; budget = 100; count = 5; } else if (index < 106) { tier = 2; budget = 300; count = 100; } else { tier = 3; budget = 450; count = 694; } // Fixed 900-USDT pool; floor each tier allocation and give all dust to the final award. amount = (prizePool / 900 * budget + prizePool % 900 * budget / 900) / count; } function refund() external nonReentrant { bool expired = sold < MAX_TICKETS ? block.timestamp >= saleDeadline : block.timestamp >= drawDeadline; uint256 quantity = ticketsBought[msg.sender]; if (requestId != 0 || !expired || quantity == 0 || refunded[msg.sender]) revert InvalidState(); refunded[msg.sender] = true; uint256 amount = quantity * TICKET_PRICE; _transferExact(stable, msg.sender, amount); emit Refunded(msg.sender, amount); } function claim() external nonReentrant { uint256 amount = claimable[msg.sender]; if (!settled || amount == 0) revert InvalidState(); claimable[msg.sender] = 0; _transferExact(stable, msg.sender, amount); emit Claimed(msg.sender, amount); } /// @notice Anyone may trigger payment, but the recipient and amount cannot be redirected. function claimFee() external nonReentrant { if (!settled || feeClaimed) revert InvalidState(); feeClaimed = true; _transferExact(stable, treasury, SERVICE_FEE); emit FeePaid(treasury, SERVICE_FEE); } function _transferExact(IERC20Exact token, address to, uint256 amount) private { uint256 beforeHere = token.balanceOf(address(this)); uint256 beforeThere = token.balanceOf(to); _tokenCall(address(token), abi.encodeCall(IERC20Exact.transfer, (to, amount))); if (token.balanceOf(address(this)) != beforeHere - amount || token.balanceOf(to) != beforeThere + amount) revert InexactTransfer(); } function _tokenCall(address token, bytes memory data) private { (bool success, bytes memory result) = token.call(data); if (!success || (result.length != 0 && (result.length != 32 || !abi.decode(result, (bool))))) revert TokenCallFailed(); } }