Address
0x728a3799bc3af8efada3f3fcbaf975ad7b1e9af2Current 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
);
}
}