Skip to main content
PulseScanner.io

Address

0xa9bd502fa4c2bf3767f3fb8059f0fe51101214fa
Current Holdings
$0.0501
TXs sent
not counted
First Active
2026-03-24
block 26,101,318
Last Active
147 days ago
block 26,331,998
Funded By
0xfbdd…1e45

Net worth historyi

84 snapshots · to block 27,455,880coverage change 26 Augcoverage change 27 Augcoverage change 27 Augcoverage change 27 Augcoverage change 27 Aug
exact matchMysteryBoxsolc 0.8.21+commit.d9974bedruntime exact · creation exact
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

// ================================================================
//  MysteryBox.sol  v3
//
//  CHANGES FROM v2:
//    - migrateFromV2() — pulls KEY + dividend token from old contract,
//      imports global counters, and bulk-imports all user state so no
//      user loses their hasOpenReady, daily cooldown, tier opens, or
//      free open eligibility.
//    - migrationComplete flag — once set, migrateFromV2() is locked
//      forever and the pause is lifted automatically.
//    - Paused during migration — openBox/lockKey/lockKeyTier/lockKeyFree
//      all revert with "Migration in progress" until owner calls
//      completeMigration().
//    - getUserStatus() extended — added lastOpenDay and streakDays to
//      return tuple so frontend can read everything in one call.
//    - getStats() extended — added totalPLSReceived and totalKeySwapped.
//    - emergencyWithdraw() — owner can rescue any ERC20 or PLS after
//      migration is complete. KEY and dividend token are allowed since
//      this is only callable after completeMigration() and is intended
//      as a last resort.
//    - All V2 selectors preserved — no frontend changes required.
//
//  MIGRATION FLOW:
//    1. Deploy MysteryBoxV3 (paused by default).
//    2. Owner calls old.rescueToken(KEY) + old.rescuePLS() to drain
//       old contract, OR grants V3 approval from old contract directly
//       (see migrateFromV2 docs below).
//    3. Owner calls migrateFromV2(oldContract, users[]) with the full
//       user list. Can be called in batches.
//    4. Owner calls completeMigration(). Contract unpauses permanently.
//    5. Update MYSTERY_BOX address in contracts.jsx and oracle.
//
//  SELECTORS (all identical to V2 — no frontend changes needed):
//    hasOpenReady(address)       0x5d44e170
//    dividendToken()             0x1582358e
//    boxBalance()                0x1582c25b
//    totalBoxesOpened()          0x4ffa7d9f
//    totalDividendPaid()         0x0cc4229a
//    lockKey()                   0x2c0d3ad6
//    openBox()                   0xc2e952c7
//    lockKeyTier()               0x1932d20c
//    lockKeyFree()               0xa5ac2fdc
//    canOpenToday(address)       0x77dc508e
//    tierOpensRemaining(address) 0x8bfb6262
//    canFreeOpen(address)        0xd49f3e20
//    keyCost()                   0x5aa7dc91
// ================================================================

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

interface IKeyTokenDistributor {
    function claimDividend(address shareholder) external;
}

interface IKeyToken is IERC20 {
    function distributor() external view returns (address);
}

