Robinhood MainnetChain ID: 4663
Contract detail

TimeWeightedStakingPool

Live Robinhood Chain account facts for 0xaf89d59a…6aa7426e with at least 25 observed transactions. The history total is updating as indexing continues.

Address type
Smart contract
Transactions
25+ observed
First indexed block
#809381
Latest indexed block
#51057490
History coverage
Recent activity available; total is catching up
Indexed token transfers
10
ERC-20 transfers
19
Emitted events
12
Verification
Verified
Compiler
v0.8.24+commit.e11b9ed9
Verification match
full
Creation transaction
0xd9477709…1ad480d6
Created at block
#809381
Language
solidity
EVM version
cancun
License
none
Compiler optimization
Enabled · 200 runs
Related activity

Transactions

Verified contract

Contract source code

// 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 {Math} from "@openzeppelin/contracts/utils/math/Math.sol";

/// @title TimeWeightedStakingPool
/// @notice ERC20 staking pool platform with owner-listed tokens and time-weighted reward distribution.
/// @dev Pools use one ERC20 token for both stake and rewards. Rewards pay a per-pool platform fee snapshot.
///      Only trusted normal ERC20 tokens should be listed. Tokens with mutable tax, rebasing, blacklist, or
///      non-standard transfer behavior may break user expectations and are rejected when exact transfers fail.
contract TimeWeightedStakingPool is Ownable2Step, ReentrancyGuard {
    using SafeERC20 for IERC20;

    /// @notice Default staking duration used by the simple pool-creation overload.
    uint64 public constant DEFAULT_DURATION = 14 days;

    /// @notice Default post-end claim window used by pool-creation overloads when claimDuration is zero.
    uint64 public constant DEFAULT_CLAIM_WINDOW = 30 days;

    /// @notice Denominator for basis-point fee math.
    uint16 public constant BPS_DENOMINATOR = 10_000;

    /// @notice Maximum default platform fee, in basis points.
    uint16 public constant MAX_PLATFORM_FEE_BPS = 500;

    /// @notice Default platform fee for future pools, in basis points.
    uint16 public defaultPlatformFeeBps;

    /// @notice When enabled, only the owner can create pools.
    bool public ownerOnlyPoolCreation = true;

    /// @notice Default minimum reward amount required for new pools unless a token override is set.
    /// @dev Uses 18-decimal token units. Set per-token overrides for tokens with other decimals.
    uint256 public globalMinRewardAmount = 1000 ether;

    struct Pool {
        IERC20 token;
        address creator;
        uint256 rewardAmount;
        uint64 startTime;
        uint64 endTime;
        uint64 claimDeadline;
        uint16 platformFeeBps;
        uint256 totalStaked;
        uint256 totalPoints;
        uint64 lastGlobalUpdate;
        bool finalized;
        uint256 rewardClaimed;
        uint256 rewardRecovered;
    }

    struct UserInfo {
        uint256 amount;
        uint256 points;
        uint64 lastUpdate;
        bool claimed;
        uint256 rewardPaid;
        uint256 rewardFeePaid;
    }

    struct ListedToken {
        address token;
        string name;
        string symbol;
        string metadataURI;
        address lister;
        uint64 listedAt;
        bool listed;
        bool active;
    }

    error ZeroAddress();
    error ZeroAmount();
    error PoolDoesNotExist();
    error PoolNotStarted();
    error PoolEnded();
    error PoolNotEnded();
    error PoolAlreadyFinalized();
    error PoolNotFinalized();
    error ClaimDeadlinePassed();
    error ClaimDeadlineNotPassed();
    error AlreadyClaimed();
    error InsufficientStake();
    error TokenAmountReserved();
    error NoRecoverableTokens();
    error InvalidPoolTime();
    error InsufficientPlatformFees();
    error FeeTooHigh();
    error PoolCreationRestricted();
    error TokenNotListed();
    error TokenAlreadyListed();
    error TokenInactive();
    error TokenNotContract();
    error EmptyTokenMetadata();
    error UnsupportedTokenTransfer();
    error NotPoolCreatorOrOwner();
    error RewardAmountTooLow();

    event PoolCreated(
        uint256 indexed poolId,
        address indexed creator,
        address indexed token,
        uint256 rewardAmount,
        uint64 startTime,
        uint64 endTime,
        uint64 claimDeadline,
        uint16 platformFeeBps
    );
    event Staked(uint256 indexed poolId, address indexed user, uint256 amount);
    event Withdrawn(uint256 indexed poolId, address indexed user, uint256 amount);
    event Claimed(
        uint256 indexed poolId, address indexed user, uint256 grossReward, uint256 platformFee, uint256 netReward
    );
    event PoolFinalized(uint256 indexed poolId, uint256 totalPoints);
    event PlatformFeesWithdrawn(address indexed token, address indexed to, uint256 amount);
    event TokenListed(address indexed token, address indexed lister, string name, string symbol, string metadataURI);
    event ListedTokenUpdated(address indexed token, string name, string symbol, string metadataURI);
    event ListedTokenActiveUpdated(address indexed token, bool active);
    event DefaultPlatformFeeBpsUpdated(uint16 oldFeeBps, uint16 newFeeBps);
    event OwnerOnlyPoolCreationUpdated(bool enabled);
    event GlobalMinRewardAmountUpdated(uint256 oldAmount, uint256 newAmount);
    event TokenMinRewardAmountUpdated(address indexed token, uint256 oldAmount, uint256 newAmount);
    event RewardDustRecovered(uint256 indexed poolId, address indexed token, address indexed to, uint256 amount);
    event TokenRecovered(address indexed token, address indexed to, uint256 amount);

    Pool[] private _pools;
    mapping(uint256 poolId => mapping(address user => UserInfo)) private _users;
    mapping(address token => ListedToken info) private _listedTokens;
    address[] private _listedTokenList;
    mapping(address token => uint256 amount) private _tokenMinRewardAmount;
    mapping(address token => uint256[] poolIds) private _poolIdsByToken;
    mapping(address creator => uint256[] poolIds) private _poolIdsByCreator;
    mapping(address token => uint256 amount) private _reservedStake;
    mapping(address token => uint256 amount) private _reservedReward;
    mapping(address token => uint256 amount) private _platformFees;

    constructor(address initialOwner) Ownable(initialOwner) {
        if (initialOwner == address(0)) revert ZeroAddress();
        defaultPlatformFeeBps = 50;
        ownerOnlyPoolCreation = true;
    }

    /// @notice Create a same-token staking/reward pool starting now with the default duration and claim window.
    /// @param token Listed active ERC20 token users stake and receive as rewards.
    /// @param rewardAmount Fixed reward budget deposited by the pool creator.
    /// @return poolId The created pool id.
    function createPool(address token, uint256 rewardAmount) external nonReentrant returns (uint256 poolId) {
        return _createPoolFromDuration(token, rewardAmount, DEFAULT_DURATION, DEFAULT_CLAIM_WINDOW);
    }

    /// @notice Create a same-token staking/reward pool starting now with configurable duration and claim window.
    /// @dev Passing duration as zero uses the default duration. Passing claimDuration as zero uses the default claim window.
    /// @param token Listed active ERC20 token users stake and receive as rewards.
    /// @param rewardAmount Fixed reward budget deposited by the pool creator.
    /// @param duration Pool staking duration in seconds.
    /// @param claimDuration Seconds after endTime during which users may claim; zero uses the default claim window.
    /// @return poolId The created pool id.
    function createPool(address token, uint256 rewardAmount, uint64 duration, uint64 claimDuration)
        external
        nonReentrant
        returns (uint256 poolId)
    {
        uint64 poolDuration = duration == 0 ? DEFAULT_DURATION : duration;
        uint64 actualClaimDuration = claimDuration == 0 ? DEFAULT_CLAIM_WINDOW : claimDuration;
        return _createPoolFromDuration(token, rewardAmount, poolDuration, actualClaimDuration);
    }

    /// @notice Create a same-token staking/reward pool with explicit staking start and end timestamps.
    /// @dev startTime must be now or later, and endTime must be after startTime.
    ///      Passing claimDuration as zero uses the default claim window.
    /// @param token Listed active ERC20 token users stake and receive as rewards.
    /// @param rewardAmount Fixed reward budget deposited by the pool creator.
    /// @param startTime Unix timestamp when staking opens.
    /// @param endTime Unix timestamp when staking closes and reward points stop accruing.
    /// @param claimDuration Seconds after endTime during which users may claim; zero uses the default claim window.
    /// @return poolId The created pool id.
    function createPool(address token, uint256 rewardAmount, uint64 startTime, uint64 endTime, uint64 claimDuration)
        external
        nonReentrant
        returns (uint256 poolId)
    {
        uint64 actualClaimDuration = claimDuration == 0 ? DEFAULT_CLAIM_WINDOW : claimDuration;
        return _createPool(token, rewardAmount, startTime, endTime, actualClaimDuration);
    }

    /// @notice Stake ERC20 tokens while a pool is active.
    /// @param poolId Pool id.
    /// @param amount Amount of staking tokens to deposit.
    function stake(uint256 poolId, uint256 amount) external nonReentrant {
        if (amount == 0) revert ZeroAmount();

        Pool storage pool = _pool(poolId);
        if (block.timestamp < pool.startTime) revert PoolNotStarted();
        if (block.timestamp >= pool.endTime) revert PoolEnded();

        _updatePool(pool);
        UserInfo storage user = _users[poolId][msg.sender];
        _updateUser(pool, user);

        _safeTransferFromExact(pool.token, msg.sender, amount);

        pool.totalStaked += amount;
        user.amount += amount;
        _reservedStake[address(pool.token)] += amount;

        emit Staked(poolId, msg.sender, amount);
    }

    /// @notice Claim earned net rewards after the pool has ended.
    /// @param poolId Pool id.
    function claim(uint256 poolId) external nonReentrant {
        Pool storage pool = _pool(poolId);
        (,, uint256 netReward) = _prepareClaim(poolId, pool, msg.sender, true);

        if (netReward != 0) {
            pool.token.safeTransfer(msg.sender, netReward);
        }
    }

    /// @notice Withdraw staked tokens after the pool has ended.
    /// @param poolId Pool id.
    /// @param amount Amount of staking tokens to withdraw.
    function withdraw(uint256 poolId, uint256 amount) external nonReentrant {
        Pool storage pool = _pool(poolId);
        uint256 withdrawn = _prepareWithdraw(poolId, pool, msg.sender, amount);

        pool.token.safeTransfer(msg.sender, withdrawn);
    }

    /// @notice Claim available net rewards and withdraw the caller's full stake after the pool has ended.
    /// @dev If the claim deadline has passed, this still withdraws stake but does not claim rewards.
    /// @param poolId Pool id.
    function exit(uint256 poolId) external nonReentrant {
        Pool storage pool = _pool(poolId);
        if (block.timestamp < pool.endTime) revert PoolNotEnded();

        _finalizePool(poolId, pool);

        UserInfo storage user = _users[poolId][msg.sender];
        uint256 netReward;
        if (!user.claimed && !_isClaimDeadlinePassed(pool)) {
            (,, netReward) = _prepareClaimAfterFinalized(poolId, pool, user, msg.sender);
        }

        uint256 withdrawn;
        if (user.amount != 0) {
            withdrawn = _prepareWithdrawAfterEnd(poolId, pool, user, msg.sender, user.amount);
        }

        if (netReward != 0) {
            pool.token.safeTransfer(msg.sender, netReward);
        }
        if (withdrawn != 0) {
            pool.token.safeTransfer(msg.sender, withdrawn);
        }
    }

    /// @notice Finalize a pool after its end time so reward shares use final total points.
    /// @param poolId Pool id.
    function finalizePool(uint256 poolId) external {
        Pool storage pool = _pool(poolId);
        if (block.timestamp < pool.endTime) revert PoolNotEnded();
        if (pool.finalized) revert PoolAlreadyFinalized();

        _finalizePool(poolId, pool);
    }

    /// @notice Return a user's currently claimable net reward after platform fee.
    /// @dev Reverts before endTime because final totalPoints are not available yet.
    /// @param poolId Pool id.
    /// @param account User address.
    /// @return reward Pending net reward amount.
    function pendingReward(uint256 poolId, address account) external view returns (uint256 reward) {
        (,, reward) = pendingRewardBreakdown(poolId, account);
    }

    /// @notice Return a user's gross reward, platform fee, and net reward after the pool has ended.
    /// @dev Reverts before endTime because final totalPoints are not available yet.
    /// @param poolId Pool id.
    /// @param account User address.
    /// @return grossReward User reward before platform fee.
    /// @return platformFee Platform fee deducted from gross reward.
    /// @return netReward User reward after platform fee.
    function pendingRewardBreakdown(uint256 poolId, address account)
        public
        view
        returns (uint256 grossReward, uint256 platformFee, uint256 netReward)
    {
        Pool storage pool = _pool(poolId);
        if (block.timestamp < pool.endTime) revert PoolNotEnded();
        if (_isClaimDeadlinePassed(pool)) revert ClaimDeadlinePassed();

        UserInfo storage user = _users[poolId][account];
        if (user.claimed) return (0, 0, 0);

        uint256 finalTotalPoints = _finalTotalPoints(pool);
        if (finalTotalPoints == 0) return (0, 0, 0);

        return
            _rewardBreakdown(pool.rewardAmount, _currentUserPoints(pool, user), finalTotalPoints, pool.platformFeeBps);
    }

    /// @notice Return a user's time-weighted staking points, capped to the pool's active window.
    /// @param poolId Pool id.
    /// @param account User address.
    /// @return points User points.
    function userPoints(uint256 poolId, address account) external view returns (uint256 points) {
        Pool storage pool = _pool(poolId);
        return _currentUserPoints(pool, _users[poolId][account]);
    }

    /// @notice Return pool data.
    /// @param poolId Pool id.
    /// @return pool Pool info.
    function poolInfo(uint256 poolId) external view returns (Pool memory pool) {
        return _pool(poolId);
    }

    /// @notice Return a pool's creator address.
    /// @param poolId Pool id.
    /// @return creator Pool creator.
    function poolCreator(uint256 poolId) external view returns (address creator) {
        return _pool(poolId).creator;
    }

    /// @notice Return a pool's single staking/reward token address.
    /// @param poolId Pool id.
    /// @return token Pool ERC20 token.
    function poolToken(uint256 poolId) external view returns (address token) {
        return address(_pool(poolId).token);
    }

    /// @notice Return stored user data.
    /// @param poolId Pool id.
    /// @param account User address.
    /// @return user User info.
    function userInfo(uint256 poolId, address account) external view returns (UserInfo memory user) {
        _pool(poolId);
        return _users[poolId][account];
    }

    /// @notice Return the number of pools created.
    /// @return count Pool count.
    function poolCount() external view returns (uint256 count) {
        return _pools.length;
    }

    /// @notice Return number of pools created for one token.
    /// @param token ERC20 token address.
    /// @return count Pool count for token.
    function poolCountByToken(address token) external view returns (uint256 count) {
        return _poolIdsByToken[token].length;
    }

    /// @notice Return a pool id for one token by index.
    /// @param token ERC20 token address.
    /// @param index Token pool index.
    /// @return poolId Pool id.
    function poolIdByTokenAt(address token, uint256 index) external view returns (uint256 poolId) {
        return _poolIdsByToken[token][index];
    }

    /// @notice Return number of pools created by one creator.
    /// @param creator Pool creator address.
    /// @return count Pool count for creator.
    function poolCountByCreator(address creator) external view returns (uint256 count) {
        return _poolIdsByCreator[creator].length;
    }

    /// @notice Return a pool id for one creator by index.
    /// @param creator Pool creator address.
    /// @param index Creator pool index.
    /// @return poolId Pool id.
    function poolIdByCreatorAt(address creator, uint256 index) external view returns (uint256 poolId) {
        return _poolIdsByCreator[creator][index];
    }

    /// @notice Return a paginated list of pool ids for one token.
    /// @param token ERC20 token address.
    /// @param offset Start index.
    /// @param limit Maximum number of pool ids to return.
    /// @return poolIds Pool ids.
    function poolIdsByToken(address token, uint256 offset, uint256 limit)
        external
        view
        returns (uint256[] memory poolIds)
    {
        return _sliceUintArray(_poolIdsByToken[token], offset, limit);
    }

    /// @notice Return a paginated list of pool ids for one creator.
    /// @param creator Pool creator address.
    /// @param offset Start index.
    /// @param limit Maximum number of pool ids to return.
    /// @return poolIds Pool ids.
    function poolIdsByCreator(address creator, uint256 offset, uint256 limit)
        external
        view
        returns (uint256[] memory poolIds)
    {
        return _sliceUintArray(_poolIdsByCreator[creator], offset, limit);
    }

    /// @notice Return accumulated withdrawable platform fees for one token.
    /// @param token ERC20 token address.
    /// @return amount Accumulated platform fees.
    function platformFees(address token) external view returns (uint256 amount) {
        return _platformFees[token];
    }

    /// @notice List a token for future pool creation.
    /// @param token ERC20 token contract.
    /// @param name Frontend display name.
    /// @param symbol Frontend display symbol.
    /// @param metadataURI IPFS or HTTPS metadata JSON URI.
    function listToken(address token, string calldata name, string calldata symbol, string calldata metadataURI)
        external
        onlyOwner
    {
        _listToken(token, name, symbol, metadataURI);
    }

    /// @notice Update frontend metadata for a listed token.
    /// @param token Listed token address.
    /// @param name Frontend display name.
    /// @param symbol Frontend display symbol.
    /// @param metadataURI IPFS or HTTPS metadata JSON URI.
    function updateListedTokenMetadata(
        address token,
        string calldata name,
        string calldata symbol,
        string calldata metadataURI
    ) external onlyOwner {
        ListedToken storage info = _listedTokens[token];
        if (!info.listed) revert TokenNotListed();
        _requireTokenMetadata(name, symbol, metadataURI);

        info.name = name;
        info.symbol = symbol;
        info.metadataURI = metadataURI;

        emit ListedTokenUpdated(token, name, symbol, metadataURI);
    }

    /// @notice Enable or disable a listed token for new pool creation.
    /// @param token Listed token address.
    /// @param active Whether new pools can be created for the token.
    function setListedTokenActive(address token, bool active) external onlyOwner {
        ListedToken storage info = _listedTokens[token];
        if (!info.listed) revert TokenNotListed();

        info.active = active;

        emit ListedTokenActiveUpdated(token, active);
    }

    /// @notice Return whether a token is listed.
    /// @param token Token address.
    /// @return listed True if listed.
    function isTokenListed(address token) external view returns (bool listed) {
        return _listedTokens[token].listed;
    }

    /// @notice Return whether a token is listed and active.
    /// @param token Token address.
    /// @return active True if active.
    function isTokenActive(address token) external view returns (bool active) {
        return _listedTokens[token].active;
    }

    /// @notice Return listed token metadata.
    /// @param token Token address.
    /// @return info Listed token info.
    function listedTokenInfo(address token) external view returns (ListedToken memory info) {
        return _listedTokens[token];
    }

    /// @notice Return listed token count.
    /// @return count Listed token count.
    function listedTokenCount() external view returns (uint256 count) {
        return _listedTokenList.length;
    }

    /// @notice Return a listed token address by index.
    /// @param index Listed token index.
    /// @return token Token address.
    function listedTokenAt(uint256 index) external view returns (address token) {
        return _listedTokenList[index];
    }

    /// @notice Return a paginated list of listed token addresses.
    /// @param offset Start index.
    /// @param limit Maximum number of addresses to return.
    /// @return tokens Listed token addresses.
    function listedTokenAddresses(uint256 offset, uint256 limit) external view returns (address[] memory tokens) {
        uint256 length = _listedTokenList.length;
        if (offset >= length || limit == 0) return new address[](0);

        uint256 remaining = length - offset;
        uint256 resultLength = limit < remaining ? limit : remaining;
        tokens = new address[](resultLength);

        for (uint256 i = 0; i < resultLength; i++) {
            tokens[i] = _listedTokenList[offset + i];
        }
    }

    /// @notice Update the default platform fee used by future pools.
    /// @param newFeeBps New fee in basis points.
    function setDefaultPlatformFeeBps(uint16 newFeeBps) external onlyOwner {
        if (newFeeBps > MAX_PLATFORM_FEE_BPS) revert FeeTooHigh();

        uint16 oldFeeBps = defaultPlatformFeeBps;
        defaultPlatformFeeBps = newFeeBps;

        emit DefaultPlatformFeeBpsUpdated(oldFeeBps, newFeeBps);
    }

    /// @notice Toggle whether only the owner can create new pools.
    /// @param enabled True to restrict pool creation to owner.
    function setOwnerOnlyPoolCreation(bool enabled) external onlyOwner {
        ownerOnlyPoolCreation = enabled;
        emit OwnerOnlyPoolCreationUpdated(enabled);
    }

    /// @notice Update the default minimum reward amount required for future pools.
    /// @param newAmount New global minimum reward amount.
    function setGlobalMinRewardAmount(uint256 newAmount) external onlyOwner {
        uint256 oldAmount = globalMinRewardAmount;
        globalMinRewardAmount = newAmount;

        emit GlobalMinRewardAmountUpdated(oldAmount, newAmount);
    }

    /// @notice Update the minimum reward amount override for one listed token.
    /// @param token Listed token address.
    /// @param newAmount New token-specific minimum reward amount; zero uses global minimum.
    function setTokenMinRewardAmount(address token, uint256 newAmount) external onlyOwner {
        if (token == address(0)) revert ZeroAddress();
        if (!_listedTokens[token].listed) revert TokenNotListed();

        uint256 oldAmount = _tokenMinRewardAmount[token];
        _tokenMinRewardAmount[token] = newAmount;

        emit TokenMinRewardAmountUpdated(token, oldAmount, newAmount);
    }

    /// @notice Return token-specific minimum reward amount override.
    /// @param token Token address.
    /// @return amount Token-specific minimum reward amount; zero means no override.
    function tokenMinRewardAmount(address token) external view returns (uint256 amount) {
        return _tokenMinRewardAmount[token];
    }

    /// @notice Return effective minimum reward amount for new pools using one token.
    /// @param token Token address.
    /// @return amount Token override if set, otherwise global minimum.
    function effectiveMinRewardAmount(address token) public view returns (uint256 amount) {
        uint256 tokenMin = _tokenMinRewardAmount[token];
        return tokenMin == 0 ? globalMinRewardAmount : tokenMin;
    }

    /// @notice Withdraw accumulated platform fees for one token.
    /// @param token ERC20 token to withdraw fees from.
    /// @param to Recipient address.
    /// @param amount Fee amount to withdraw.
    function withdrawPlatformFees(address token, address to, uint256 amount) external onlyOwner nonReentrant {
        if (token == address(0) || to == address(0)) revert ZeroAddress();
        if (amount == 0) revert ZeroAmount();
        if (amount > _platformFees[token]) revert InsufficientPlatformFees();

        _platformFees[token] -= amount;
        IERC20(token).safeTransfer(to, amount);

        emit PlatformFeesWithdrawn(token, to, amount);
    }

    /// @notice Withdraw all accumulated platform fees for one token.
    /// @param token ERC20 token to withdraw fees from.
    /// @param to Recipient address.
    /// @return amount Fee amount withdrawn.
    function withdrawAllPlatformFees(address token, address to)
        external
        onlyOwner
        nonReentrant
        returns (uint256 amount)
    {
        if (token == address(0) || to == address(0)) revert ZeroAddress();

        amount = _platformFees[token];
        if (amount == 0) revert NoRecoverableTokens();

        _platformFees[token] = 0;
        IERC20(token).safeTransfer(to, amount);

        emit PlatformFeesWithdrawn(token, to, amount);
    }

    /// @notice Recover unclaimed reward dust after a pool's claim deadline. Callable by pool creator or owner.
    /// @param poolId Pool id.
    /// @param to Recipient address.
    /// @return amount Amount recovered.
    function recoverRewardDust(uint256 poolId, address to) external nonReentrant returns (uint256 amount) {
        if (to == address(0)) revert ZeroAddress();

        Pool storage pool = _pool(poolId);
        if (block.timestamp < pool.endTime) revert PoolNotEnded();
        _finalizePool(poolId, pool);
        if (msg.sender != owner() && msg.sender != pool.creator) revert NotPoolCreatorOrOwner();
        if (block.timestamp <= pool.claimDeadline) revert ClaimDeadlineNotPassed();

        uint256 remaining = pool.rewardAmount - pool.rewardClaimed - pool.rewardRecovered;
        if (remaining == 0) revert NoRecoverableTokens();

        pool.rewardRecovered += remaining;
        _reservedReward[address(pool.token)] -= remaining;

        pool.token.safeTransfer(to, remaining);

        emit RewardDustRecovered(poolId, address(pool.token), to, remaining);
        return remaining;
    }

    /// @notice Recover tokens accidentally sent to this contract without touching reserved stake, rewards, or platform fees.
    /// @param token ERC20 token to recover.
    /// @param to Recipient address.
    /// @param amount Amount to recover.
    function recoverToken(IERC20 token, address to, uint256 amount) external onlyOwner nonReentrant {
        if (address(token) == address(0) || to == address(0)) revert ZeroAddress();
        if (amount == 0) revert ZeroAmount();

        uint256 protectedAmount =
            _reservedStake[address(token)] + _reservedReward[address(token)] + _platformFees[address(token)];
        uint256 balance = token.balanceOf(address(this));
        if (balance < protectedAmount || amount > balance - protectedAmount) revert TokenAmountReserved();

        token.safeTransfer(to, amount);

        emit TokenRecovered(address(token), to, amount);
    }

    /// @notice Return aggregate staking-token reserves protected from normal owner recovery.
    /// @param token ERC20 token address.
    /// @return amount Reserved stake amount.
    function reservedStake(address token) external view returns (uint256 amount) {
        return _reservedStake[token];
    }

    /// @notice Return aggregate reward-token reserves protected from normal owner recovery.
    /// @param token ERC20 token address.
    /// @return amount Reserved reward amount.
    function reservedReward(address token) external view returns (uint256 amount) {
        return _reservedReward[token];
    }

    function _createPoolFromDuration(address token, uint256 rewardAmount, uint64 duration, uint64 claimDuration)
        private
        returns (uint256 poolId)
    {
        uint64 startTime = uint64(block.timestamp);
        return _createPool(token, rewardAmount, startTime, startTime + duration, claimDuration);
    }

    function _createPool(address token, uint256 rewardAmount, uint64 startTime, uint64 endTime, uint64 claimDuration)
        private
        returns (uint256 poolId)
    {
        _enforcePoolCreationPermission();
        if (token == address(0)) revert ZeroAddress();
        _requireListedAndActiveToken(token);
        if (rewardAmount == 0) revert ZeroAmount();
        if (startTime < block.timestamp || endTime <= startTime) revert InvalidPoolTime();
        if (rewardAmount < effectiveMinRewardAmount(token)) revert RewardAmountTooLow();

        _safeTransferFromExact(IERC20(token), msg.sender, rewardAmount);

        uint64 claimDeadline = endTime + claimDuration;

        poolId = _pools.length;
        _pools.push(
            Pool({
                token: IERC20(token),
                creator: msg.sender,
                rewardAmount: rewardAmount,
                startTime: startTime,
                endTime: endTime,
                claimDeadline: claimDeadline,
                platformFeeBps: defaultPlatformFeeBps,
                totalStaked: 0,
                totalPoints: 0,
                lastGlobalUpdate: startTime,
                finalized: false,
                rewardClaimed: 0,
                rewardRecovered: 0
            })
        );

        _reservedReward[token] += rewardAmount;
        _poolIdsByToken[token].push(poolId);
        _poolIdsByCreator[msg.sender].push(poolId);

        emit PoolCreated(
            poolId, msg.sender, token, rewardAmount, startTime, endTime, claimDeadline, defaultPlatformFeeBps
        );
    }

    function _prepareClaim(uint256 poolId, Pool storage pool, address account, bool enforceDeadline)
        private
        returns (uint256 grossReward, uint256 platformFee, uint256 netReward)
    {
        if (block.timestamp < pool.endTime) revert PoolNotEnded();
        _finalizePool(poolId, pool);
        if (enforceDeadline && _isClaimDeadlinePassed(pool)) revert ClaimDeadlinePassed();

        UserInfo storage user = _users[poolId][account];
        return _prepareClaimAfterFinalized(poolId, pool, user, account);
    }

    function _prepareClaimAfterFinalized(uint256 poolId, Pool storage pool, UserInfo storage user, address account)
        private
        returns (uint256 grossReward, uint256 platformFee, uint256 netReward)
    {
        if (!pool.finalized) revert PoolNotFinalized();
        if (user.claimed) revert AlreadyClaimed();

        _updateUser(pool, user);

        if (pool.totalPoints != 0) {
            (grossReward, platformFee, netReward) =
                _rewardBreakdown(pool.rewardAmount, user.points, pool.totalPoints, pool.platformFeeBps);
        }

        user.claimed = true;
        user.rewardPaid = netReward;
        user.rewardFeePaid = platformFee;
        pool.rewardClaimed += grossReward;
        _reservedReward[address(pool.token)] -= grossReward;
        _platformFees[address(pool.token)] += platformFee;

        emit Claimed(poolId, account, grossReward, platformFee, netReward);
    }

    function _prepareWithdraw(uint256 poolId, Pool storage pool, address account, uint256 amount)
        private
        returns (uint256 withdrawn)
    {
        if (block.timestamp < pool.endTime) revert PoolNotEnded();
        _finalizePool(poolId, pool);

        UserInfo storage user = _users[poolId][account];
        return _prepareWithdrawAfterEnd(poolId, pool, user, account, amount);
    }

    function _prepareWithdrawAfterEnd(
        uint256 poolId,
        Pool storage pool,
        UserInfo storage user,
        address account,
        uint256 amount
    ) private returns (uint256 withdrawn) {
        if (amount == 0) revert ZeroAmount();
        if (user.amount < amount) revert InsufficientStake();

        _updateUser(pool, user);

        user.amount -= amount;
        pool.totalStaked -= amount;
        _reservedStake[address(pool.token)] -= amount;

        emit Withdrawn(poolId, account, amount);
        return amount;
    }

    function _finalizePool(uint256 poolId, Pool storage pool) private {
        if (pool.finalized) return;

        _updatePool(pool);
        pool.finalized = true;

        emit PoolFinalized(poolId, pool.totalPoints);
    }

    function _updatePool(Pool storage pool) private {
        uint64 applicableTime = _applicableTime(pool);
        if (applicableTime <= pool.lastGlobalUpdate) return;

        pool.totalPoints += pool.totalStaked * (applicableTime - pool.lastGlobalUpdate);
        pool.lastGlobalUpdate = applicableTime;
    }

    function _updateUser(Pool storage pool, UserInfo storage user) private {
        uint64 applicableTime = _applicableTime(pool);
        if (user.lastUpdate == 0 && user.amount == 0) {
            user.lastUpdate = applicableTime;
            return;
        }
        if (applicableTime <= user.lastUpdate) return;

        user.points += user.amount * (applicableTime - user.lastUpdate);
        user.lastUpdate = applicableTime;
    }

    function _currentUserPoints(Pool storage pool, UserInfo storage user) private view returns (uint256 points) {
        points = user.points;
        if (user.lastUpdate == 0 && user.amount == 0) return points;

        uint64 applicableTime = _applicableTime(pool);
        if (applicableTime > user.lastUpdate) {
            points += user.amount * (applicableTime - user.lastUpdate);
        }
    }

    function _finalTotalPoints(Pool storage pool) private view returns (uint256 points) {
        points = pool.totalPoints;
        uint64 applicableTime = _applicableTime(pool);
        if (applicableTime > pool.lastGlobalUpdate) {
            points += pool.totalStaked * (applicableTime - pool.lastGlobalUpdate);
        }
    }

    function _rewardBreakdown(uint256 rewardAmount, uint256 userPoints_, uint256 totalPoints_, uint16 feeBps)
        private
        pure
        returns (uint256 grossReward, uint256 platformFee, uint256 netReward)
    {
        grossReward = Math.mulDiv(rewardAmount, userPoints_, totalPoints_);
        platformFee = Math.mulDiv(grossReward, feeBps, BPS_DENOMINATOR);
        netReward = grossReward - platformFee;
    }

    function _applicableTime(Pool storage pool) private view returns (uint64) {
        uint256 timestamp = block.timestamp;
        if (timestamp < pool.startTime) return pool.startTime;
        return timestamp < pool.endTime ? uint64(timestamp) : pool.endTime;
    }

    function _isClaimDeadlinePassed(Pool storage pool) private view returns (bool) {
        return block.timestamp > pool.claimDeadline;
    }

    function _pool(uint256 poolId) private view returns (Pool storage pool) {
        if (poolId >= _pools.length) revert PoolDoesNotExist();
        return _pools[poolId];
    }

    function _listToken(address token, string calldata name, string calldata symbol, string calldata metadataURI)
        private
    {
        if (token == address(0)) revert ZeroAddress();
        if (token.code.length == 0) revert TokenNotContract();
        if (_listedTokens[token].listed) revert TokenAlreadyListed();
        _requireTokenMetadata(name, symbol, metadataURI);

        _listedTokens[token] = ListedToken({
            token: token,
            name: name,
            symbol: symbol,
            metadataURI: metadataURI,
            lister: msg.sender,
            listedAt: uint64(block.timestamp),
            listed: true,
            active: true
        });
        _listedTokenList.push(token);

        emit TokenListed(token, msg.sender, name, symbol, metadataURI);
    }

    function _requireTokenMetadata(string calldata name, string calldata symbol, string calldata metadataURI)
        private
        pure
    {
        if (bytes(name).length == 0 || bytes(symbol).length == 0 || bytes(metadataURI).length == 0) {
            revert EmptyTokenMetadata();
        }
    }

    function _enforcePoolCreationPermission() private view {
        if (ownerOnlyPoolCreation && msg.sender != owner()) revert PoolCreationRestricted();
    }

    function _requireListedAndActiveToken(address token) private view {
        ListedToken storage info = _listedTokens[token];
        if (!info.listed) revert TokenNotListed();
        if (!info.active) revert TokenInactive();
    }

    function _sliceUintArray(uint256[] storage source, uint256 offset, uint256 limit)
        private
        view
        returns (uint256[] memory result)
    {
        uint256 length = source.length;
        if (offset >= length || limit == 0) return new uint256[](0);

        uint256 remaining = length - offset;
        uint256 resultLength = limit < remaining ? limit : remaining;
        result = new uint256[](resultLength);

        for (uint256 i = 0; i < resultLength; i++) {
            result[i] = source[offset + i];
        }
    }

    function _safeTransferFromExact(IERC20 token, address from, uint256 amount) private {
        uint256 beforeBalance = token.balanceOf(address(this));
        token.safeTransferFrom(from, address(this), amount);
        uint256 received = token.balanceOf(address(this)) - beforeBalance;

        if (received != amount) revert UnsupportedTokenTransfer();
    }
}