Skip to main content
PulseScanner.io

Address

0x728a3799bc3af8efada3f3fcbaf975ad7b1e9af2
Current Holdings
$0.7009
TXs sent
not counted
First Active
2026-04-01
block 26,172,228
Last Active
170 days ago
block 26,173,187
Funded By
not identified

Net worth historyi

30 snapshots · to block 27,522,596coverage change 26 Augcoverage change 27 Augcoverage change 27 Augcoverage change 27 Augcoverage change 27 Aug
exact matchStreamingRewardsV5solc 0.8.34+commit.80d5c536runtime exact · creation exact
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

// ─────────────────────────────────────────────────────────────────────────────
//  StreamingRewardsV5
//
//  ARCHITECTURE
//  ────────────
//  • Rate-based: every boosted second earns (baseRate × boostMultiplier) tokens
//  • Continuous accumulation: oracle pushes deltas every ~60s, user sees live balance
//  • Casino layer: on-chain RNG applies session multipliers (1x/2x/5x/10x)
//  • Proportional fallback: if pool is low, reward scales down gracefully — never stops
//  • Weekly jackpot: oracle settles weekly via distributeWeeklyJackpot()
//  • Fully frontend-friendly: getUserDashboard() returns everything in one call
//
//  ORACLE FLOW
//  ───────────
//  Every ~60s oracle calls updateStream(user, deltaSeconds, boostedDelta, token,
//                                       sessionId, nonce, signature)
//  Contract:
//    1. Verifies signature
//    2. Computes gross reward = boostedDelta × baseRate
//    3. Applies proportional fallback if pool low
//    4. Calls RNG for session multiplier (legendary/rare/uncommon/base)
//    5. Adds to pendingRewards[user][token]
//    6. Updates weeklyBoostedSeconds[user] (oracle uses for leaderboard)
//    7. Updates streak
//
//  USER CLAIM FLOW
//  ───────────────
//  User calls claim(token) or claimAll() at any time
//  Taxes applied on claim: noExpectationsTax → creatorAddress, incentiveBps → jackpotPool
// ─────────────────────────────────────────────────────────────────────────────

interface IKEYStaking {
    function getBoostBps(address user) external view returns (uint256 boostBps);
    function getTierId(address user) external view returns (uint8 tierId);
}

interface IRNG {
    function Generate() external returns (uint64);
}

