Skip to main content
PulseScanner.io

Address

0x00a52f8564d6a43153fbc7682bc6a61bbb26f9bc
Current Holdings
$0.00
TXs sent
0
First Active
2026-06-27
block 26,886,338
Last Active
today
block 27,544,261
Funded By
not identified

Net worth historyi

119 snapshots · to block 27,544,262coverage change 26 Augcoverage change 27 Augcoverage change 27 Augcoverage change 27 Augcoverage change 27 Aug
exact matchTetraStakingsolc 0.8.24+commit.e11b9ed9runtime exact · creation exact
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";

/// @title TetraStaking
/// @notice Flexible (no-lock) staking of $TETRAp on PulseChain that distributes
///         multi-token revenue rewards (e.g. WPLS, DAI) to stakers on a weekly cycle.
/// @dev Immutable, not upgradeable, NOT pausable, Ownable2Step. The owner can ONLY
///      add/remove whitelist tokens and set the funding contract — no pause, no
///      rescue; staked principal and owed rewards are never touchable by the owner.
///
/// Reward accounting is a MasterChef-style per-token accumulator (`accRewardPerShare`)
/// over *eligible* stake, advanced only at weekly boundaries inside _settle. New stake
/// waits one epoch in a `pending` bucket before becoming eligible. The accumulator is
/// stored as a sparse Checkpoints trace keyed by epoch, so (a) settling a long dormancy
/// gap costs O(elapsed) cheap reads rather than O(elapsed) storage writes, and (b) an
/// inactive staker's pending stake can be credited from the exact accumulator value at
/// the epoch it became eligible. Undistributed integer-division remainders are carried
/// forward (never stranded). claimable() faithfully simulates _settle so the view never
/// disagrees with claim().
contract TetraStaking is Ownable2Step, ReentrancyGuard {
    using SafeERC20 for IERC20;
    using Checkpoints for Checkpoints.Trace256;

    // ----------------------------- Constants ---------------------------- //

    uint256 public constant WEEK = 7 days;
    uint256 private constant PRECISION = 1e18;
    uint256 public constant MAX_SUPPORTED_TOKENS = 20;
    uint256 public constant MAX_REWARD_TOKENS = 20;
    /// @dev Caps how far in the past `genesis` may be set at deploy, bounding the
    ///      initial epoch so the very first interaction can never be a gas bomb.
    uint256 public constant MAX_GENESIS_BACKDATE = 2 * WEEK;
    /// @dev Caps how far in the FUTURE `genesis` may be set at deploy. `genesis` is
    ///      immutable with no setter, so a fat-fingered far-future value would silently
    ///      disable all payouts forever; this bounds the launch window to ~1 year.
    uint256 public constant MAX_GENESIS_FORWARD = 52 * WEEK;

    // ----------------------------- Immutables --------------------------- //

    IERC20 public immutable stakingToken; // $TETRAp — principal only, never a reward token
    uint256 public immutable genesis; // a Friday 17:00 UTC anchor; payouts at genesis + k*WEEK

    // ------------------------------- Config ----------------------------- //

    address public fundingContract; // the only address allowed to deposit rewards

    // -------------------------- Token registries ------------------------ //

    address[] private _supportedTokens; // active whitelist (deposit gate)
    mapping(address => bool) public isSupportedToken;
    address[] private _rewardTokens; // every token ever funded (append-only; claims survive de-whitelisting)
    mapping(address => bool) private _isRewardToken;

    // ----------------------------- Stake state -------------------------- //

    struct UserInfo {
        uint256 eligibleStake; // earns the upcoming payout
        uint256 pendingStake; // staked this epoch; eligible at `pendingEligibleEpoch`
        uint256 pendingEligibleEpoch; // epoch at which pendingStake promotes to eligible
    }

    mapping(address => UserInfo) public users;
    uint256 public totalEligibleStake;
    uint256 public totalPendingStake;

    // ----------------------------- Reward state ------------------------- //

    mapping(address => Checkpoints.Trace256) private _acc; // token => acc-per-share trace (key=epoch, value scaled by PRECISION); uint256 value cannot overflow
    mapping(address => uint256) public currentEpochPool; // token => rewards awaiting distribution
    mapping(address => uint256) public totalDeposited; // token => lifetime deposited
    mapping(address => uint256) public totalClaimed; // token => lifetime claimed

    mapping(address => mapping(address => uint256)) public rewardDebt; // user => token => debt
    mapping(address => mapping(address => uint256)) public storedClaimable; // user => token => settled, unclaimed

    uint256 public lastSettledEpoch; // highest weekly boundary global settlement has processed

    // ------------------------------- Events ----------------------------- //

    event Staked(address indexed user, uint256 amount, uint256 eligibleEpoch);
    event Unstaked(address indexed user, uint256 amount);
    event RewardsClaimed(address indexed user, address indexed token, uint256 amount);
    event RewardsDeposited(address indexed funder, address indexed token, uint256 amount, uint256 epoch);
    event Settled(uint256 indexed toEpoch);
    event SupportedTokenAdded(address indexed token);
    event SupportedTokenRemoved(address indexed token);
    event FundingContractChanged(address indexed previous, address indexed current);

    // ------------------------------- Errors ----------------------------- //

    error ZeroAmount();
    error ZeroAddress();
    error InvalidToken();
    error InvalidGenesis();
    error InvalidFundingContract();
    error TokenAlreadySupported();
    error TokenNotSupported();
    error TooManySupportedTokens();
    error TooManyRewardTokens();
    error NotFundingContract();
    error InsufficientStake();
    error NotSelf();

    // ----------------------------- Constructor -------------------------- //

    constructor(
        address _stakingToken,
        uint256 _genesis,
        address _fundingContract,
        address _owner,
        address[] memory _initialTokens
    ) Ownable(_owner) {
        if (_stakingToken == address(0) || _fundingContract == address(0)) revert ZeroAddress();
        // genesis must be non-zero, not back-dated beyond the cap (prevents a
        // misconfigured deploy from creating an unbounded initial settlement gap), and
        // not set absurdly far in the future (which would permanently disable payouts).
        if (
            _genesis == 0 || _genesis + MAX_GENESIS_BACKDATE < block.timestamp
                || _genesis > block.timestamp + MAX_GENESIS_FORWARD
        ) revert InvalidGenesis();
        if (_fundingContract == _stakingToken) revert InvalidFundingContract();

        stakingToken = IERC20(_stakingToken);
        genesis = _genesis;
        fundingContract = _fundingContract;
        emit FundingContractChanged(address(0), _fundingContract);

        uint256 n = _initialTokens.length;
        for (uint256 i; i < n; ++i) {
            _addSupportedToken(_initialTokens[i]);
        }
    }

    // ------------------------- User: stake / unstake -------------------- //

    /// @notice Stake `amount` of $TETRAp. New stake becomes eligible at the next
    ///         weekly boundary (it "rolls" to next week if past this week's cutoff).
    function stake(uint256 amount) external nonReentrant {
        if (amount == 0) revert ZeroAmount();
        _settle();
        _harvest(msg.sender);

        uint256 before = stakingToken.balanceOf(address(this));
        stakingToken.safeTransferFrom(msg.sender, address(this), amount);
        uint256 received = stakingToken.balanceOf(address(this)) - before;
        if (received == 0) revert ZeroAmount();

        UserInfo storage u = users[msg.sender];
        uint256 eligEpoch = _eligibleEpochForNow();
        if (eligEpoch <= lastSettledEpoch) {
            // pre-genesis stake: eligible immediately
            u.eligibleStake += received;
            totalEligibleStake += received;
        } else {
            u.pendingStake += received;
            u.pendingEligibleEpoch = eligEpoch;
            totalPendingStake += received;
        }
        _resetDebt(msg.sender);
        emit Staked(msg.sender, received, eligEpoch);
    }

    /// @notice Unstake `amount` of $TETRAp. Removed from PENDING (not-yet-earning)
    ///         stake first, then from eligible stake — so a user pulling only their
    ///         brand-new stake keeps their eligible rewards; removing eligible stake
    ///         proportionally forfeits the current week's reward to those still staked.
    function unstake(uint256 amount) external nonReentrant {
        _unstake(msg.sender, amount);
    }

    function _unstake(address user, uint256 amount) private {
        if (amount == 0) revert ZeroAmount();
        _settle();
        _harvest(user);

        UserInfo storage u = users[user];
        if (amount > u.eligibleStake + u.pendingStake) revert InsufficientStake();

        uint256 fromPending = amount <= u.pendingStake ? amount : u.pendingStake;
        uint256 fromEligible = amount - fromPending;
        if (fromPending != 0) {
            u.pendingStake -= fromPending;
            totalPendingStake -= fromPending;
        }
        if (fromEligible != 0) {
            u.eligibleStake -= fromEligible;
            totalEligibleStake -= fromEligible;
        }
        _resetDebt(user);
        stakingToken.safeTransfer(user, amount);
        emit Unstaked(user, amount);
    }

    // ----------------------------- User: claim -------------------------- //

    /// @notice Claim all accrued rewards. A reward token whose transfer reverts
    ///         (e.g. a blacklisting token) is skipped — it never blocks the others.
    function claim() external nonReentrant {
        _settle();
        _harvest(msg.sender);
        uint256 elig = users[msg.sender].eligibleStake;
        address[] memory toks = _rewardTokens;
        uint256 n = toks.length;
        for (uint256 i; i < n; ++i) {
            address t = toks[i];
            rewardDebt[msg.sender][t] = Math.mulDiv(elig, _curAcc(t), PRECISION); // reset debt (overflow-safe)
            _tryPayout(msg.sender, t); // skip-on-failure
        }
    }

    /// @notice Claim accrued rewards of a single token (reverts if that token's transfer fails).
    function claim(address token) external nonReentrant {
        _settle();
        _harvest(msg.sender);
        _resetDebt(msg.sender);
        uint256 amount = storedClaimable[msg.sender][token];
        if (amount != 0) {
            uint256 bal = IERC20(token).balanceOf(address(this));
            uint256 pay = amount <= bal ? amount : bal; // cap at available (no hard revert on a dust shortfall)
            if (pay != 0) {
                storedClaimable[msg.sender][token] = amount - pay;
                totalClaimed[token] += pay;
                IERC20(token).safeTransfer(msg.sender, pay);
                emit RewardsClaimed(msg.sender, token, pay);
            }
        }
    }

    /// @notice Claim everything (skip-on-failure) and unstake the full principal in one call.
    function exit() external nonReentrant {
        _settle();
        _harvest(msg.sender);
        UserInfo storage u = users[msg.sender];
        uint256 principal = u.eligibleStake + u.pendingStake;
        if (u.eligibleStake != 0) totalEligibleStake -= u.eligibleStake;
        if (u.pendingStake != 0) totalPendingStake -= u.pendingStake;
        u.eligibleStake = 0;
        u.pendingStake = 0;

        address[] memory toks = _rewardTokens;
        uint256 n = toks.length;
        for (uint256 i; i < n; ++i) {
            rewardDebt[msg.sender][toks[i]] = 0;
            _tryPayout(msg.sender, toks[i]);
        }
        if (principal != 0) {
            stakingToken.safeTransfer(msg.sender, principal);
            emit Unstaked(msg.sender, principal);
        }
    }

    // ----------------------- Funding contract: deposit ------------------ //

    /// @notice Fund the rewards pool. Callable ONLY by `fundingContract`, only for
    ///         whitelisted tokens. Distributed to eligible stakers at the next boundary.
    function depositRewards(address token, uint256 amount) external nonReentrant {
        if (msg.sender != fundingContract) revert NotFundingContract();
        if (!isSupportedToken[token]) revert TokenNotSupported();
        if (amount == 0) revert ZeroAmount();
        _settle();

        if (!_isRewardToken[token]) {
            if (_rewardTokens.length >= MAX_REWARD_TOKENS) revert TooManyRewardTokens();
            _isRewardToken[token] = true;
            _rewardTokens.push(token);
        }

        uint256 before = IERC20(token).balanceOf(address(this));
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
        uint256 received = IERC20(token).balanceOf(address(this)) - before;

        currentEpochPool[token] += received;
        totalDeposited[token] += received;
        emit RewardsDeposited(msg.sender, token, received, lastSettledEpoch);
    }

    // -------------------------- Owner: the only admin ------------------- //

    function addSupportedToken(address token) external onlyOwner {
        _addSupportedToken(token);
    }

    function removeSupportedToken(address token) external onlyOwner {
        if (!isSupportedToken[token]) revert TokenNotSupported();
        isSupportedToken[token] = false;
        uint256 n = _supportedTokens.length;
        for (uint256 i; i < n; ++i) {
            if (_supportedTokens[i] == token) {
                _supportedTokens[i] = _supportedTokens[n - 1];
                _supportedTokens.pop();
                break;
            }
        }
        emit SupportedTokenRemoved(token);
    }

    function setFundingContract(address newFundingContract) external onlyOwner {
        if (newFundingContract == address(0) || newFundingContract == address(this)
            || newFundingContract == address(stakingToken)) revert InvalidFundingContract();
        emit FundingContractChanged(fundingContract, newFundingContract);
        fundingContract = newFundingContract;
    }

    // ------------------------------- Settlement ------------------------- //

    /// @notice Permissionless: advance settlement to the current epoch. Useful to keep
    ///         a dormant contract current; not required for correctness.
    function poke() external {
        _settle();
    }

    /// @dev Roll global state forward across every elapsed weekly boundary. At each
    ///      boundary: distribute the pool over eligible stake (carrying the integer
    ///      remainder forward; carrying the whole pool if there is no eligible stake),
    ///      checkpoint the accumulator only when it actually changes, then promote
    ///      pending stake. Storage writes happen only on real distributions/promotions,
    ///      so settling a long gap is O(elapsed) cheap reads, not O(elapsed) writes.
    function _settle() internal {
        uint256 epoch = _currentEpoch();
        uint256 last = lastSettledEpoch;
        if (epoch <= last) return;

        address[] memory toks = _rewardTokens;
        uint256 nTokens = toks.length;

        for (uint256 k = last + 1; k <= epoch; ++k) {
            bool didWork = false;
            uint256 te = totalEligibleStake;
            if (te != 0) {
                for (uint256 i; i < nTokens; ++i) {
                    address t = toks[i];
                    uint256 pool = currentEpochPool[t];
                    if (pool == 0) continue;
                    uint256 cur = _curAcc(t);
                    // mulDiv: full 512-bit intermediate so a huge pool can't spuriously
                    // overflow `pool * PRECISION` and brick the non-pausable contract.
                    uint256 add = Math.mulDiv(pool, PRECISION, te);
                    if (add == 0) continue; // too small to distribute this round — carry forward
                    uint256 distributed = Math.mulDiv(add, te, PRECISION); // <= pool
                    if (distributed == 0) continue; // would move zero tokens — carry whole pool, never inflate acc
                    _acc[t].push(k, cur + add);
                    currentEpochPool[t] = pool - distributed; // carry the remainder
                    didWork = true;
                }
            }
            // promote pending → eligible for the new epoch
            if (totalPendingStake != 0) {
                totalEligibleStake += totalPendingStake;
                totalPendingStake = 0;
                didWork = true;
            }
            // Once a boundary neither distributes nor promotes, eligible stake and the
            // pools are stable, so every remaining boundary is a no-op. Stop the loop
            // (the skipped epochs change nothing) — this bounds settlement gas to O(1)
            // in steady state, defusing any dormancy gas bomb.
            if (!didWork) break;
        }
        lastSettledEpoch = epoch;
        emit Settled(epoch);
    }

    /// @dev Move a user's accrued rewards (eligible earnings + any now-due pending
    ///      promotion) into storedClaimable, and promote their pending stake. Does NOT
    ///      reset rewardDebt — callers follow with _resetDebt (or set debt themselves).
    function _harvest(address user) internal {
        UserInfo storage u = users[user];
        address[] memory toks = _rewardTokens;
        uint256 nTokens = toks.length;
        uint256 elig = u.eligibleStake;
        uint256 pending = u.pendingStake;
        uint256 pe = u.pendingEligibleEpoch;
        bool promote = pending != 0 && lastSettledEpoch >= pe;

        for (uint256 i; i < nTokens; ++i) {
            address t = toks[i];
            uint256 cur = _curAcc(t);
            uint256 accrued = Math.mulDiv(elig, cur, PRECISION) - rewardDebt[user][t];
            if (promote) {
                accrued += Math.mulDiv(pending, cur - _accAt(t, pe), PRECISION);
            }
            if (accrued != 0) storedClaimable[user][t] += accrued;
        }
        if (promote) {
            u.eligibleStake = elig + pending;
            u.pendingStake = 0;
        }
    }

    function _resetDebt(address user) internal {
        uint256 elig = users[user].eligibleStake;
        address[] memory toks = _rewardTokens;
        uint256 nTokens = toks.length;
        for (uint256 i; i < nTokens; ++i) {
            address t = toks[i];
            rewardDebt[user][t] = Math.mulDiv(elig, _curAcc(t), PRECISION);
        }
    }

    /// @dev Pay a user's stored claimable for one token, skipping it (no state change)
    ///      if EITHER the balance read OR the transfer reverts — so one bad reward token
    ///      cannot brick a batch claim()/exit(). The balanceOf read AND the transfer run
    ///      inside the self-call wrapped by try/catch; the previous version read balanceOf
    ///      OUTSIDE the guard, so a balanceOf-reverting token (self-destructed / paused /
    ///      reverting-proxy) could brick the whole loop. Accounting updates only on a real
    ///      payout, so a skipped token stays fully claimable later.
    function _tryPayout(address user, address token) private {
        uint256 amount = storedClaimable[user][token];
        if (amount == 0) return;
        try this.__capTransferReward(token, user, amount) returns (uint256 paid) {
            if (paid != 0) {
                storedClaimable[user][token] = amount - paid; // keep any un-payable wei claimable later
                totalClaimed[token] += paid;
                emit RewardsClaimed(user, token, paid);
            }
        } catch {
            // balance read or transfer reverted — skip this token, leave it fully claimable
        }
    }

    /// @dev External self-call wrapper: read the live balance, cap the payout at it
    ///      (so floored-accumulator wei-dust can never strand a claimer), then SafeERC20
    ///      transfer; returns the amount actually paid. Only callable by self, so any
    ///      revert in the balance read or the transfer is isolated by the caller's
    ///      try/catch. (Mutating entry points are nonReentrant, so a reentrant token's
    ///      re-entry reverts and is swallowed here with no state change.)
    function __capTransferReward(address token, address to, uint256 amount) external returns (uint256 paid) {
        if (msg.sender != address(this)) revert NotSelf();
        uint256 bal = IERC20(token).balanceOf(address(this));
        paid = amount <= bal ? amount : bal;
        if (paid != 0) IERC20(token).safeTransfer(to, paid);
    }

    function _addSupportedToken(address token) internal {
        if (token == address(0) || token == address(stakingToken)) revert InvalidToken();
        if (isSupportedToken[token]) revert TokenAlreadySupported();
        if (_supportedTokens.length >= MAX_SUPPORTED_TOKENS) revert TooManySupportedTokens();
        isSupportedToken[token] = true;
        _supportedTokens.push(token);
        emit SupportedTokenAdded(token);
    }

    // ------------------------- Internal: accumulator -------------------- //

    function _curAcc(address token) internal view returns (uint256) {
        return _acc[token].latest();
    }

    function _accAt(address token, uint256 epoch) internal view returns (uint256) {
        return _acc[token].upperLookup(epoch);
    }

    // --------------------------- Internal: time ------------------------- //

    function _currentEpoch() internal view returns (uint256) {
        if (block.timestamp <= genesis) return 0;
        return (block.timestamp - genesis) / WEEK;
    }

    function _eligibleEpochForNow() internal view returns (uint256) {
        if (block.timestamp < genesis) return 0;
        return (block.timestamp - genesis) / WEEK + 1;
    }

    // ------------------------------- Views ------------------------------ //

    function stakedBalanceOf(address user) public view returns (uint256) {
        UserInfo storage u = users[user];
        return u.eligibleStake + u.pendingStake;
    }

    function eligibleStakeOf(address user) external view returns (uint256) {
        return users[user].eligibleStake;
    }

    function pendingStakeOf(address user) external view returns (uint256) {
        return users[user].pendingStake;
    }

    function totalStaked() public view returns (uint256) {
        return totalEligibleStake + totalPendingStake;
    }

    function accRewardPerShare(address token) external view returns (uint256) {
        return _curAcc(token);
    }

    /// @notice Rewards of `token` currently claimable by `user`. Faithfully simulates
    ///         _settle + _harvest, so it always equals what claim() would pay.
    function claimable(address user, address token) public view returns (uint256 amount) {
        UserInfo storage u = users[user];
        amount = storedClaimable[user][token];

        uint256 curE = _currentEpoch();
        uint256 last = lastSettledEpoch;
        uint256 simAcc = _curAcc(token);
        uint256 simTe = totalEligibleStake;
        uint256 simPending = totalPendingStake;
        uint256 simPool = currentEpochPool[token];

        uint256 pe = u.pendingEligibleEpoch;
        bool promote = u.pendingStake != 0 && curE >= pe;
        bool peResolved;
        uint256 accAtPe;
        if (promote && pe <= last) {
            accAtPe = _accAt(token, pe);
            peResolved = true;
        }

        // simulate each elapsed boundary exactly as _settle would (same early-exit)
        for (uint256 k = last + 1; k <= curE; ++k) {
            bool didWork = false;
            if (simTe != 0 && simPool != 0) {
                uint256 add = Math.mulDiv(simPool, PRECISION, simTe); // mirror _settle (overflow-safe)
                if (add != 0) {
                    uint256 dist = Math.mulDiv(add, simTe, PRECISION);
                    if (dist != 0) {
                        simAcc += add;
                        simPool -= dist;
                        didWork = true;
                    }
                }
            }
            if (promote && !peResolved && k == pe) {
                accAtPe = simAcc;
                peResolved = true;
            }
            if (simPending != 0) {
                simTe += simPending;
                simPending = 0;
                didWork = true;
            }
            if (!didWork) break;
        }

        amount += Math.mulDiv(u.eligibleStake, simAcc, PRECISION) - rewardDebt[user][token];
        if (promote) {
            if (!peResolved) accAtPe = simAcc;
            amount += Math.mulDiv(u.pendingStake, simAcc - accAtPe, PRECISION);
        }
    }

    function claimableAll(address user)
        external
        view
        returns (address[] memory tokens, uint256[] memory amounts)
    {
        uint256 n = _rewardTokens.length;
        tokens = new address[](n);
        amounts = new uint256[](n);
        for (uint256 i; i < n; ++i) {
            tokens[i] = _rewardTokens[i];
            amounts[i] = claimable(user, _rewardTokens[i]);
        }
    }

    function isEligibleForNextPayout(address user) public view returns (bool) {
        UserInfo storage u = users[user];
        uint256 elig = u.eligibleStake;
        if (u.pendingStake != 0 && _currentEpoch() >= u.pendingEligibleEpoch) elig += u.pendingStake;
        return elig != 0;
    }

    function nextPayoutTime() public view returns (uint256) {
        return genesis + (_currentEpoch() + 1) * WEEK;
    }

    function snapshotCutoffTime() public view returns (uint256) {
        return genesis + _currentEpoch() * WEEK;
    }

    function currentEpoch() external view returns (uint256) {
        return _currentEpoch();
    }

    function supportedTokens() external view returns (address[] memory) {
        return _supportedTokens;
    }

    function rewardTokens() external view returns (address[] memory) {
        return _rewardTokens;
    }

    /// @dev Saturating: a direct token donation can raise the balance-capped payout so
    ///      cumulative `totalClaimed` slightly exceeds `totalDeposited` (donated wei). Guard
    ///      the subtraction so this view can never underflow-revert.
    function outstandingRewards(address token) external view returns (uint256) {
        uint256 dep = totalDeposited[token];
        uint256 claimed = totalClaimed[token];
        return dep >= claimed ? dep - claimed : 0;
    }

    // ---------------------- Aggregator views (dApp) --------------------- //

    struct UserView {
        uint256 eligibleStake;
        uint256 pendingStake;
        uint256 totalStake;
        bool eligibleNextPayout;
        address[] tokens;
        uint256[] claimableAmounts;
    }

    struct GlobalView {
        uint256 totalStaked;
        uint256 totalEligibleStake;
        uint256 totalPendingStake;
        uint256 currentEpoch;
        uint256 nextPayoutTime;
        uint256 snapshotCutoffTime;
        address[] supportedTokens;
        address[] rewardTokens;
    }

    function getUserInfo(address user) external view returns (UserView memory v) {
        UserInfo storage u = users[user];
        v.eligibleStake = u.eligibleStake;
        v.pendingStake = u.pendingStake;
        v.totalStake = u.eligibleStake + u.pendingStake;
        v.eligibleNextPayout = isEligibleForNextPayout(user);
        uint256 n = _rewardTokens.length;
        v.tokens = new address[](n);
        v.claimableAmounts = new uint256[](n);
        for (uint256 i; i < n; ++i) {
            v.tokens[i] = _rewardTokens[i];
            v.claimableAmounts[i] = claimable(user, _rewardTokens[i]);
        }
    }

    function getGlobalInfo() external view returns (GlobalView memory v) {
        v.totalStaked = totalStaked();
        v.totalEligibleStake = totalEligibleStake;
        v.totalPendingStake = totalPendingStake;
        v.currentEpoch = _currentEpoch();
        v.nextPayoutTime = nextPayoutTime();
        v.snapshotCutoffTime = snapshotCutoffTime();
        v.supportedTokens = _supportedTokens;
        v.rewardTokens = _rewardTokens;
    }
}