// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /** * @title FomoMinerBibiUpgradeable * @notice BIBI-market FOMO miner on BSC. * @notice UUPS-upgradeable edition for「BIBI版(Proxy代理合约测试)」. * Players always interact with the Proxy address (stable). * Owner can `upgradeTo` / `upgradeToAndCall` a new implementation anytime. * Storage is append-only — never reorder/remove existing state variables. * * Funding: * - Transfer BIBI directly to this contract, then `skimToCapital()` (auto on play). * - Pot is NEVER injected directly. In the first 5 minutes after each round opens, * `seedPotFromCapital()` moves 50% of `capitalPool` into `pot` (also auto on first hire). * * Open schedule: * - A round stays closed until someone transfers exactly `OPEN_TRIGGER_AMOUNT` (12345 BIBI) * into the contract (bare transfer + skim, `depositCapital`, or `signalOpen`). * - That signal schedules `roundOpensAt` = next whole hour after the signal * (first round after deploy uses the same rule). * * Kinds / slots (上一版规则写进链上): * - 3 kinds only: 0 辘轳 · 1 墨瑞亚 · 2 沦波(无布鲁克) * - Slots: 50 / 20 / 10 (`slotsOf(kindId)`) * - Weights: 100 / 500 / 1000 + trades × (10 / 15 / 20) * - Extend: +1 / +3 / +5 minutes (halved when open→end span > 24h) * * Mining (矿池滴灌): * - Every full minute while the round is open and pot is seeded: * drip rate = min(0.5%, 0.1% + (active miners × 0.0005%) + (unique miner addresses × 0.005%)), * then split that drip across kinds that currently have ≥1 miner, * proportional to each kind's mining weight. Within a kind, split equally * among its miners. A single active kind still receives the full drip * every minute. If no miners are active, that minute is skipped (pot unchanged); * in normal play the round has miners before meaningful drip. * - Lazy settlement on play / claim / pool withdraw / end: between interactions the * roster is fixed, so catch-up compounds all pending minutes then credits miners once. * * Timer: * - After the 12345 BIBI signal, opens at the next whole hour (UTC hour boundary = Beijing 整点). * - Starter 5 minutes; hire/snatch +1/+3/+5 min; remaining cap 23h59m59s. * - When (time since open + remaining collapse time) > 24 hours — i.e. the * current end is more than 24h after open — those extensions are halved * (+30s / +1m30s / +2m30s). * * Pools (all claimable by `poolClaimer`): * - pot 矿池 · insurance 保险 · capitalPool 资金池 * - On collapse, remaining insurance still credits `lastBuyer` winVault; * pot principal carries to the next round. * * Snatch: price = last buyPrice × buyout multiplier (starts 2.0×, decays to 1.2×); * 85% prev winVault · 5% pot · 5% insurance · 5% capital * Hire: listPrice · 0% pot · 50% insurance · 50% capital * Hold cap: each address may own at most MAX_MINERS_PER_ADDRESS slots (hire/snatch blocked at cap). * * Invite rebate (v1.2.0-proxy-invite, append-only upgrade): * - Top-cut `inviteFeeBps` of hire/snatch payment BEFORE pool split (100 = 1%). * - Half of fee → `opsRebateReceiver` genVault; half → inviter payout genVault. * - No trader→inviter map or no inviter payout → full fee to ops. * - Strategy B: `traderInviter[trader]=anchor` (stable); `inviterPayout[anchor]=EOA` (changeable). * - Disabled while `inviteFeeBps==0` or `opsRebateReceiver==address(0)`. * - Existing genVault/winVault / pools survive UUPS upgrade (storage append-only). */ interface IERC20 { function transferFrom(address from, address to, uint256 amount) external returns (bool); function transfer(address to, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256); } library SafeTransfer { function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal { (bool ok, bytes memory data) = address(token).call( abi.encodeWithSelector(token.transferFrom.selector, from, to, value) ); require(ok && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FROM_FAILED"); } function safeTransfer(IERC20 token, address to, uint256 value) internal { (bool ok, bytes memory data) = address(token).call( abi.encodeWithSelector(token.transfer.selector, to, value) ); require(ok && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FAILED"); } } contract FomoMinerBibiUpgradeable { using SafeTransfer for IERC20; /// @notice 三种矿工:0 辘轳 · 1 墨瑞亚 · 2 沦波(布鲁克已移除) uint8 public constant KIND_COUNT = 3; /// @notice Storage width (max of per-kind slot counts). uint8 public constant MAX_SLOTS = 50; uint8 public constant SLOTS_KIND_0 = 50; // 辘轳 uint8 public constant SLOTS_KIND_1 = 20; // 墨瑞亚 uint8 public constant SLOTS_KIND_2 = 10; // 沦波 /// @notice Max remaining collapse timer after hire/snatch (23h59m59s). uint256 public constant ROUND_MAX_DURATION = 23 hours + 59 minutes + 59 seconds; /// @notice Starter countdown + capital→pot seed window after open. uint256 public constant ROUND_START_DURATION = 5 minutes; uint256 public constant EXTEND_MINUTES_0 = 1; uint256 public constant EXTEND_MINUTES_1 = 3; uint256 public constant EXTEND_MINUTES_2 = 5; /// @notice When (elapsed since open + remaining) exceeds this, hire/snatch extends by half. uint256 public constant EXTEND_HALF_WHEN_SPAN_GT = 24 hours; uint256 public constant BUYOUT_DECAY_PER_MINUTE_BPS = 500; // 0.05x uint256 public constant BUYOUT_START_BPS = 20_000; // 2.0x uint256 public constant BUYOUT_FLOOR_BPS = 12_000; // 1.2x uint256 public constant BPS = 10_000; /// @notice Hire no longer funds pot; snatch still sends 5% to pot. uint256 public constant HIRE_POT_BPS = 0; uint256 public constant HIRE_INSURANCE_BPS = 5_000; // 50% // hire capital = remainder → 50% uint256 public constant SNATCH_PREV_BPS = 8_500; // 85% uint256 public constant SNATCH_POT_BPS = 500; // 5% uint256 public constant SNATCH_INSURANCE_BPS = 500; // 5% // snatch capital = remainder → 5% /// @notice Drip rate scale: 1_000_000 = 100%. Allows 0.0005% steps. uint256 public constant DRIP_PPM = 1_000_000; /// @notice Base pot drip per minute = 0.1%. uint256 public constant MINE_DRIP_BASE_PPM = 1_000; /// @notice Extra drip per active miner slot = 0.0005%. uint256 public constant MINE_DRIP_PER_MINER_PPM = 5; /// @notice Extra drip per unique address that holds ≥1 miner = 0.005%. uint256 public constant MINE_DRIP_PER_ADDRESS_PPM = 50; /// @notice Hard cap on pot drip per minute = 0.5%. uint256 public constant MINE_DRIP_MAX_PPM = 5_000; /// @notice Optional partial catch-up cap for `dripMiningSteps` (manual pushes). uint256 public constant MAX_DRIP_STEPS = 60; /// @notice Full catch-up ceiling (~7 days of minutes). Batch settle is cheap. uint256 public constant MAX_DRIP_CATCHUP = 10_080; /// @notice Exact BIBI amount that schedules the next whole-hour open. uint256 public constant OPEN_TRIGGER_AMOUNT = 12_345 ether; /// @notice Max miners one address may hold at once (across all kinds). uint8 public constant MAX_MINERS_PER_ADDRESS = 5; // Kind mining weights: 100/500/1000 + trades × (10/15/20) uint256 public constant BASE_WEIGHT_0 = 100; uint256 public constant BASE_WEIGHT_1 = 500; uint256 public constant BASE_WEIGHT_2 = 1000; uint256 public constant TRADE_BONUS_0 = 10; // 辘轳 uint256 public constant TRADE_BONUS_1 = 15; // 墨瑞亚 uint256 public constant TRADE_BONUS_2 = 20; // 沦波 /// @dev Set in `initialize` (storage, not immutable — required for proxies). IERC20 public paymentToken; /// @notice Can withdraw pot / insurance / capitalPool. Set in `initialize`. address public poolClaimer; address public owner; /// @notice Implementation semantic version (bump on each upgrade). string public version; bool private _initialized; bool private _initializing; struct KindConfig { uint256 listPrice; uint256 baseWeight; uint256 tradeBonus; } struct Slot { address owner; uint8 kindId; uint8 index; uint64 boughtAt; uint256 buyPrice; } struct Player { uint256 genVault; uint256 winVault; uint256 prebuy; } KindConfig[KIND_COUNT] public kinds; /// @dev slots[kindId][index]; only `slotsOf(kindId)` indices are valid. Slot[MAX_SLOTS][KIND_COUNT] public slots; uint256 public roundId; uint64 public roundEndsAt; uint64 public roundOpensAt; address public lastBuyer; bool public live; /// @notice True after 50% capital → pot seed for the current round. bool public potSeeded; /// @notice Last fully settled drip minute timestamp (unix). uint64 public lastMineDripAt; /// @notice Hire/snatch counts this round (resets each round); drives kind weights. uint256[KIND_COUNT] public tradeCounts; uint256 public pot; uint256 public insurance; uint256 public capitalPool; /// @notice Fee capital accrued this round (stats); pot seeding uses full capitalPool/2 at open. uint256 public capitalPeriodIn; /// @notice Sum of all player vault balances (tokens owed inside the contract). uint256 public vaultDebt; mapping(address => Player) public players; /// @notice Backend wallet allowed to set trader→inviter and inviter→payout maps. address public inviteRegistry; /// @notice Owner-settable ops EOA; receives half (or all) of the top-cut fee in genVault. address public opsRebateReceiver; /// @notice Top-cut of hire/snatch payment before pool split (100 = 1%). uint256 public inviteFeeBps; /// @notice trader wallet → inviter anchor (stable identity; does not change when payout changes). mapping(address => address) public traderInviter; /// @notice inviter anchor → current payout EOA (genVault credits). mapping(address => address) public inviterPayout; event RoundStarted(uint256 indexed roundId, uint64 opensAt, uint64 endsAt, address starter); event OpenScheduled(uint256 indexed roundId, uint64 opensAt, uint64 endsAt, address indexed signaler); event PotSeeded(uint256 indexed roundId, uint256 amount, uint256 capitalLeft); event CapitalDeposited(address indexed from, uint256 amount, uint256 capitalPool_); event MineDripped( uint256 indexed roundId, uint8 kindId, uint256 drip, uint256 miners, uint256 potLeft ); event Hired( address indexed buyer, uint8 kindId, uint8 index, uint256 paid, uint256 fromVault, uint256 fromWallet, uint256 toPot, uint256 toInsurance, uint256 toCapital ); event Snatched( address indexed buyer, address indexed previous, uint8 kindId, uint8 index, uint256 paid, uint256 fromVault, uint256 fromWallet, uint256 toPrevious, uint256 toPot, uint256 toInsurance, uint256 toCapital ); event Prebought(address indexed player, uint256 amount, uint256 fromVault, uint256 fromWallet, uint256 total); event RoundEnded( uint256 indexed roundId, address indexed winner, uint256 insuranceCredited, uint256 potCarried, uint256 capitalCarried, uint64 endsAt ); event Claimed(address indexed player, uint256 amount); event PotClaimed(address indexed to, uint256 amount); event InsuranceClaimed(address indexed to, uint256 amount); event CapitalClaimed(address indexed to, uint256 amount); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); event PoolClaimerUpdated(address indexed previousClaimer, address indexed newClaimer); event ForceEnded(uint256 indexed roundId, address indexed by); event InviteRegistryUpdated(address indexed previous, address indexed next); event OpsRebateReceiverUpdated(address indexed previous, address indexed next); event InviteFeeBpsUpdated(uint256 previous, uint256 next); event TraderInviterSet(address indexed trader, address indexed inviter, address indexed by); event InviterPayoutSet(address indexed inviter, address indexed payout, address indexed by); event InviteFeeSplit( address indexed trader, address indexed ops, address indexed invitePay, uint256 toOps, uint256 toInvite, uint256 paid ); modifier onlyOwner() { require(msg.sender == owner, "NOT_OWNER"); _; } modifier onlyPoolClaimer() { require(msg.sender == poolClaimer, "NOT_CLAIMER"); _; } modifier onlyInviteRegistry() { require(msg.sender == inviteRegistry && inviteRegistry != address(0), "NOT_REGISTRY"); _; } // EIP-1967 implementation slot bytes32 private constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; event Initialized(uint64 indexed version); event Upgraded(address indexed implementation); /// @dev Lock logic contract so it cannot be initialized outside a proxy. constructor() { _initialized = true; version = "impl-locked"; } modifier initializer() { require(!_initialized || _initializing, "ALREADY_INIT"); bool isTopLevel = !_initializing; if (isTopLevel) { _initializing = true; _initialized = true; } _; if (isTopLevel) { _initializing = false; emit Initialized(1); } } /// @notice One-time proxy initializer. Pass as Proxy constructor calldata. function initialize( address token_, address poolClaimer_, address owner_, uint256[KIND_COUNT] memory listPrices_ ) external initializer { require(token_ != address(0) && poolClaimer_ != address(0) && owner_ != address(0), "ZERO_ADDR"); paymentToken = IERC20(token_); poolClaimer = poolClaimer_; owner = owner_; version = "1.2.0-proxy-invite"; kinds[0] = KindConfig({listPrice: listPrices_[0], baseWeight: BASE_WEIGHT_0, tradeBonus: TRADE_BONUS_0}); kinds[1] = KindConfig({listPrice: listPrices_[1], baseWeight: BASE_WEIGHT_1, tradeBonus: TRADE_BONUS_1}); kinds[2] = KindConfig({listPrice: listPrices_[2], baseWeight: BASE_WEIGHT_2, tradeBonus: TRADE_BONUS_2}); for (uint8 k = 0; k < KIND_COUNT; k++) { uint8 n = slotsOf(k); for (uint8 i = 0; i < n; i++) { slots[k][i] = Slot({ owner: address(0), kindId: k, index: i, boughtAt: 0, buyPrice: 0 }); } } _startRound(owner_); } /// @notice Current logic contract (ERC-1967 slot). Call on the Proxy address. function implementation() public view returns (address impl) { bytes32 slot = _IMPLEMENTATION_SLOT; assembly { impl := sload(slot) } } /// @notice Upgrade Proxy to a new implementation, then optionally delegatecall `data`. function upgradeToAndCall(address newImplementation, bytes memory data) external onlyOwner { _setImplementation(newImplementation); if (data.length > 0) { (bool ok, bytes memory ret) = newImplementation.delegatecall(data); if (!ok) { if (ret.length == 0) revert("UPGRADE_CALL_FAILED"); assembly { revert(add(ret, 32), mload(ret)) } } } } /// @notice Upgrade Proxy implementation only. function upgradeTo(address newImplementation) external onlyOwner { _setImplementation(newImplementation); } /// @notice Owner can bump the advertised version after `upgradeTo` (storage lives on Proxy). function setVersion(string calldata newVersion) external onlyOwner { version = newVersion; } /// @notice Owner can rotate the three-pool claimer without upgrading the implementation. function setPoolClaimer(address newClaimer) external onlyOwner { require(newClaimer != address(0), "ZERO_ADDR"); address prev = poolClaimer; poolClaimer = newClaimer; emit PoolClaimerUpdated(prev, newClaimer); } function setInviteRegistry(address registry) external onlyOwner { address prev = inviteRegistry; inviteRegistry = registry; emit InviteRegistryUpdated(prev, registry); } function setOpsRebateReceiver(address receiver) external onlyOwner { require(receiver != address(0), "ZERO_ADDR"); address prev = opsRebateReceiver; opsRebateReceiver = receiver; emit OpsRebateReceiverUpdated(prev, receiver); } function setInviteFeeBps(uint256 bps) external onlyOwner { require(bps <= 2_000, "BPS_CAP"); // max 20% uint256 prev = inviteFeeBps; inviteFeeBps = bps; emit InviteFeeBpsUpdated(prev, bps); } /// @notice Bind a trader EOA to an inviter anchor (strategy B identity). function setTraderInviter(address trader, address inviter) external onlyInviteRegistry { require(trader != address(0), "ZERO_TRADER"); traderInviter[trader] = inviter; emit TraderInviterSet(trader, inviter, msg.sender); } /// @notice Update inviter's current payout EOA; traderInviter links stay on the anchor. function setInviterPayout(address inviter, address payout) external onlyInviteRegistry { require(inviter != address(0), "ZERO_INVITER"); inviterPayout[inviter] = payout; emit InviterPayoutSet(inviter, payout, msg.sender); } function _setImplementation(address newImplementation) private { require(newImplementation != address(0), "ZERO_IMPL"); require(newImplementation.code.length > 0, "NOT_CONTRACT"); bytes32 slot = _IMPLEMENTATION_SLOT; assembly { sstore(slot, newImplementation) } emit Upgraded(newImplementation); } // ── views ─────────────────────────────────────────────────────────────── /// @notice Per-kind slot count: 50 / 20 / 10. function slotsOf(uint8 kindId) public pure returns (uint8) { if (kindId == 0) return SLOTS_KIND_0; if (kindId == 1) return SLOTS_KIND_1; if (kindId == 2) return SLOTS_KIND_2; revert("BAD_KIND"); } function pools() external view returns (uint256 pot_, uint256 insurance_, uint256 capitalPool_, uint256 capitalPeriodIn_) { return (pot, insurance, capitalPool, capitalPeriodIn); } /// @notice Tokens on this contract not yet booked into pot/insurance/capital/vaults. function unaccountedTokens() public view returns (uint256) { uint256 bal = paymentToken.balanceOf(address(this)); uint256 accounted = pot + insurance + capitalPool + vaultDebt; return bal > accounted ? bal - accounted : 0; } function seedWindowOpen() public view returns (bool) { if (!live || potSeeded || roundOpensAt == 0) return false; if (block.timestamp < roundOpensAt) return false; return block.timestamp < uint256(roundOpensAt) + ROUND_START_DURATION; } /// @notice True while this round is waiting for a 12345 BIBI open signal. function openAwaitingSignal() public view returns (bool) { return live && roundOpensAt == 0; } /// @notice How many slots `player` currently owns. function minerCount(address player) public view returns (uint8 n) { for (uint8 k = 0; k < KIND_COUNT; k++) { uint8 lim = slotsOf(k); for (uint8 i = 0; i < lim; i++) { if (slots[k][i].owner == player) n += 1; } } } function kindWeight(uint8 kindId) public view returns (uint256) { require(kindId < KIND_COUNT, "BAD_KIND"); KindConfig storage k = kinds[kindId]; return k.baseWeight + tradeCounts[kindId] * k.tradeBonus; } function buyoutMultiplierBps(uint64 boughtAt) public view returns (uint256) { if (boughtAt == 0) return BUYOUT_START_BPS; uint256 elapsedMin = (block.timestamp - uint256(boughtAt)) / 60; uint256 decay = elapsedMin * BUYOUT_DECAY_PER_MINUTE_BPS; if (decay >= BUYOUT_START_BPS - BUYOUT_FLOOR_BPS) return BUYOUT_FLOOR_BPS; return BUYOUT_START_BPS - decay; } function snatchPrice(uint8 kindId, uint8 index) public view returns (uint256) { require(kindId < KIND_COUNT && index < slotsOf(kindId), "BAD_SLOT"); Slot storage s = slots[kindId][index]; require(s.owner != address(0), "EMPTY"); // Base is last trade price (buyPrice), not the kind's fixed listPrice. uint256 base = s.buyPrice > 0 ? s.buyPrice : kinds[kindId].listPrice; return (base * buyoutMultiplierBps(s.boughtAt)) / BPS; } /// @notice Settled genVault only (call after play to flush drips). Does not simulate pending drips. function pendingMining(address player) public view returns (uint256) { return players[player].genVault; } function claimable(address player) public view returns (uint256) { return players[player].genVault + players[player].winVault; } // ── capital / pot seed ────────────────────────────────────────────────── /** * @notice Book any bare BIBI transfers to this contract into `capitalPool`. * A credit of exactly `OPEN_TRIGGER_AMOUNT` while awaiting open schedules the next hour. */ function skimToCapital() public returns (uint256 credited) { credited = unaccountedTokens(); if (credited == 0) return 0; capitalPool += credited; capitalPeriodIn += credited; emit CapitalDeposited(msg.sender, credited, capitalPool); _maybeScheduleOpenFromCredit(credited, msg.sender); } /// @notice Optional: approve + deposit in one step (same effect as transfer + skim). /// Depositing exactly `OPEN_TRIGGER_AMOUNT` while awaiting open schedules the next hour. function depositCapital(uint256 amount) external { require(amount > 0, "ZERO"); paymentToken.safeTransferFrom(msg.sender, address(this), amount); capitalPool += amount; capitalPeriodIn += amount; emit CapitalDeposited(msg.sender, amount, capitalPool); _maybeScheduleOpenFromCredit(amount, msg.sender); } /** * @notice Pull exactly 12345 BIBI from caller and schedule open at the next whole hour. */ function signalOpen() external { require(live, "NOT_LIVE"); require(roundOpensAt == 0, "ALREADY_SCHEDULED"); paymentToken.safeTransferFrom(msg.sender, address(this), OPEN_TRIGGER_AMOUNT); capitalPool += OPEN_TRIGGER_AMOUNT; capitalPeriodIn += OPEN_TRIGGER_AMOUNT; emit CapitalDeposited(msg.sender, OPEN_TRIGGER_AMOUNT, capitalPool); _scheduleOpen(msg.sender); } /** * @notice Move 50% of capitalPool into pot. Only in the first 5 minutes after open, once per round. */ function seedPotFromCapital() public returns (uint256 seeded) { skimToCapital(); require(live, "NOT_LIVE"); _requireOpen(); require(block.timestamp < uint256(roundOpensAt) + ROUND_START_DURATION, "SEED_WINDOW_CLOSED"); require(!potSeeded, "ALREADY_SEEDED"); seeded = capitalPool / 2; require(seeded > 0, "NO_CAPITAL"); capitalPool -= seeded; pot += seeded; potSeeded = true; lastMineDripAt = uint64(block.timestamp); emit PotSeeded(roundId, seeded, capitalPool); } // ── actions ───────────────────────────────────────────────────────────── function hireOrSnatch(uint8 kindId, uint8 index) external { skimToCapital(); require(live, "NOT_LIVE"); _requireOpen(); require(kindId < KIND_COUNT && index < slotsOf(kindId), "BAD_SLOT"); _dripMining(); _maybeEndRound(); require(live, "ROUND_ENDED"); _requireOpen(); _ensurePotSeeded(); Slot storage s = slots[kindId][index]; uint256 listPrice = kinds[kindId].listPrice; if (s.owner == address(0)) { _requireCanAcquire(msg.sender); _hireEmpty(s, kindId, index, listPrice); } else { require(s.owner != msg.sender, "OWN_SLOT"); _requireCanAcquire(msg.sender); _snatch(s, kindId, index); } } function prebuy(uint256 amount) external { skimToCapital(); require(live, "NOT_LIVE"); _requireOpen(); require(amount > 0, "ZERO"); _dripMining(); _maybeEndRound(); require(live, "ROUND_ENDED"); _requireOpen(); _ensurePotSeeded(); (uint256 fromVault, uint256 fromWallet) = _takePayment(msg.sender, amount); players[msg.sender].prebuy += amount; pot += amount; emit Prebought(msg.sender, amount, fromVault, fromWallet, players[msg.sender].prebuy); } function endRoundIfDue() external { skimToCapital(); _dripMining(); _maybeEndRound(); } /// @notice Owner can collapse the current round immediately (insurance → lastBuyer, pot carries). function forceEndRound() external onlyOwner { skimToCapital(); _dripMining(); require(live, "NOT_LIVE"); if (roundOpensAt == 0) { // Allow ending a round that never received the open signal. roundOpensAt = uint64(block.timestamp); } roundEndsAt = uint64(block.timestamp); emit ForceEnded(roundId, msg.sender); _maybeEndRound(); } /// @notice Anyone can push pending drips on-chain (useful if idle). /// Batch-compounds all pending minutes then credits miners once. function dripMining() external { skimToCapital(); _dripMiningUpTo(MAX_DRIP_CATCHUP); } /** * @notice Settle at most `maxSteps` drip minutes (1..MAX_DRIP_STEPS). * Rarely needed after batch catch-up; kept for partial manual pushes. */ function dripMiningSteps(uint8 maxSteps) external { require(maxSteps > 0, "ZERO_STEPS"); skimToCapital(); uint256 cap = uint256(maxSteps); if (cap > MAX_DRIP_STEPS) cap = MAX_DRIP_STEPS; _dripMiningUpTo(cap); } function claim() external { _dripMining(); Player storage p = players[msg.sender]; uint256 amount = p.genVault + p.winVault; require(amount > 0, "NOTHING"); p.genVault = 0; p.winVault = 0; vaultDebt -= amount; paymentToken.safeTransfer(msg.sender, amount); emit Claimed(msg.sender, amount); } function claimPot(uint256 amount) external onlyPoolClaimer { skimToCapital(); _dripMining(); require(amount > 0 && amount <= pot, "BAD_AMOUNT"); pot -= amount; paymentToken.safeTransfer(poolClaimer, amount); emit PotClaimed(poolClaimer, amount); } function claimInsurance(uint256 amount) external onlyPoolClaimer { skimToCapital(); _dripMining(); require(amount > 0 && amount <= insurance, "BAD_AMOUNT"); insurance -= amount; paymentToken.safeTransfer(poolClaimer, amount); emit InsuranceClaimed(poolClaimer, amount); } function claimCapital(uint256 amount) external onlyPoolClaimer { skimToCapital(); _dripMining(); require(amount > 0 && amount <= capitalPool, "BAD_AMOUNT"); capitalPool -= amount; paymentToken.safeTransfer(poolClaimer, amount); emit CapitalClaimed(poolClaimer, amount); } function transferOwnership(address next) external onlyOwner { require(next != address(0), "ZERO_ADDR"); address prev = owner; owner = next; emit OwnershipTransferred(prev, next); } // ── internals ─────────────────────────────────────────────────────────── function _requireOpen() internal view { require(roundOpensAt != 0 && block.timestamp >= roundOpensAt, "NOT_OPEN"); } function _requireCanAcquire(address player) internal view { require(minerCount(player) < MAX_MINERS_PER_ADDRESS, "HOLD_CAP"); } function _maybeScheduleOpenFromCredit(uint256 credited, address signaler) internal { if (roundOpensAt != 0 || !live) return; if (credited != OPEN_TRIGGER_AMOUNT) return; _scheduleOpen(signaler); } function _scheduleOpen(address signaler) internal { require(live, "NOT_LIVE"); require(roundOpensAt == 0, "ALREADY_SCHEDULED"); roundOpensAt = uint64(_nextWholeHour(block.timestamp)); roundEndsAt = uint64(uint256(roundOpensAt) + ROUND_START_DURATION); emit OpenScheduled(roundId, roundOpensAt, roundEndsAt, signaler); emit RoundStarted(roundId, roundOpensAt, roundEndsAt, signaler); } function _ensurePotSeeded() internal { if (potSeeded) return; if (roundOpensAt == 0 || block.timestamp < roundOpensAt) revert("NOT_OPEN"); if (block.timestamp >= uint256(roundOpensAt) + ROUND_START_DURATION) { revert("NEED_SEED"); } seedPotFromCapital(); } function _takePayment(address payer, uint256 amount) internal returns (uint256 fromVault, uint256 fromWallet) { Player storage p = players[payer]; uint256 vault = p.genVault + p.winVault; if (vault >= amount) { _debitVault(p, amount); return (amount, 0); } fromVault = vault; fromWallet = amount - vault; if (fromVault > 0) { _debitVault(p, fromVault); } paymentToken.safeTransferFrom(payer, address(this), fromWallet); } function _debitVault(Player storage p, uint256 amount) internal { if (amount <= p.winVault) { p.winVault -= amount; vaultDebt -= amount; return; } amount -= p.winVault; vaultDebt -= p.winVault; p.winVault = 0; require(p.genVault >= amount, "VAULT"); p.genVault -= amount; vaultDebt -= amount; } function _creditWin(address player, uint256 amount) internal { if (amount == 0) return; players[player].winVault += amount; vaultDebt += amount; } function _creditGen(address player, uint256 amount) internal { if (amount == 0) return; players[player].genVault += amount; vaultDebt += amount; } /** * @dev Top-cut `inviteFeeBps` of `paid` before pool split. * Half → opsRebateReceiver genVault; half → inviter payout genVault. * No inviter / no payout → full fee to ops. * Disabled when fee bps is 0 or ops receiver unset. */ function _applyInviteFee(uint256 paid) internal returns (uint256 fee) { if (inviteFeeBps == 0 || opsRebateReceiver == address(0)) return 0; fee = (paid * inviteFeeBps) / BPS; if (fee == 0) return 0; address inviter = traderInviter[msg.sender]; address invitePay = inviter == address(0) ? address(0) : inviterPayout[inviter]; uint256 toInvite = 0; uint256 toOps = fee; if (invitePay != address(0)) { toInvite = fee / 2; toOps = fee - toInvite; } _creditGen(opsRebateReceiver, toOps); if (toInvite > 0) { _creditGen(invitePay, toInvite); } emit InviteFeeSplit(msg.sender, opsRebateReceiver, invitePay, toOps, toInvite, paid); } function _hireEmpty(Slot storage s, uint8 kindId, uint8 index, uint256 listPrice) internal { (uint256 fromVault, uint256 fromWallet) = _takePayment(msg.sender, listPrice); uint256 fee = _applyInviteFee(listPrice); uint256 net = listPrice - fee; uint256 toPot = (net * HIRE_POT_BPS) / BPS; uint256 toInsurance = (net * HIRE_INSURANCE_BPS) / BPS; uint256 toCapital = net - toPot - toInsurance; pot += toPot; insurance += toInsurance; capitalPool += toCapital; capitalPeriodIn += toCapital; s.owner = msg.sender; s.boughtAt = uint64(block.timestamp); s.buyPrice = listPrice; lastBuyer = msg.sender; tradeCounts[kindId] += 1; _extendRound(kindId); emit Hired(msg.sender, kindId, index, listPrice, fromVault, fromWallet, toPot, toInsurance, toCapital); emit RoundStarted(roundId, roundOpensAt, roundEndsAt, msg.sender); } function _snatch(Slot storage s, uint8 kindId, uint8 index) internal { uint256 base = s.buyPrice > 0 ? s.buyPrice : kinds[kindId].listPrice; uint256 price = (base * buyoutMultiplierBps(s.boughtAt)) / BPS; (uint256 fromVault, uint256 fromWallet) = _takePayment(msg.sender, price); address previous = s.owner; uint256 fee = _applyInviteFee(price); uint256 net = price - fee; uint256 toPrevious = (net * SNATCH_PREV_BPS) / BPS; uint256 toPot = (net * SNATCH_POT_BPS) / BPS; uint256 toInsurance = (net * SNATCH_INSURANCE_BPS) / BPS; uint256 toCapital = net - toPrevious - toPot - toInsurance; _creditWin(previous, toPrevious); pot += toPot; insurance += toInsurance; capitalPool += toCapital; capitalPeriodIn += toCapital; s.owner = msg.sender; s.boughtAt = uint64(block.timestamp); s.buyPrice = price; lastBuyer = msg.sender; tradeCounts[kindId] += 1; _extendRound(kindId); emit Snatched( msg.sender, previous, kindId, index, price, fromVault, fromWallet, toPrevious, toPot, toInsurance, toCapital ); // Same as hire: index the post-extend collapse deadline for 局况「耗时」. emit RoundStarted(roundId, roundOpensAt, roundEndsAt, msg.sender); } /// @dev Full catch-up helper (hire/snatch/claim paths). function _dripMining() internal { _dripMiningUpTo(MAX_DRIP_CATCHUP); } /** * @dev Settle up to `maxSteps` minutes of pot drips in one batch. * Between plays the roster (and drip ppm / kind weights) is fixed, so we * compound pot for all pending minutes first, then credit each miner once. * Same minute formula as before; avoids O(minutes × slots) gas on long gaps. */ function _dripMiningUpTo(uint256 maxSteps) internal { if (!live || !potSeeded || lastMineDripAt == 0) return; if (roundOpensAt == 0 || block.timestamp < roundOpensAt) return; if (maxSteps == 0) return; uint256 cursor = uint256(lastMineDripAt); if (cursor + 1 minutes > block.timestamp) return; uint256 elapsed = (block.timestamp - cursor) / 1 minutes; if (elapsed > maxSteps) elapsed = maxSteps; if (elapsed == 0) return; uint256 w0 = _activeKindWeight(0); uint256 w1 = _activeKindWeight(1); uint256 w2 = _activeKindWeight(2); uint256 totalW = w0 + w1 + w2; // No active miners → advance the clock, pot unchanged (same as per-minute skip). if (totalW == 0) { lastMineDripAt = uint64(cursor + elapsed * 1 minutes); return; } (uint256 activeMiners, uint256 activeAddresses) = _countActiveMinersAndAddresses(); uint256 dripPpm = _dripPpm(activeMiners, activeAddresses); uint256 potLocal = pot; uint256 totalDrip; uint256 settled; for (; settled < elapsed; settled++) { if (potLocal == 0) break; uint256 drip = (potLocal * dripPpm) / DRIP_PPM; if (drip == 0) break; potLocal -= drip; totalDrip += drip; } lastMineDripAt = uint64(cursor + settled * 1 minutes); if (totalDrip == 0) return; pot = potLocal; uint256 paidAll; paidAll += _payKindDrip(0, w0, totalW, totalDrip); paidAll += _payKindDrip(1, w1, totalW, totalDrip); paidAll += _payKindDrip(2, w2, totalW, totalDrip); uint256 dust = totalDrip - paidAll; if (dust > 0) { capitalPool += dust; capitalPeriodIn += dust; } } /// @notice Current minute drip rate in parts-per-million of pot (1_000_000 = 100%). function currentDripPpm() external view returns ( uint256 dripPpm, uint256 activeMiners, uint256 activeAddresses ) { (activeMiners, activeAddresses) = _countActiveMinersAndAddresses(); dripPpm = _dripPpm(activeMiners, activeAddresses); } /// @notice Pure formula helper (also useful for UI / tests). Capped at 0.5%. function dripPpmFor(uint256 activeMiners, uint256 activeAddresses) external pure returns (uint256) { return _dripPpm(activeMiners, activeAddresses); } /// @dev min(0.5%, 0.1% + miners×0.0005% + addresses×0.005%). function _dripPpm(uint256 activeMiners, uint256 activeAddresses) internal pure returns (uint256) { uint256 ppm = MINE_DRIP_BASE_PPM + activeMiners * MINE_DRIP_PER_MINER_PPM + activeAddresses * MINE_DRIP_PER_ADDRESS_PPM; return ppm > MINE_DRIP_MAX_PPM ? MINE_DRIP_MAX_PPM : ppm; } function _countActiveMinersAndAddresses() internal view returns (uint256 miners, uint256 addressesCount) { // At most 50+20+10 = 80 unique owners. address[80] memory seen; uint256 seenN; for (uint8 k = 0; k < KIND_COUNT; k++) { uint8 lim = slotsOf(k); for (uint8 i = 0; i < lim; i++) { address o = slots[k][i].owner; if (o == address(0)) continue; miners += 1; bool found; for (uint256 j = 0; j < seenN; j++) { if (seen[j] == o) { found = true; break; } } if (!found) { seen[seenN] = o; seenN += 1; } } } addressesCount = seenN; } function _activeKindWeight(uint8 kindId) internal view returns (uint256) { if (_countMining(kindId) == 0) return 0; KindConfig storage k = kinds[kindId]; return k.baseWeight + tradeCounts[kindId] * k.tradeBonus; } /// @return paid Amount actually credited to miners of this kind (0 if weight 0). function _payKindDrip( uint8 kindId, uint256 weight, uint256 totalW, uint256 drip ) internal returns (uint256 paid) { if (weight == 0 || totalW == 0) return 0; uint256 kindShare = (drip * weight) / totalW; if (kindShare == 0) return 0; uint8 miners = _countMining(kindId); if (miners == 0) return 0; uint256 share = kindShare / miners; uint8 lim = slotsOf(kindId); for (uint8 i = 0; i < lim; i++) { address o = slots[kindId][i].owner; if (o == address(0)) continue; _creditGen(o, share); paid += share; } emit MineDripped(roundId, kindId, kindShare, miners, pot); } function _countMining(uint8 kindId) internal view returns (uint8 n) { uint8 lim = slotsOf(kindId); for (uint8 i = 0; i < lim; i++) { if (slots[kindId][i].owner != address(0)) n += 1; } } function _maybeEndRound() internal { // Unscheduled rounds (awaiting 12345 signal) must not collapse via endsAt==0. if (!live || roundOpensAt == 0) return; if (block.timestamp < roundEndsAt) return; live = false; address winner = lastBuyer; uint256 insurancePaid = insurance; insurance = 0; if (winner != address(0)) { _creditWin(winner, insurancePaid); } uint256 potCarried = pot; capitalPeriodIn = 0; uint256 capitalCarried = capitalPool; for (uint8 k = 0; k < KIND_COUNT; k++) { uint8 lim = slotsOf(k); for (uint8 i = 0; i < lim; i++) { Slot storage s = slots[k][i]; s.owner = address(0); s.boughtAt = 0; s.buyPrice = 0; } tradeCounts[k] = 0; } lastMineDripAt = 0; // endsAt = mine-collapse deadline (not the settlement tx timestamp). emit RoundEnded(roundId, winner, insurancePaid, potCarried, capitalCarried, roundEndsAt); _startRound(winner); } function _startRound(address starter) internal { roundId += 1; live = true; potSeeded = false; lastMineDripAt = 0; // Wait for exactly 12345 BIBI signal, then next whole hour. roundOpensAt = 0; roundEndsAt = 0; lastBuyer = starter; emit RoundStarted(roundId, 0, 0, starter); } function _extendRound(uint8 kindId) internal { uint256 addSec = _extendSeconds(kindId); uint256 end = uint256(roundEndsAt); if (end < block.timestamp) end = block.timestamp; end += addSec; uint256 maxEnd = block.timestamp + ROUND_MAX_DURATION; if (end > maxEnd) end = maxEnd; roundEndsAt = uint64(end); } /// @notice Seconds this kind would add right now (respects half-extend rule). function extendSecondsFor(uint8 kindId) external view returns (uint256) { require(kindId < KIND_COUNT, "BAD_KIND"); return _extendSeconds(kindId); } /// @notice True when (time since open + remaining) already exceeds 24 hours. function extendIsHalved() external view returns (bool) { return _extendIsHalved(); } function _extendSeconds(uint8 kindId) internal view returns (uint256) { uint256 fullSec = EXTEND_MINUTES_0 * 1 minutes; if (kindId == 1) fullSec = EXTEND_MINUTES_1 * 1 minutes; else if (kindId >= 2) fullSec = EXTEND_MINUTES_2 * 1 minutes; if (_extendIsHalved()) { return fullSec / 2; } return fullSec; } /// @dev 开盘已过时间 + 坍塌剩余时间 > 24h → 续命减半(等价于 endsAt - opensAt > 24h)。 function _extendIsHalved() internal view returns (bool) { uint256 opens = uint256(roundOpensAt); if (opens == 0) return false; uint256 end = uint256(roundEndsAt); if (end < block.timestamp) end = block.timestamp; if (end <= opens) return false; return (end - opens) > EXTEND_HALF_WHEN_SPAN_GT; } function _remainingCollapse() internal view returns (uint256) { if (roundEndsAt <= block.timestamp) return 0; return uint256(roundEndsAt) - block.timestamp; } /// @notice Next unix timestamp that is exactly on an hour boundary (strictly after `ts`). function _nextWholeHour(uint256 ts) internal pure returns (uint256) { return (ts / 1 hours + 1) * 1 hours; } // ------------------------------------------------------------------------- // Reserve storage slots for future upgrades. // v1.2.0 consumed 5 slots from the original [40] gap: // inviteRegistry, opsRebateReceiver, inviteFeeBps, traderInviter, inviterPayout // When adding new state variables, declare them ABOVE this gap and reduce // the array length by the number of slots consumed. // ------------------------------------------------------------------------- uint256[35] private __gap; }