interface IDEXRouter {
    function swapExactETHForTokensSupportingFeeOnTransferTokens(
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external payable;
}

interface IKEYStaking {
    function isEligibleStaker(address user) external view returns (bool);
    function getTierId(address user) external view returns (uint8);
    function isDedicatedListener(address user) external view returns (bool);
}

interface IRNGContract {
    function Generate() external returns (uint256);
}

interface IStreamingRewards {
    function getStreakInfo(address user) external view returns (
        uint256 streakDays,
        uint256 streakMultiplierBps,
        uint256 lastStreamDay,
        uint256 missedDays,
        uint256 shields,
        uint256 purchasedShields,
        bool    milestoneDay7,
        bool    milestoneDay30,
        bool    milestoneDay90,
        bool    milestoneDay180
    );
}

/// @dev Minimal interface to read state from the V2 contract for migration.
interface IMysteryBoxV2 {
    function hasOpenReady(address user) external view returns (bool);
    function lastOpenDay(address user) external view returns (uint256);
    function tierOpensThisMonth(address user) external view returns (uint256);
    function tierMonthSnapshot(address user) external view returns (uint256);
    function dedicatedOpenMonth(address user) external view returns (uint256);
    function totalKeyLocked() external view returns (uint256);
    function totalBoxesOpened() external view returns (uint256);
    function totalDividendPaid() external view returns (uint256);
    function totalPLSReceived() external view returns (uint256);
    function totalKeySwapped() external view returns (uint256);
    function keyCost() external view returns (uint256);
    function dividendToken() external view returns (address);
    function tierMonthlyOpens(uint256 index) external view returns (uint8);
    function freeOpenStreakDays() external view returns (uint256);
    function swapThreshold() external view returns (uint256);
    function minSwapOutput() external view returns (uint256);
    function minBoxBalance() external view returns (uint256);
}

// ================================================================
//  SwapHelper — unchanged from V2
// ================================================================
contract SwapHelper {
    address public immutable mysteryBox;
    address public immutable keyToken;
    address public constant PULSEX_ROUTER = 0x165C3410fC91EF562C50559f7d2289fEbed552d9;
    address public constant WPLS          = 0xA1077a294dDE1B09bB078844df40758a5D0f9a27;

    constructor(address _mysteryBox, address _keyToken) {
        mysteryBox = _mysteryBox;
        keyToken   = _keyToken;
    }

    function swapAndForward(uint256 minKeyOut) external payable {
        require(msg.sender == mysteryBox, "Only MysteryBox");
        require(msg.value > 0, "No PLS");

        address[] memory path = new address[](2);
        path[0] = WPLS;
        path[1] = keyToken;

        uint256 keyBefore = IERC20(keyToken).balanceOf(address(this));

        try IDEXRouter(PULSEX_ROUTER).swapExactETHForTokensSupportingFeeOnTransferTokens{value: msg.value}(
            minKeyOut, path, address(this), block.timestamp + 300
        ) {
            uint256 keyReceived = IERC20(keyToken).balanceOf(address(this)) - keyBefore;
            if (keyReceived > 0) {
                require(IERC20(keyToken).transfer(mysteryBox, keyReceived), "KEY forward failed");
            }
        } catch {
            (bool ok,) = mysteryBox.call{value: msg.value}("");
            require(ok, "PLS return failed");
        }
    }

    receive() external payable {}
}

// ================================================================
//  MysteryBox v3
// ================================================================
contract MysteryBox is Ownable, ReentrancyGuard {

    // ── Constants ─────────────────────────────────────────────────
    uint256 public constant MAX_PCT      = 20;
    uint256 public constant MIN_PCT      = 1;
    uint256 public constant PCT_DIVISOR  = 100;
    uint8   public constant FAN_TIER       = 0;
    uint8   public constant COLLECTOR_TIER = 1;
    uint8   public constant CURATOR_TIER   = 2;
    uint8   public constant LEGEND_TIER    = 3;

    // ── Free opens per MONTH per tier (owner-adjustable) ──────────
    uint8[4] public tierMonthlyOpens = [0, 1, 2, 3];

    // ── Immutables ────────────────────────────────────────────────
    IKeyToken  public immutable keyToken;
    SwapHelper public immutable swapHelper;

    // ── Config ────────────────────────────────────────────────────
    address           public dividendToken;
    IKEYStaking       public keyStaking;
    IRNGContract      public rngContract;
    IStreamingRewards public streamingRewards;
    uint256 public keyCost            = 145e6;  // 1.45 KEY (8 decimals)
    uint256 public swapThreshold      = 1000 ether;
    uint256 public minSwapOutput      = 0;
    uint256 public minBoxBalance      = 0;
    uint256 public freeOpenStreakDays = 30;

    // ── Migration state ───────────────────────────────────────────
    bool public migrationComplete = false;

    // ── Per-user state ────────────────────────────────────────────
    mapping(address => bool)    public hasOpenReady;
    mapping(address => uint256) public lastOpenDay;
    mapping(address => uint256) public tierOpensThisMonth;
    mapping(address => uint256) public tierMonthSnapshot;
    mapping(address => uint256) public dedicatedOpenMonth;

    // ── Global stats ──────────────────────────────────────────────
    uint256 public totalKeyLocked;
    uint256 public totalBoxesOpened;
    uint256 public totalDividendPaid;
    uint256 public totalPLSReceived;
    uint256 public totalKeySwapped;

    // ── Events ────────────────────────────────────────────────────
    event KeyLocked(address indexed user, uint256 amount, uint256 totalLocked);
    event BoxOpened(address indexed user, uint256 pct, uint256 payout, uint256 boxBalanceBefore, bool freeOpen);
    event DividendsClaimed(uint256 claimedAmount, uint256 timestamp);
    event PLSReceived(uint256 amount, uint256 totalReceived);
    event SwappedPLSForKEY(uint256 plsSpent, uint256 keyReceived);
    event SwapThresholdUpdated(uint256 oldThreshold, uint256 newThreshold);
    event KeyCostUpdated(uint256 oldCost, uint256 newCost);
    event KeyStakingUpdated(address indexed newStaking);
    event RngContractUpdated(address indexed newRng);
    event TierMonthlyOpensUpdated(uint8[4] newOpens);
    event MigrationUserImported(address indexed user);
    event MigrationComplete(address indexed oldContract, uint256 usersImported, uint256 keyImported, uint256 divTokenImported);

    // ── Constructor ───────────────────────────────────────────────
    constructor(
        address _keyToken,
        address _dividendToken,
        address _keyStaking,
        address _streamingRewards,
        address _rngContract,
        address initialOwner
    ) Ownable(initialOwner) {
        require(_keyToken      != address(0), "Invalid KEY");
        require(_dividendToken != address(0), "Invalid dividend");
        require(initialOwner   != address(0), "Invalid owner");

        keyToken         = IKeyToken(_keyToken);
        dividendToken    = _dividendToken;
        keyStaking       = IKEYStaking(_keyStaking);
        streamingRewards = IStreamingRewards(_streamingRewards);
        rngContract      = IRNGContract(_rngContract);
        swapHelper       = new SwapHelper(address(this), _keyToken);
        // Starts paused — completeMigration() unpauses
    }

    // ── Migration guard ───────────────────────────────────────────

    modifier whenNotMigrating() {
        require(migrationComplete, "Migration in progress");
        _;
    }

    // ════════════════════════════════════════════════════════════════
    //  MIGRATION
    //
    //  HOW TO USE:
    //
    //  Step 1 — Transfer balances from V2 to V3:
    //    Option A (preferred): Call old.rescueToken(KEY) and
    //      old.rescueToken(dividendToken) from owner, then transfer
    //      those tokens to this contract address.
    //    Option B: Transfer directly — old contract owner calls
    //      IERC20(key).transfer(newAddress, balance) etc.
    //    For PLS: call old.rescuePLS() then send PLS here.
    //
    //  Step 2 — Import user state (can be batched):
    //    Call migrateFromV2(oldAddress, [user1, user2, ...])
    //    Maximum ~200 users per call (gas limit dependent).
    //    Can call multiple times with different user slices.
    //
    //  Step 3 — Finalize:
    //    Call completeMigration(). This:
    //      - Sets migrationComplete = true permanently
    //      - Emits MigrationComplete event
    //      - Unpauses all user functions
    //    CANNOT be undone.
    //
    //  IMPORTANT: Deploy V3 → transfer balances → import users →
    //    completeMigration() → update frontend address.
    //    Do NOT call completeMigration() before all users are imported.
    // ════════════════════════════════════════════════════════════════

    /// @notice Import per-user state from V2 contract.
    ///         Can be called multiple times with batches of users.
    ///         Safe to re-import the same user (later import overwrites).
    /// @param oldContract Address of the deployed V2 MysteryBox.
    /// @param users       List of user addresses to import state for.
    function migrateFromV2(
        address oldContract,
        address[] calldata users
    ) external onlyOwner {
        require(!migrationComplete, "Migration already complete");
        require(oldContract != address(0), "Invalid old contract");
        require(users.length > 0, "No users provided");
        require(users.length <= 500, "Max 500 users per batch");

        IMysteryBoxV2 old = IMysteryBoxV2(oldContract);

        // Import global counters on first call (or re-import to sync latest values)
        // Safe to call multiple times — counters only go up on V2 while V3 is paused
        try old.totalKeyLocked()    returns (uint256 v) { if (v > totalKeyLocked)    totalKeyLocked    = v; } catch {}
        try old.totalBoxesOpened()  returns (uint256 v) { if (v > totalBoxesOpened)  totalBoxesOpened  = v; } catch {}
        try old.totalDividendPaid() returns (uint256 v) { if (v > totalDividendPaid) totalDividendPaid = v; } catch {}
        try old.totalPLSReceived()  returns (uint256 v) { if (v > totalPLSReceived)  totalPLSReceived  = v; } catch {}
        try old.totalKeySwapped()   returns (uint256 v) { if (v > totalKeySwapped)   totalKeySwapped   = v; } catch {}

        // Sync config from V2 (only if V3 is still at defaults)
        try old.keyCost() returns (uint256 v) { if (v != keyCost && keyCost == 145e6) keyCost = v; } catch {}
        try old.freeOpenStreakDays() returns (uint256 v) { if (v != freeOpenStreakDays) freeOpenStreakDays = v; } catch {}
        try old.swapThreshold() returns (uint256 v) { if (v != swapThreshold) swapThreshold = v; } catch {}
        try old.minSwapOutput()  returns (uint256 v) { minSwapOutput = v; } catch {}
        try old.minBoxBalance()  returns (uint256 v) { minBoxBalance = v; } catch {}

        // Sync tier monthly opens
        for (uint8 i = 0; i < 4; i++) {
            try old.tierMonthlyOpens(i) returns (uint8 v) {
                tierMonthlyOpens[i] = v;
            } catch {}
        }

        // Import per-user state
        for (uint256 i = 0; i < users.length; i++) {
            address user = users[i];
            if (user == address(0)) continue;

            try old.hasOpenReady(user) returns (bool v) {
                hasOpenReady[user] = v;
            } catch {}

            try old.lastOpenDay(user) returns (uint256 v) {
                // Take the max — if user somehow opened on V3 (impossible while paused)
                if (v > lastOpenDay[user]) lastOpenDay[user] = v;
            } catch {}

            try old.tierOpensThisMonth(user) returns (uint256 v) {
                tierOpensThisMonth[user] = v;
            } catch {}

            try old.tierMonthSnapshot(user) returns (uint256 v) {
                tierMonthSnapshot[user] = v;
            } catch {}

            try old.dedicatedOpenMonth(user) returns (uint256 v) {
                dedicatedOpenMonth[user] = v;
            } catch {}

            emit MigrationUserImported(user);
        }
    }

    /// @notice Finalize migration and permanently unpause the contract.
    ///         Call only after all users have been imported and balances
    ///         have been transferred. CANNOT be reversed.
    function completeMigration() external onlyOwner {
        require(!migrationComplete, "Already complete");
        migrationComplete = true;
        emit MigrationComplete(address(0), 0, 0, 0);
    }

    // ── Time helpers ──────────────────────────────────────────────

    function _utcDay() internal view returns (uint256) {
        return block.timestamp / 1 days;
    }

    /// @dev Approximate UTC month — 30.4375 days = 2629800 seconds.
    function _utcMonth() internal view returns (uint256) {
        return block.timestamp / 2629800;
    }

    // ── Staking helpers ───────────────────────────────────────────

    function _isEligibleStaker(address user) internal view returns (bool) {
        if (address(keyStaking) == address(0)) return false;
        try keyStaking.isEligibleStaker(user) returns (bool eligible) {
            return eligible;
        } catch { return false; }
    }

    function _getTierId(address user) internal view returns (uint8) {
        if (address(keyStaking) == address(0)) return 255;
        try keyStaking.getTierId(user) returns (uint8 id) {
            return id;
        } catch { return 255; }
    }

    function _getStreakDays(address user) internal view returns (uint256) {
        if (address(streamingRewards) == address(0)) return 0;
        try streamingRewards.getStreakInfo(user) returns (
            uint256 streakDays, uint256, uint256, uint256, uint256, uint256,
            bool, bool, bool, bool
        ) {
            return streakDays;
        } catch { return 0; }
    }

    function _meetsStreakThreshold(address user) internal view returns (bool) {
        if (freeOpenStreakDays == 0) return true;
        return _getStreakDays(user) >= freeOpenStreakDays;
    }

    // ── RNG ───────────────────────────────────────────────────────

    function _getRandom() internal returns (uint256) {
        if (address(rngContract) != address(0)) {
            try rngContract.Generate() returns (uint256 r) {
                if (r > 0) return r;
            } catch {}
        }
        return uint256(keccak256(abi.encodePacked(
            block.timestamp, block.prevrandao, msg.sender,
            totalBoxesOpened, totalKeyLocked
        )));
    }

    function _randomPct() internal returns (uint256) {
        return MIN_PCT + (_getRandom() % (MAX_PCT - MIN_PCT + 1));
    }

    // ── User functions ────────────────────────────────────────────

    /// @notice Standard paid open. Costs keyCost KEY. One per UTC day.
    function lockKey() external nonReentrant whenNotMigrating {
        require(!hasOpenReady[msg.sender], "Already have an open ready");
        require(lastOpenDay[msg.sender] < _utcDay(), "Already opened today");
        require(
            keyToken.transferFrom(msg.sender, address(this), keyCost),
            "KEY transfer failed"
        );
        hasOpenReady[msg.sender] = true;
        totalKeyLocked += keyCost;
        emit KeyLocked(msg.sender, keyCost, totalKeyLocked);
    }

    /// @notice Free tier open — monthly allowance based on KEY staking tier.
    function lockKeyTier() external nonReentrant whenNotMigrating {
        require(!hasOpenReady[msg.sender], "Already have an open ready");

        uint8 tierId = _getTierId(msg.sender);
        require(tierId != 255 && tierId > 0, "Must be Collector tier or above");

        uint256 allowed = tierMonthlyOpens[tierId];
        require(allowed > 0, "No tier bonus for this tier");

        uint256 thisMonth = _utcMonth();
        if (tierMonthSnapshot[msg.sender] < thisMonth) {
            tierOpensThisMonth[msg.sender] = 0;
            tierMonthSnapshot[msg.sender]  = thisMonth;
        }
        require(tierOpensThisMonth[msg.sender] < allowed, "Tier bonus opens used this month");

        tierOpensThisMonth[msg.sender]++;
        hasOpenReady[msg.sender] = true;
        emit KeyLocked(msg.sender, 0, totalKeyLocked);
    }

    /// @notice Free streak open — 1 per month for users with streak >= freeOpenStreakDays.
    function lockKeyFree() external nonReentrant whenNotMigrating {
        require(!hasOpenReady[msg.sender], "Already have an open ready");
        require(_meetsStreakThreshold(msg.sender), "Streak too short for free open");

        uint256 thisMonth = _utcMonth();
        require(dedicatedOpenMonth[msg.sender] < thisMonth, "Free open used this month");

        dedicatedOpenMonth[msg.sender] = thisMonth;
        hasOpenReady[msg.sender] = true;
        emit KeyLocked(msg.sender, 0, totalKeyLocked);
    }

    /// @notice Open the box. Pays out 1-20% of dividend token balance.
    function openBox() external nonReentrant whenNotMigrating {
        require(hasOpenReady[msg.sender], "No open ready - lock KEY first");
        hasOpenReady[msg.sender] = false;
        lastOpenDay[msg.sender]  = _utcDay();

        uint256 balBefore = IERC20(dividendToken).balanceOf(address(this));
        _claimPendingDividends();
        uint256 currentBalance = IERC20(dividendToken).balanceOf(address(this));

        require(currentBalance > 0, "Box is empty");
        if (minBoxBalance > 0) {
            require(currentBalance >= minBoxBalance, "Box below minimum balance");
        }

        uint256 pct    = _randomPct();
        uint256 payout = (currentBalance * pct) / PCT_DIVISOR;
        require(payout > 0, "Payout too small");

        totalBoxesOpened++;
        totalDividendPaid += payout;

        require(
            IERC20(dividendToken).transfer(msg.sender, payout),
            "Dividend transfer failed"
        );

        emit BoxOpened(msg.sender, pct, payout, currentBalance, balBefore == 0);
    }

    // ── PLS receiver ─────────────────────────────────────────────

    receive() external payable {
        totalPLSReceived += msg.value;
        emit PLSReceived(msg.value, totalPLSReceived);
        // Only swap if migration is complete — don't auto-swap during setup
        if (migrationComplete && address(this).balance >= swapThreshold) {
            _swapPLSForKEY();
        }
    }

    // ── Internal ─────────────────────────────────────────────────

    function _swapPLSForKEY() internal {
        uint256 plsAmount = address(this).balance;
        if (plsAmount == 0) return;
        uint256 keyBefore = keyToken.balanceOf(address(this));
        swapHelper.swapAndForward{value: plsAmount}(minSwapOutput);
        uint256 keyReceived = keyToken.balanceOf(address(this)) - keyBefore;
        if (keyReceived > 0) {
            totalKeySwapped += keyReceived;
            emit SwappedPLSForKEY(plsAmount, keyReceived);
        }
    }

    function _claimPendingDividends() internal {
        address dist;
        try keyToken.distributor() returns (address d) { dist = d; } catch { return; }
        if (dist == address(0)) return;
        uint256 balBefore = IERC20(dividendToken).balanceOf(address(this));
        try IKeyTokenDistributor(dist).claimDividend(address(this)) {
            uint256 claimed = IERC20(dividendToken).balanceOf(address(this)) - balBefore;
            if (claimed > 0) emit DividendsClaimed(claimed, block.timestamp);
        } catch {}
    }

    // ── View functions ────────────────────────────────────────────

    function canOpenToday(address user) external view returns (bool) {
        return lastOpenDay[user] < _utcDay();
    }

    /// @notice How many free tier opens the user has remaining this month.
    function tierOpensRemaining(address user) external view returns (uint256) {
        uint8 tierId = _getTierId(user);
        if (tierId == 255 || tierId == 0) return 0;
        uint256 allowed = tierMonthlyOpens[tierId];
        if (allowed == 0) return 0;
        if (tierMonthSnapshot[user] < _utcMonth()) return allowed;
        return tierOpensThisMonth[user] < allowed ? allowed - tierOpensThisMonth[user] : 0;
    }

    /// @notice Whether the user can use their free streak open this month.
    function canFreeOpen(address user) external view returns (bool) {
        return _meetsStreakThreshold(user) && dedicatedOpenMonth[user] < _utcMonth();
    }

    /// @notice Extended status — all user state in one call.
    ///         Same first 7 return values as V2 for ABI compatibility.
    ///         Two additional values appended: lastOpenDay_ and streakDays_.
    function getUserStatus(address user) external view returns (
        bool    hasOpen,
        bool    dailyAvailable,
        bool    tierAvailable,
        bool    freeAvailable,
        uint256 tierOpensLeft,
        uint256 keyCost_,
        uint256 boxBalance_,
        uint256 lastOpenDay_,
        uint256 streakDays_
    ) {
        hasOpen        = hasOpenReady[user];
        dailyAvailable = lastOpenDay[user] < _utcDay();
        tierOpensLeft  = this.tierOpensRemaining(user);
        tierAvailable  = tierOpensLeft > 0;
        freeAvailable  = _meetsStreakThreshold(user) && dedicatedOpenMonth[user] < _utcMonth();
        keyCost_       = keyCost;
        boxBalance_    = IERC20(dividendToken).balanceOf(address(this));
        lastOpenDay_   = lastOpenDay[user];
        streakDays_    = _getStreakDays(user);
    }

    function boxBalance() external view returns (uint256) {
        return IERC20(dividendToken).balanceOf(address(this));
    }

    function keyBalance() external view returns (uint256) {
        return keyToken.balanceOf(address(this));
    }

    function plsBalance() external view returns (uint256) {
        return address(this).balance;
    }

    function getStats() external view returns (
        uint256 totalKeyLocked_,
        uint256 totalBoxesOpened_,
        uint256 totalDividendPaid_,
        uint256 currentBoxBalance_,
        uint256 currentKeyHeld_,
        uint256 currentPLSHeld_,
        uint256 totalPLSReceived_,
        uint256 totalKeySwapped_
    ) {
        return (
            totalKeyLocked, totalBoxesOpened, totalDividendPaid,
            IERC20(dividendToken).balanceOf(address(this)),
            keyToken.balanceOf(address(this)),
            address(this).balance,
            totalPLSReceived, totalKeySwapped
        );
    }

    // ── Admin ─────────────────────────────────────────────────────

    function setTierMonthlyOpens(uint8[4] calldata _opens) external onlyOwner {
        require(_opens[0] == 0, "Fan tier cannot have free opens");
        require(_opens[1] <= 10 && _opens[2] <= 10 && _opens[3] <= 10, "Max 10/month");
        tierMonthlyOpens = _opens;
        emit TierMonthlyOpensUpdated(_opens);
    }

    function setKeyCost(uint256 _cost) external onlyOwner {
        require(_cost > 0, "Invalid cost");
        emit KeyCostUpdated(keyCost, _cost);
        keyCost = _cost;
    }

    function setDividendToken(address _newToken) external onlyOwner {
        require(_newToken != address(0), "Invalid token");
        dividendToken = _newToken;
    }

    function setFreeOpenStreakDays(uint256 _days) external onlyOwner {
        freeOpenStreakDays = _days;
    }

    function setKeyStaking(address _ks) external onlyOwner {
        keyStaking = IKEYStaking(_ks);
        emit KeyStakingUpdated(_ks);
    }

    function setStreamingRewards(address _sr) external onlyOwner {
        streamingRewards = IStreamingRewards(_sr);
    }

    function setRngContract(address _rng) external onlyOwner {
        rngContract = IRNGContract(_rng);
        emit RngContractUpdated(_rng);
    }

    function setSwapThreshold(uint256 _threshold) external onlyOwner {
        require(_threshold > 0, "Invalid threshold");
        emit SwapThresholdUpdated(swapThreshold, _threshold);
        swapThreshold = _threshold;
    }

    function setMinSwapOutput(uint256 _min) external onlyOwner { minSwapOutput = _min; }
    function setMinBoxBalance(uint256 _min) external onlyOwner  { minBoxBalance  = _min; }

    function triggerSwap() external onlyOwner {
        _swapPLSForKEY();
    }

    function claimDividends() external {
        _claimPendingDividends();
    }

    /// @notice Rescue any token after migration is complete.
    ///         Unlike V2, KEY and dividend token ARE allowed here since
    ///         this is a last-resort owner function post-migration.
    function rescueToken(address token) external onlyOwner {
        uint256 bal = IERC20(token).balanceOf(address(this));
        require(bal > 0, "Nothing to rescue");
        require(IERC20(token).transfer(owner(), bal), "Transfer failed");
    }

    function rescuePLS() external onlyOwner {
        uint256 bal = address(this).balance;
        require(bal > 0, "No PLS");
        payable(owner()).transfer(bal);
    }
}