// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /** * @title FomoMinerBibi * @notice BIBI-market FOMO miner on BSC. * * 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. * * 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). */ 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 FomoMinerBibi { 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 Gas cap: settle at most this many minutes per call. uint256 public constant MAX_DRIP_STEPS = 60; /// @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; // 沦波 IERC20 public immutable paymentToken; /// @notice Can withdraw pot / insurance / capitalPool. address public immutable poolClaimer; address public owner; 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; 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 ); 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 ForceEnded(uint256 indexed roundId, address indexed by); modifier onlyOwner() { require(msg.sender == owner, "NOT_OWNER"); _; } modifier onlyPoolClaimer() { require(msg.sender == poolClaimer, "NOT_CLAIMER"); _; } constructor(address token_, address poolClaimer_, uint256[KIND_COUNT] memory listPrices_) { require(token_ != address(0) && poolClaimer_ != address(0), "ZERO_ADDR"); paymentToken = IERC20(token_); poolClaimer = poolClaimer_; owner = msg.sender; 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(msg.sender); } // ── 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 minute drips on-chain (useful if idle). /// Settles up to MAX_DRIP_STEPS minutes (can exceed ~10M gas when busy). function dripMining() external { skimToCapital(); _dripMiningUpTo(MAX_DRIP_STEPS); } /** * @notice Settle at most `maxSteps` drip minutes (1..MAX_DRIP_STEPS). * Use small steps (e.g. 15–25) so each tx stays under mobile wallet * gas caps (~10M); call repeatedly until caught up. */ 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; } function _hireEmpty(Slot storage s, uint8 kindId, uint8 index, uint256 listPrice) internal { (uint256 fromVault, uint256 fromWallet) = _takePayment(msg.sender, listPrice); uint256 toPot = (listPrice * HIRE_POT_BPS) / BPS; uint256 toInsurance = (listPrice * HIRE_INSURANCE_BPS) / BPS; uint256 toCapital = listPrice - 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 toPrevious = (price * SNATCH_PREV_BPS) / BPS; uint256 toPot = (price * SNATCH_POT_BPS) / BPS; uint256 toInsurance = (price * SNATCH_INSURANCE_BPS) / BPS; uint256 toCapital = price - 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 ); } /// @dev Full catch-up helper (hire/snatch/claim paths). function _dripMining() internal { _dripMiningUpTo(MAX_DRIP_STEPS); } /** * @dev Settle up to `maxSteps` minutes of pot drips. * Each minute drips pot × min(0.5%, 0.1% + miners×0.0005% + addresses×0.005%), * then apportions by weight to every kind that has ≥1 active miner (even if only one kind). * Within each kind, miners split that kind's share equally. */ 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); uint256 steps; while (steps < maxSteps && cursor + 1 minutes <= block.timestamp) { if (pot == 0) break; uint256 w0 = _activeKindWeight(0); uint256 w1 = _activeKindWeight(1); uint256 w2 = _activeKindWeight(2); uint256 totalW = w0 + w1 + w2; // No active miners → do not drip this minute (pot stays put). if (totalW == 0) { cursor += 1 minutes; steps += 1; continue; } (uint256 activeMiners, uint256 activeAddresses) = _countActiveMinersAndAddresses(); uint256 dripPpm = _dripPpm(activeMiners, activeAddresses); uint256 drip = (pot * dripPpm) / DRIP_PPM; if (drip == 0) break; cursor += 1 minutes; pot -= drip; uint256 paidAll; paidAll += _payKindDrip(0, w0, totalW, drip); paidAll += _payKindDrip(1, w1, totalW, drip); paidAll += _payKindDrip(2, w2, totalW, drip); uint256 dust = drip - paidAll; if (dust > 0) { capitalPool += dust; capitalPeriodIn += dust; } steps += 1; } lastMineDripAt = uint64(cursor); } /// @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; emit RoundEnded(roundId, winner, insurancePaid, potCarried, capitalCarried); _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; } }