contract StreamingRewardsV5 is Ownable, ReentrancyGuard {
    using SafeERC20 for IERC20;
    using ECDSA for bytes32;
    using MessageHashUtils for bytes32;

    // ─── Constants ───────────────────────────────────────────────────────────

    // ── Truly immutable (math/security primitives) ───────────────────────────
    uint256 public constant BPS_DENOMINATOR    = 10000;
    uint256 public constant SECONDS_PER_WEEK   = 604800;
    uint256 public constant MAX_TAX_BPS        = 5000;   // hard ceiling 50%

    // ── Configurable by owner after deployment ────────────────────────────────
    uint256 public signatureWindow    = 600;      // 10 min — setSignatureWindow()
    uint256 public oracleChangeDelay  = 172800;   // 48h    — setOracleChangeDelay()
    uint256 public runwayWarningSecs  = 86400;    // 24h    — setRunwayWarning()

    // RNG thresholds (out of 10000) — setRNGThresholds()
    uint256 public legendaryThreshold = 10;    // 0.1%  → legendaryMultiplierBps
    uint256 public rareThreshold      = 60;    // 0.5%  → rareMultiplierBps
    uint256 public uncommonThreshold  = 260;   // 2.0%  → uncommonMultiplierBps

    // RNG multipliers in bps (10000 = 1x) — setRNGMultipliers()
    uint256 public legendaryMultiplierBps = 100000; // 10x
    uint256 public rareMultiplierBps      = 50000;  // 5x
    uint256 public uncommonMultiplierBps  = 20000;  // 2x

    // ─── Token Pool ──────────────────────────────────────────────────────────

    struct TokenPool {
        uint256 baseRatePerSecond;  // tokens earned per boosted second (token decimals)
        uint256 balance;            // current pool balance
        uint256 totalFunded;        // all-time funded
        uint256 totalDistributed;   // all-time paid out
        uint8   decimals;
        bool    isActive;
    }

    address[]                      public supportedTokens;
    mapping(address => TokenPool)  public pools;
    mapping(address => bool)       public isSupported;

    // ─── User State ──────────────────────────────────────────────────────────

    // pendingRewards[user][token] — claimable balance accumulating on-chain
    mapping(address => mapping(address => uint256)) public pendingRewards;

    // weeklyBoostedSeconds[user] — resets each week, oracle uses for leaderboard
    mapping(address => uint256) public weeklyBoostedSeconds;

    // lifetimeEarned[user][token]
    mapping(address => mapping(address => uint256)) public lifetimeEarned;

    // totalLifetimeBoostedSeconds[user]
    mapping(address => uint256) public totalLifetimeBoostedSeconds;

    // nonces[user] — replay protection
    mapping(address => uint256) public userNonces;

    // preferredToken[user]
    mapping(address => address) public preferredToken;

    // ─── Streak ──────────────────────────────────────────────────────────────

    struct StreakInfo {
        uint256 streakDays;
        uint256 lastStreamDay;     // unix day (timestamp / 86400)
        uint256 streakMultiplierBps; // additional bps on top of base 10000
        uint256 shields;
        uint256 totalDaysStreamed;
    }
    mapping(address => StreakInfo) public streaks;

    // Streak multiplier phases (bps per day)
    uint256 public streakPhase1BpsPerDay = 100;  // days 1-7:   +1%/day
    uint256 public streakPhase2BpsPerDay = 50;   // days 8-30:  +0.5%/day
    uint256 public streakPhase3BpsPerDay = 25;   // days 31-180: +0.25%/day
    uint256 public streakMaxBps          = 5400; // cap at ~54% (day 180)

    // ─── Weekly Jackpot ──────────────────────────────────────────────────────

    uint256 public weeklyJackpotPool;       // MEFI accumulated this week
    uint256 public weekNumber;              // increments on settlement
    uint256 public lastJackpotSettlement;   // timestamp of last settlement
    uint256 public minJackpotSize;          // rolls over if below this

    // jackpot funded by incentive carve on claims
    address public jackpotToken;            // MEFI

    // Legend staker pool — % of jackpot split equally among active Legend stakers
    uint256 public legendPoolBps = 500;     // 5% default — setLegendPoolBps()
    address[] public legendStakerList;      // maintained by oracle via syncLegendStakers()
    mapping(address => bool) public isLegendStaker;
    uint8   public legendTierId = 3;        // tier ID for Legend in KEYStaking

    // ─── Taxes ───────────────────────────────────────────────────────────────

    uint256 public noExpectationsTaxBps = 1000; // 10% → creator MEFI
    uint256 public incentiveBps         = 500;  // 5%  → jackpot pool

    // creator tax: address is passed per updateStream call (from oracle)
    // incentive: goes to jackpotPool

    // ─── External Contracts ──────────────────────────────────────────────────

    IKEYStaking public keyStaking;
    IRNG        public rngContract;

    // ─── Oracle Signer (with timelock) ───────────────────────────────────────

    address public oracleSigner;
    address public pendingOracleSigner;
    uint256 public oracleSignerChangeTime;

    // ─── Pause ───────────────────────────────────────────────────────────────

    bool public paused;

    // ─── Events ──────────────────────────────────────────────────────────────

    event RewardAccumulated(
        address indexed user,
        address indexed token,
        uint256 baseAmount,
        uint256 finalAmount,
        uint256 multiplier,   // 10000 = 1x, 20000 = 2x, etc
        bool    isLegendary
    );
    event RewardClaimed(
        address indexed user,
        address indexed token,
        uint256 amount,
        address creatorAddress
    );
    event StreamUpdated(
        address indexed user,
        uint256 boostedDelta,
        uint256 weeklyTotal,
        uint256 streakDays
    );
    event LegendarySession(
        address indexed user,
        address indexed token,
        uint256 amount,
        uint256 sessionId
    );
    event WeeklyJackpotDistributed(
        address[] winners,
        uint256[] amounts,
        uint256   weekNumber,
        uint256   totalPaid
    );
    event WeeklyJackpotRolledOver(uint256 amount, uint256 newTotal);
    event JackpotFunded(uint256 amount, uint256 newTotal);
    event TokenAdded(address indexed token, uint256 baseRatePerSecond, uint8 decimals);
    event TokenRateChanged(address indexed token, uint256 oldRate, uint256 newRate);
    event TokenActiveChanged(address indexed token, bool isActive);
    event PoolFunded(address indexed token, uint256 amount, uint256 newBalance, uint256 runwaySeconds);
    event StreakUpdated(address indexed user, uint256 streakDays, uint256 multiplierBps);
    event StreakMilestone(address indexed user, uint256 day, uint256 shieldsEarned);
    event DedicatedListener(address indexed user, address indexed topArtist, uint256 streakDays, uint256 timestamp);
    event OracleSignerProposed(address indexed proposed, uint256 executeAfter);
    event OracleSignerChanged(address indexed oldSigner, address indexed newSigner);

    // ─── Constructor ─────────────────────────────────────────────────────────

    constructor(
        address _oracleSigner,
        address _keyStaking,
        address _rngContract,
        address _jackpotToken,
        address initialOwner
    ) Ownable(initialOwner) {
        oracleSigner  = _oracleSigner;
        keyStaking    = IKEYStaking(_keyStaking);
        rngContract   = IRNG(_rngContract);
        jackpotToken  = _jackpotToken;
        lastJackpotSettlement = block.timestamp;
        weekNumber    = 1;
        minJackpotSize = 1e18; // 1 MEFI minimum to pay out

        // ── Pre-register all supported tokens ─────────────────────────────────
        // Base rates are conservative placeholders (tokens per boosted second).
        // Adjust per-token post-deploy with setTokenRate() once pool sizes
        // and token prices are known. Target: meaningful reward per 3-min session.
        //
        // 8-decimal tokens:  1e4  / sec = 0.0001 tokens/sec
        // 18-decimal tokens: 1e14 / sec = 0.0001 tokens/sec
        // Both scale to ~0.018 tokens per 3-min session at base rate (no boost).

        _registerToken(0x92337f43FB462163869342E72538744E030EAF55, 1e4,  8);  // AER
        _registerToken(0x644F10dF242b43F3de45FCb3f6Ef8526fb5fdF71, 1e4,  8);  // KEY
        _registerToken(0x9A28F76c18eE03E65EA6703AbAec77c1b99ddF31, 1e14, 18); // SINEZ
        _registerToken(0xA1077a294dDE1B09bB078844df40758a5D0f9a27, 1e14, 18); // WPLS
        _registerToken(0x2b591e99afE9f32eAA6214f7B7629768c40Eeb39, 1e4,  8);  // PHEX
        _registerToken(0x95B303987A60C71504D99Aa1b13B4DA07b0790ab, 1e14, 18); // PLSX
        _registerToken(0x2fa878Ab3F87CC1C9737Fc071108F904c0B0C95d, 1e14, 18); // INC
        _registerToken(0x94534EeEe131840b1c0F61847c572228bdfDDE93, 1e14, 18); // PTGC
        _registerToken(0x456548A9B56eFBbD89Ca0309edd17a9E20b04018, 1e14, 18); // UFO
        _registerToken(0xcd1094C07F2dCF774cB0576E4E6C19c1319a3033, 1e14, 18); // RMX
        _registerToken(0xF6f8Db0aBa00007681F8fAF16A0FDa1c9B030b11, 1e14, 18); // PRVX
        _registerToken(0xec4252e62C6dE3D655cA9Ce3AfC12E553ebBA274, 1e14, 18); // PUMP
        _registerToken(0x6B175474E89094C44Da98b954EedeAC495271d0F, 1e14, 18); // PDAI
        _registerToken(0x57fde0a71132198BBeC939B98976993d8D89D225, 1e4,  8);  // EHEX
        _registerToken(0xefD766cCb38EaF1dfd701853BFCe31359239F305, 1e14, 18); // EDAI
        _registerToken(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599, 1e4,  8);  // PWBTC
        _registerToken(0xCc78A0acDF847A2C1714D2A925bB4477df5d48a6, 1e14, 18); // ATROPA
    }

    /// @dev Internal helper used only by constructor to register tokens without
    ///      the onlyOwner check (constructor runs as deployer = owner anyway).
    function _registerToken(address token, uint256 rate, uint8 decimals) internal {
        supportedTokens.push(token);
        isSupported[token] = true;
        pools[token] = TokenPool({
            baseRatePerSecond: rate,
            balance:           0,
            totalFunded:       0,
            totalDistributed:  0,
            decimals:          decimals,
            isActive:          true
        });
        emit TokenAdded(token, rate, decimals);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  ORACLE — updateStream
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * @notice Oracle calls this every ~60s with accumulated streaming delta.
     * @param user           Streamer address
     * @param deltaSeconds   Raw seconds streamed since last update
     * @param boostedDelta   Seconds × boostMultiplier (oracle applies KEY boost)
     * @param token          User's preferred reward token
     * @param creatorAddress Creator of currently playing track (for tax routing)
     * @param sessionId      Unique session ID for RNG seed
     * @param nonce          Next expected nonce for this user
     * @param signature      Oracle signature over packed params
     */
    function updateStream(
        address user,
        uint256 deltaSeconds,
        uint256 boostedDelta,
        address token,
        address creatorAddress,
        uint256 sessionId,
        uint256 nonce,
        bytes   calldata signature
    ) external nonReentrant {
        require(!paused, "Paused");
        require(isSupported[token], "Bad token");
        require(pools[token].isActive, "Inactive");
        require(boostedDelta > 0, "Zero delta");
        require(nonce == userNonces[user] + 1, "Bad nonce");

        _verifySignature(user, deltaSeconds, boostedDelta, token, creatorAddress, sessionId, nonce, signature);
        userNonces[user] = nonce;

        _processReward(user, token, boostedDelta, sessionId);

        weeklyBoostedSeconds[user]        += boostedDelta;
        totalLifetimeBoostedSeconds[user] += boostedDelta;

        _updateStreak(user, deltaSeconds);

        if (preferredToken[user] != token) preferredToken[user] = token;

        emit StreamUpdated(user, boostedDelta, weeklyBoostedSeconds[user], streaks[user].streakDays);
    }

    function _verifySignature(
        address user,
        uint256 deltaSeconds,
        uint256 boostedDelta,
        address token,
        address creatorAddress,
        uint256 sessionId,
        uint256 nonce,
        bytes calldata signature
    ) internal view {
        uint256 window = block.timestamp / signatureWindow;
        bytes32 msgHash = keccak256(abi.encode(
            user, deltaSeconds, boostedDelta,
            token, creatorAddress, sessionId,
            nonce, window
        ));
        address signer = msgHash.toEthSignedMessageHash().recover(signature);
        require(signer == oracleSigner, "Bad sig");
    }

    function _processReward(
        address user,
        address token,
        uint256 boostedDelta,
        uint256 sessionId
    ) internal {
        TokenPool storage pool = pools[token];
        uint256 grossReward = boostedDelta * pool.baseRatePerSecond;
        if (grossReward == 0) return;

        // Proportional fallback
        uint256 netReward = grossReward > pool.balance ? pool.balance : grossReward;
        if (netReward == 0) return;

        // RNG multiplier
        uint256 multiplierBps = 10000;
        bool isLegendary = false;
        if (address(rngContract) != address(0)) {
            try rngContract.Generate() returns (uint64 rand) {
                uint256 roll = uint256(rand) % 10000;
                if (roll < legendaryThreshold) {
                    multiplierBps = legendaryMultiplierBps;
                    isLegendary   = true;
                } else if (roll < rareThreshold) {
                    multiplierBps = rareMultiplierBps;
                } else if (roll < uncommonThreshold) {
                    multiplierBps = uncommonMultiplierBps;
                }
            } catch {}
        }

        uint256 finalReward = (netReward * multiplierBps) / 10000;
        if (finalReward > pool.balance) finalReward = pool.balance;
        if (finalReward == 0) return;

        pool.balance          -= finalReward;
        pool.totalDistributed += finalReward;
        pendingRewards[user][token] += finalReward;
        lifetimeEarned[user][token] += finalReward;

        emit RewardAccumulated(user, token, grossReward, finalReward, multiplierBps, isLegendary);
        if (isLegendary) emit LegendarySession(user, token, finalReward, sessionId);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  STREAK
    // ─────────────────────────────────────────────────────────────────────────

    function _updateStreak(address user, uint256 deltaSeconds) internal {
        if (deltaSeconds == 0) return;

        StreakInfo storage s = streaks[user];
        uint256 today = block.timestamp / 86400;

        if (s.lastStreamDay == today) return; // already counted today

        uint256 yesterday = today - 1;

        if (s.lastStreamDay == 0) {
            // First ever stream
            s.streakDays = 1;
            s.streakMultiplierBps = streakPhase1BpsPerDay;
        } else if (s.lastStreamDay == yesterday) {
            // Consecutive day
            s.streakDays++;
            s.streakMultiplierBps = _computeStreakBps(s.streakDays);
        } else {
            // Missed at least one day
            if (s.shields > 0) {
                // One shield absorbs one missed day — only works if exactly one day missed
                uint256 daysMissed = today - s.lastStreamDay - 1;
                if (daysMissed == 1) {
                    s.shields--;
                    s.streakDays++;
                    s.streakMultiplierBps = _computeStreakBps(s.streakDays);
                } else {
                    // Missed multiple days — shields can't help, streak resets
                    s.streakDays = 1;
                    s.streakMultiplierBps = streakPhase1BpsPerDay;
                }
            } else {
                // No shield — streak resets immediately
                s.streakDays = 1;
                s.streakMultiplierBps = streakPhase1BpsPerDay;
            }
        }

        s.lastStreamDay = today;
        s.totalDaysStreamed++;

        // Check milestones
        _checkStreakMilestones(user, s.streakDays);

        emit StreakUpdated(user, s.streakDays, s.streakMultiplierBps);
    }

    function _computeStreakBps(uint256 streakDay) internal view returns (uint256) {
        uint256 bps = 0;
        if (streakDay <= 7) {
            bps = streakDay * streakPhase1BpsPerDay;
        } else if (streakDay <= 30) {
            bps = 7 * streakPhase1BpsPerDay + (streakDay - 7) * streakPhase2BpsPerDay;
        } else {
            bps = 7 * streakPhase1BpsPerDay
                + 23 * streakPhase2BpsPerDay
                + (streakDay - 30) * streakPhase3BpsPerDay;
        }
        return bps > streakMaxBps ? streakMaxBps : bps;
    }

    function _checkStreakMilestones(address user, uint256 day) internal {
        StreakInfo storage s = streaks[user];
        // Milestone days earn a streak shield
        if (day == 7 || day == 30 || day == 90 || day == 180) {
            s.shields++;
            if (s.shields > 3) s.shields = 3; // max 3 shields
            emit StreakMilestone(user, day, 1);
        }
        // Day 180 — oracle calls recordDedicatedListener() separately
        // with the user's actual top artist computed from lifetime streaming data
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  CLAIM
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * @notice Claim accumulated rewards for a specific token.
     *         Called automatically by the oracle at session end via claimFor().
     *         Users can also call this manually at any time.
     * @param token          Token to claim
     * @param creatorAddress Creator address for tax routing (pass address(0) if none)
     */
    function claim(address token, address creatorAddress) external nonReentrant {
        require(!paused, "Paused");
        uint256 amount = pendingRewards[msg.sender][token];
        require(amount > 0, "Empty");

        pendingRewards[msg.sender][token] = 0;

        // Apply taxes
        uint256 creatorTax = (amount * noExpectationsTaxBps) / BPS_DENOMINATOR;
        uint256 incentiveTax = (amount * incentiveBps) / BPS_DENOMINATOR;
        uint256 userAmount = amount - creatorTax - incentiveTax;

        IERC20 tokenContract = IERC20(token);

        if (userAmount > 0) {
            tokenContract.safeTransfer(msg.sender, userAmount);
        }
        if (creatorTax > 0 && creatorAddress != address(0)) {
            tokenContract.safeTransfer(creatorAddress, creatorTax);
        }
        // incentiveTax stays in contract — oracle sweeps to MEFI
        // (same oracle flow as V4: detects IncentiveCarved event)

        emit RewardClaimed(msg.sender, token, userAmount, creatorAddress);
    }

    /**
     * @notice Claim all pending rewards across all supported tokens.
     */
    function claimAll(address creatorAddress) external nonReentrant {
        require(!paused, "Paused");
        for (uint256 i = 0; i < supportedTokens.length; i++) {
            address token = supportedTokens[i];
            uint256 amount = pendingRewards[msg.sender][token];
            if (amount == 0) continue;

            pendingRewards[msg.sender][token] = 0;

            uint256 creatorTax  = (amount * noExpectationsTaxBps) / BPS_DENOMINATOR;
            uint256 incentiveTax = (amount * incentiveBps) / BPS_DENOMINATOR;
            uint256 userAmount   = amount - creatorTax - incentiveTax;

            IERC20 tokenContract = IERC20(token);
            if (userAmount > 0) tokenContract.safeTransfer(msg.sender, userAmount);
            if (creatorTax > 0 && creatorAddress != address(0))
                tokenContract.safeTransfer(creatorAddress, creatorTax);

            emit RewardClaimed(msg.sender, token, userAmount, creatorAddress);
        }
    }

    /**
     * @notice Oracle calls this at session end to automatically push rewards
     *         to the user's wallet. Users never need to claim manually —
     *         this is called on their behalf when STREAM_STOPPED fires.
     * @param user           Streamer to pay out
     * @param token          Token to claim
     * @param creatorAddress Creator of the last track played (for tax routing)
     */
    function claimFor(
        address user,
        address token,
        address creatorAddress
    ) external nonReentrant {
        require(msg.sender == oracleSigner || msg.sender == owner(), "Auth");
        require(!paused, "Paused");
        uint256 amount = pendingRewards[user][token];
        if (amount == 0) return;

        pendingRewards[user][token] = 0;

        uint256 creatorTax   = (amount * noExpectationsTaxBps) / BPS_DENOMINATOR;
        uint256 incentiveTax = (amount * incentiveBps) / BPS_DENOMINATOR;
        uint256 userAmount   = amount - creatorTax - incentiveTax;

        IERC20 t = IERC20(token);
        if (userAmount > 0)  t.safeTransfer(user, userAmount);
        if (creatorTax > 0 && creatorAddress != address(0))
            t.safeTransfer(creatorAddress, creatorTax);

        emit RewardClaimed(user, token, userAmount, creatorAddress);
    }

    /**
     * @notice Oracle calls this to auto-claim all tokens for a user at session end.
     */
    function claimAllFor(
        address user,
        address creatorAddress
    ) external nonReentrant {
        require(msg.sender == oracleSigner || msg.sender == owner(), "Auth");
        require(!paused, "Paused");
        for (uint256 i = 0; i < supportedTokens.length; i++) {
            address token = supportedTokens[i];
            uint256 amount = pendingRewards[user][token];
            if (amount == 0) continue;

            pendingRewards[user][token] = 0;

            uint256 creatorTax   = (amount * noExpectationsTaxBps) / BPS_DENOMINATOR;
            uint256 incentiveTax = (amount * incentiveBps) / BPS_DENOMINATOR;
            uint256 userAmount   = amount - creatorTax - incentiveTax;

            IERC20 t = IERC20(token);
            if (userAmount > 0) t.safeTransfer(user, userAmount);
            if (creatorTax > 0 && creatorAddress != address(0))
                t.safeTransfer(creatorAddress, creatorTax);

            emit RewardClaimed(user, token, userAmount, creatorAddress);
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  WEEKLY JACKPOT — oracle-settled
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * @notice Oracle calls this each Sunday with the top streamers.
     *         Contract enforces the legend staker split on-chain —
     *         oracle only computes the competitive leaderboard portion.
     *
     *         Flow:
     *         1. legendPoolBps (5%) split equally among active Legend stakers
     *            — verified on-chain via KEYStaking.getTierId()
     *         2. Remaining 95% paid to top streamers per oracle-supplied amounts
     *
     * @param winners  Top streamer addresses in rank order (oracle-computed)
     * @param amounts  MEFI amounts for each winner (oracle-computed, from 95% pool)
     */
    function distributeWeeklyJackpot(
        address[] calldata winners,
        uint256[] calldata amounts
    ) external nonReentrant {
        require(msg.sender == oracleSigner || msg.sender == owner(), "Auth");
        require(winners.length == amounts.length, "Mismatch");
        require(winners.length > 0, "No winners");
        require(block.timestamp >= lastJackpotSettlement + 6 days, "Too soon");

        if (weeklyJackpotPool < minJackpotSize) {
            emit WeeklyJackpotRolledOver(weeklyJackpotPool, weeklyJackpotPool);
            lastJackpotSettlement = block.timestamp;
            weekNumber++;
            _resetWeeklySeconds(winners);
            return;
        }

        IERC20 mefi = IERC20(jackpotToken);

        // ── Legend staker split (on-chain enforced) ───────────────────────────
        uint256 legendPool = (weeklyJackpotPool * legendPoolBps) / BPS_DENOMINATOR;
        uint256 activeLegendCount = _countActiveLegendStakers();

        if (legendPool > 0 && activeLegendCount > 0) {
            uint256 perLegend = legendPool / activeLegendCount;
            if (perLegend > 0) {
                for (uint256 i = 0; i < legendStakerList.length; i++) {
                    address staker = legendStakerList[i];
                    if (!_isActiveLegend(staker)) continue;
                    weeklyJackpotPool -= perLegend;
                    mefi.safeTransfer(staker, perLegend);
                }
            }
        }

        // ── Competitive leaderboard payout ────────────────────────────────────
        uint256 totalPayout = 0;
        for (uint256 i = 0; i < amounts.length; i++) totalPayout += amounts[i];

        require(totalPayout <= weeklyJackpotPool, "Exceeds pool");

        for (uint256 i = 0; i < winners.length; i++) {
            if (amounts[i] > 0 && winners[i] != address(0)) {
                weeklyJackpotPool -= amounts[i];
                mefi.safeTransfer(winners[i], amounts[i]);
            }
        }

        emit WeeklyJackpotDistributed(winners, amounts, weekNumber, totalPayout);
        lastJackpotSettlement = block.timestamp;
        weekNumber++;
        _resetWeeklySeconds(winners);
    }

    function _countActiveLegendStakers() internal view returns (uint256 count) {
        for (uint256 i = 0; i < legendStakerList.length; i++) {
            if (_isActiveLegend(legendStakerList[i])) count++;
        }
    }

    function _isActiveLegend(address staker) internal view returns (bool) {
        if (address(keyStaking) == address(0)) return false;
        try keyStaking.getTierId(staker) returns (uint8 tierId) {
            return tierId == legendTierId;
        } catch {
            return false;
        }
    }

    function _resetWeeklySeconds(address[] calldata users) internal {
        for (uint256 i = 0; i < users.length; i++) {
            weeklyBoostedSeconds[users[i]] = 0;
        }
    }

    /**
     * @notice Oracle syncs the legend staker list on-chain.
     *         Called after scanning KEYStaking Staked/Restaked events.
     *         Contract re-verifies each address at payout time via getTierId.
     */
    function syncLegendStakers(address[] calldata stakers) external {
        require(msg.sender == oracleSigner || msg.sender == owner(), "Auth");
        // Clear and rebuild
        for (uint256 i = 0; i < legendStakerList.length; i++) {
            isLegendStaker[legendStakerList[i]] = false;
        }
        delete legendStakerList;
        for (uint256 i = 0; i < stakers.length; i++) {
            if (!isLegendStaker[stakers[i]]) {
                legendStakerList.push(stakers[i]);
                isLegendStaker[stakers[i]] = true;
            }
        }
    }

    /**
     * @notice Oracle calls this when a user reaches day 180 streak,
     *         passing their all-time most-streamed artist address.
     *         Oracle computes topArtist from its own lifetime creator tracking.
     *         Emits a permanent on-chain DedicatedListener record.
     */
    /**
     * @notice Oracle calls this to record (or update) a user's most-streamed artist.
     *         Can be called anytime — oracle tracks lifetime boosted seconds per
     *         creator per user and calls this when the top artist changes.
     *         Emits a permanent on-chain record of who this user listens to most.
     */
    function recordDedicatedListener(address user, address topArtist) external {
        require(msg.sender == oracleSigner || msg.sender == owner(), "Auth");
        require(topArtist != address(0), "Bad artist");
        emit DedicatedListener(user, topArtist, streaks[user].streakDays, block.timestamp);
    }

    /**
     * @notice Fund the weekly jackpot pool (oracle deposits MEFI here).
     *         All MEFI arriving via the creator tax flow goes here.
     *         This is the single MEFI pool — no separate incentive reserve.
     *         Streak milestones reward shields. RNG handles surprise bonuses.
     *         MEFI is reserved for weekly leaderboard winners only.
     */
    function fundJackpot(uint256 amount) external {
        IERC20(jackpotToken).safeTransferFrom(msg.sender, address(this), amount);
        weeklyJackpotPool += amount;
        emit JackpotFunded(amount, weeklyJackpotPool);
    }

    /**
     * @notice Drop-in replacement for V4's notifyMefiReceived().
     *         Oracle's existing watchMefiArrivals watcher can call this
     *         unchanged — no oracle code changes needed.
     *         Simply routes all incoming MEFI to the jackpot pool.
     */
    function notifyMefiReceived(uint256 amount) external {
        require(msg.sender == oracleSigner || msg.sender == owner(), "Auth");
        // MEFI already sitting in contract from transfer — just account for it
        weeklyJackpotPool += amount;
        emit JackpotFunded(amount, weeklyJackpotPool);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  TOKEN POOL MANAGEMENT
    // ─────────────────────────────────────────────────────────────────────────

    function addSupportedToken(
        address token,
        uint256 baseRatePerSecond,
        uint8   decimals
    ) external onlyOwner {
        require(!isSupported[token], "Exists");
        require(token != address(0), "Zero addr");
        require(baseRatePerSecond > 0, "Bad rate");

        supportedTokens.push(token);
        isSupported[token] = true;
        pools[token] = TokenPool({
            baseRatePerSecond: baseRatePerSecond,
            balance:           0,
            totalFunded:       0,
            totalDistributed:  0,
            decimals:          decimals,
            isActive:          true
        });

        emit TokenAdded(token, baseRatePerSecond, decimals);
    }

    function setTokenRate(address token, uint256 newRate) external onlyOwner {
        require(isSupported[token], "Not supported");
        require(newRate > 0, "Bad rate");
        uint256 old = pools[token].baseRatePerSecond;
        pools[token].baseRatePerSecond = newRate;
        emit TokenRateChanged(token, old, newRate);
    }

    function setTokenActive(address token, bool active) external onlyOwner {
        require(isSupported[token], "Not supported");
        pools[token].isActive = active;
        emit TokenActiveChanged(token, active);
    }

    function fundPool(address token, uint256 amount) external {
        require(isSupported[token], "Not supported");
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
        pools[token].balance      += amount;
        pools[token].totalFunded  += amount;

        uint256 runway = pools[token].baseRatePerSecond > 0
            ? pools[token].balance / pools[token].baseRatePerSecond
            : type(uint256).max;

        emit PoolFunded(token, amount, pools[token].balance, runway);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  FRONTEND-FRIENDLY VIEW FUNCTIONS
    // ─────────────────────────────────────────────────────────────────────────

    // getUserDashboard() — see StreamingRewardsV5Lens.sol
    // Extracted to keep main contract under 24KB limit.

        struct PoolStatus {
        address token;
        uint256 balance;
        uint256 baseRatePerSecond;
        uint256 runwaySeconds;      // balance / baseRate
        uint256 totalFunded;
        uint256 totalDistributed;
        uint8   decimals;
        bool    isActive;
        bool    runwayWarning;      // true if runway < 24h
    }

    // getAllPoolsStatus() — see StreamingRewardsV5Lens.sol

    
    /**
     * @notice Returns pools whose runway is below the warning threshold.
     *         BatchBuyer and oracle dashboard should poll this.
     */
    function getRunwayWarnings()
        external
        view
        returns (address[] memory warningTokens, uint256[] memory runwaySeconds)
    {
        uint256 n = supportedTokens.length;
        address[] memory tmpTokens  = new address[](n);
        uint256[] memory tmpRunways = new uint256[](n);
        uint256 count = 0;

        for (uint256 i = 0; i < n; i++) {
            address t = supportedTokens[i];
            TokenPool storage p = pools[t];
            if (!p.isActive) continue;
            uint256 runway = p.baseRatePerSecond > 0
                ? p.balance / p.baseRatePerSecond
                : type(uint256).max;
            if (runway < runwayWarningSecs) {
                tmpTokens[count]  = t;
                tmpRunways[count] = runway;
                count++;
            }
        }

        warningTokens  = new address[](count);
        runwaySeconds  = new uint256[](count);
        for (uint256 i = 0; i < count; i++) {
            warningTokens[i] = tmpTokens[i];
            runwaySeconds[i] = tmpRunways[i];
        }
    }

    /**
     * @notice What a user earns per second right now for a given token.
     *         baseRate × (1 + keyBoost)
     *         Frontend uses this to animate the live ticking balance.
     */
    function getEarningRate(address user, address token)
        external
        view
        returns (uint256 ratePerSecond)
    {
        if (!isSupported[token] || !pools[token].isActive) return 0;

        uint256 boostBps = 0;
        if (address(keyStaking) != address(0)) {
            try keyStaking.getBoostBps(user) returns (uint256 b) { boostBps = b; } catch {}
        }

        ratePerSecond = (pools[token].baseRatePerSecond * (BPS_DENOMINATOR + boostBps)) / BPS_DENOMINATOR;
    }

    // simulateSession() — see StreamingRewardsV5Lens.sol

    
    // getPoolInfo() V5-extended signature — see StreamingRewardsV5Lens.sol
    // V4-compatible getPoolInfo is in the BATCHBUYER COMPATIBILITY section below.

    /**
     * @notice Returns the streak info for a user.
     */
    function getStreakInfo(address user) external view returns (
        uint256 streakDays,
        uint256 streakMultiplierBps,
        uint256 lastStreamDay,
        uint256 shields,
        uint256 totalDaysStreamed
    ) {
        StreakInfo storage s = streaks[user];
        return (s.streakDays, s.streakMultiplierBps, s.lastStreamDay, s.shields, s.totalDaysStreamed);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  BATCHBUYER COMPATIBILITY — V4-compatible interface
    //  GeniusBatchBuyer reads these exact signatures. Do not rename.
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * @notice V4-compatible getPoolInfo for BatchBuyer compatibility.
     *         Maps V5 fields to V4 return layout:
     *         balance         → pools[token].balance
     *         totalDeposited  → pools[token].totalFunded
     *         totalDistributed→ pools[token].totalDistributed
     *         distributionRate→ pools[token].baseRatePerSecond
     *         isActive        → pools[token].isActive
     *         nextDistribution→ runway in seconds (balance / baseRate)
     *         decimals        → pools[token].decimals
     */
    function getPoolInfo(address token) external view returns (
        uint256 balance,
        uint256 totalDeposited,
        uint256 totalDistributed,
        uint256 distributionRate,
        bool    isActive,
        uint256 nextDistribution,
        uint8   decimals
    ) {
        TokenPool storage p = pools[token];
        uint256 runway = p.baseRatePerSecond > 0
            ? p.balance / p.baseRatePerSecond
            : type(uint256).max;
        return (
            p.balance,
            p.totalFunded,
            p.totalDistributed,
            p.baseRatePerSecond,
            p.isActive,
            runway,
            p.decimals
        );
    }

    /**
     * @notice V4-compatible isTokenSupported for BatchBuyer compatibility.
     */
    function isTokenSupported(address token) external view returns (bool) {
        return isSupported[token];
    }

    /**
     * @notice getAllSupportedTokens — already used by BatchBuyer.
     */
    function getAllSupportedTokens() external view returns (address[] memory) {
        return supportedTokens;
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  ADMIN
    // ─────────────────────────────────────────────────────────────────────────

    // ── RNG & Threshold Config ────────────────────────────────────────────────

    function setRNGThresholds(
        uint256 _legendaryThreshold,
        uint256 _rareThreshold,
        uint256 _uncommonThreshold
    ) external onlyOwner {
        require(_legendaryThreshold < _rareThreshold, "Bad thresh");
        require(_rareThreshold < _uncommonThreshold, "Bad thresh");
        require(_uncommonThreshold < 10000, "Must be < 10000");
        legendaryThreshold = _legendaryThreshold;
        rareThreshold      = _rareThreshold;
        uncommonThreshold  = _uncommonThreshold;
    }

    function setRNGMultipliers(
        uint256 _legendaryBps,
        uint256 _rareBps,
        uint256 _uncommonBps
    ) external onlyOwner {
        require(_legendaryBps >= _rareBps && _rareBps >= _uncommonBps, "Bad order");
        require(_uncommonBps >= 10000, "Uncommon<1x");
        legendaryMultiplierBps = _legendaryBps;
        rareMultiplierBps      = _rareBps;
        uncommonMultiplierBps  = _uncommonBps;
    }

    // ── Timing Config ─────────────────────────────────────────────────────────

    function setSignatureWindow(uint256 _seconds) external onlyOwner {
        require(_seconds >= 60 && _seconds <= 3600, "Bad window");
        signatureWindow = _seconds;
    }

    function setOracleChangeDelay(uint256 _seconds) external onlyOwner {
        require(_seconds >= 3600, "Bad delay");
        oracleChangeDelay = _seconds;
    }

    function setRunwayWarning(uint256 _seconds) external onlyOwner {
        runwayWarningSecs = _seconds;
    }

    // ── Taxes ─────────────────────────────────────────────────────────────────

    function setTaxes(uint256 _noExpectationsBps, uint256 _incentiveBps) external onlyOwner {
        require(_noExpectationsBps + _incentiveBps <= MAX_TAX_BPS, "Exceeds max tax");
        noExpectationsTaxBps = _noExpectationsBps;
        incentiveBps         = _incentiveBps;
    }

    function setKeyStaking(address _keyStaking) external onlyOwner {
        keyStaking = IKEYStaking(_keyStaking);
    }

    function setRNGContract(address _rng) external onlyOwner {
        rngContract = IRNG(_rng);
    }

    function setJackpotToken(address _token) external onlyOwner {
        jackpotToken = _token;
    }

    function setMinJackpotSize(uint256 _min) external onlyOwner {
        minJackpotSize = _min;
    }

    function setLegendPoolBps(uint256 _bps) external onlyOwner {
        require(_bps <= 2000, "Max 20pct");
        legendPoolBps = _bps;
    }

    function setLegendTierId(uint8 _tierId) external onlyOwner {
        legendTierId = _tierId;
    }

    function setStreakParams(
        uint256 phase1Bps,
        uint256 phase2Bps,
        uint256 phase3Bps,
        uint256 maxBps
    ) external onlyOwner {
        streakPhase1BpsPerDay = phase1Bps;
        streakPhase2BpsPerDay = phase2Bps;
        streakPhase3BpsPerDay = phase3Bps;
        streakMaxBps          = maxBps;
    }

    function setPaused(bool _paused) external onlyOwner {
        paused = _paused;
    }

    // Oracle signer with 48h timelock
    function proposeOracleSignerChange(address newSigner) external onlyOwner {
        require(newSigner != address(0), "Zero addr");
        pendingOracleSigner   = newSigner;
        oracleSignerChangeTime = block.timestamp + oracleChangeDelay;
        emit OracleSignerProposed(newSigner, oracleSignerChangeTime);
    }

    function executeOracleSignerChange() external onlyOwner {
        require(pendingOracleSigner != address(0), "No change");
        require(block.timestamp >= oracleSignerChangeTime, "Locked");
        address old = oracleSigner;
        oracleSigner        = pendingOracleSigner;
        pendingOracleSigner = address(0);
        emit OracleSignerChanged(old, oracleSigner);
    }

    function cancelOracleSignerChange() external onlyOwner {
        pendingOracleSigner = address(0);
    }

    // Rescue stuck tokens (cannot rescue active pool tokens)
    function rescueToken(address token, uint256 amount) external onlyOwner {
        // Allow rescue of pool tokens only if pool is inactive
        if (isSupported[token]) {
            require(!pools[token].isActive, "Deactivate pool first");
        }
        IERC20(token).safeTransfer(owner(), amount);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  MIGRATION — import state from V4 or any prior version
    //
    //  All migration functions are owner-only and gated by migrationOpen flag.
    //  Call setMigrationOpen(false) once migration is complete to lock forever.
    //  Migration never touches pool balances — only user state.
    // ─────────────────────────────────────────────────────────────────────────

    bool public migrationOpen = true;

    event MigrationComplete(uint256 usersImported, uint256 timestamp);
    event MigrationLocked();

    modifier onlyDuringMigration() {
        require(migrationOpen, "Locked");
        require(msg.sender == owner(), "Auth");
        _;
    }

    /**
     * @notice Lock migration permanently. Cannot be reopened.
     */
    function lockMigration() external onlyOwner {
        migrationOpen = false;
        emit MigrationLocked();
    }

    /**
     * @notice Migrate pending rewards for a batch of users.
     *         Call with V4 pendingRewards state before users claim from V5.
     */
    function migrateRewards(
        address[] calldata users,
        address[] calldata tokens,
        uint256[] calldata amounts
    ) external onlyDuringMigration {
        require(users.length == tokens.length && tokens.length == amounts.length, "Mismatch");
        for (uint256 i = 0; i < users.length; i++) {
            require(isSupported[tokens[i]], "Bad token");
            pendingRewards[users[i]][tokens[i]] += amounts[i];
            lifetimeEarned[users[i]][tokens[i]] += amounts[i];
        }
    }

    /**
     * @notice Migrate user nonces from V4 to prevent replay attacks on existing sigs.
     */
    function migrateNonces(
        address[] calldata users,
        uint256[] calldata nonces
    ) external onlyDuringMigration {
        require(users.length == nonces.length, "Mismatch");
        for (uint256 i = 0; i < users.length; i++) {
            // Only allow increasing nonces — never decrease
            if (nonces[i] > userNonces[users[i]]) {
                userNonces[users[i]] = nonces[i];
            }
        }
    }

    /**
     * @notice Migrate preferred token settings from V4.
     */
    function migratePreferences(
        address[] calldata users,
        address[] calldata tokens
    ) external onlyDuringMigration {
        require(users.length == tokens.length, "Mismatch");
        for (uint256 i = 0; i < users.length; i++) {
            if (isSupported[tokens[i]]) {
                preferredToken[users[i]] = tokens[i];
            }
        }
    }

    /**
     * @notice Migrate lifetime streaming seconds from V4.
     *         Used to preserve leaderboard history and DedicatedListener tracking.
     */
    function migrateStreamingHistory(
        address[] calldata users,
        uint256[] calldata lifetimeSecs,
        uint256[] calldata weeklySecs
    ) external onlyDuringMigration {
        require(
            users.length == lifetimeSecs.length &&
            users.length == weeklySecs.length,
            "Mismatch"
        );
        for (uint256 i = 0; i < users.length; i++) {
            totalLifetimeBoostedSeconds[users[i]] += lifetimeSecs[i];
            weeklyBoostedSeconds[users[i]]        += weeklySecs[i];
        }
    }

    /**
     * @notice Migrate pool balances from V4.
     *         Caller must have approved this contract to spend the tokens.
     *         Typically called after transferring V4 pool balances to owner wallet.
     */
    function migratePoolBalance(address token, uint256 amount) external onlyDuringMigration {
        require(isSupported[token], "Bad token");
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
        pools[token].balance     += amount;
        pools[token].totalFunded += amount;
        emit PoolFunded(token, amount, pools[token].balance,
            pools[token].baseRatePerSecond > 0
                ? pools[token].balance / pools[token].baseRatePerSecond
                : type(uint256).max
        );
    }

    /**
     * @notice Convenience: migrate everything for a batch of users in one tx.
     *         Combines rewards + nonces + preferences + history.
     */
    struct UserMigrationData {
        address user;
        address preferredTokenAddr;
        uint256 nonce;
        uint256 lifetimeStreamSecs;
        uint256 weeklyStreamSecs;
        address[] rewardTokens;
        uint256[] rewardAmounts;
    }

    function migrateBatch(UserMigrationData[] calldata data) external onlyDuringMigration {
        for (uint256 i = 0; i < data.length; i++) {
            UserMigrationData calldata d = data[i];

            // Nonce
            if (d.nonce > userNonces[d.user]) {
                userNonces[d.user] = d.nonce;
            }

            // Preferred token
            if (d.preferredTokenAddr != address(0) && isSupported[d.preferredTokenAddr]) {
                preferredToken[d.user] = d.preferredTokenAddr;
            }

            // Streaming history
            totalLifetimeBoostedSeconds[d.user] += d.lifetimeStreamSecs;
            weeklyBoostedSeconds[d.user]        += d.weeklyStreamSecs;

            // Pending rewards per token
            require(d.rewardTokens.length == d.rewardAmounts.length, "Mismatch");
            for (uint256 j = 0; j < d.rewardTokens.length; j++) {
                if (isSupported[d.rewardTokens[j]] && d.rewardAmounts[j] > 0) {
                    pendingRewards[d.user][d.rewardTokens[j]] += d.rewardAmounts[j];
                    lifetimeEarned[d.user][d.rewardTokens[j]] += d.rewardAmounts[j];
                }
            }
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  SYSTEM STATUS
    // ─────────────────────────────────────────────────────────────────────────

    function getSystemStatus() external view returns (
        uint256 totalPools,
        uint256 activePools,
        uint256 jackpotBalance,
        uint256 nextJackpotTime,
        uint256 currentWeek,
        bool    isPaused
    ) {
        uint256 active = 0;
        for (uint256 i = 0; i < supportedTokens.length; i++) {
            if (pools[supportedTokens[i]].isActive) active++;
        }
        return (
            supportedTokens.length,
            active,
            weeklyJackpotPool,
            lastJackpotSettlement + SECONDS_PER_WEEK,
            weekNumber,
            paused
        );
    }
}