// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {IERC20Minimal} from "./interfaces/IERC20Minimal.sol"; import {SafeTransferLib} from "./libraries/SafeTransferLib.sol"; import {FullMath} from "./libraries/FullMath.sol"; import {ReentrancyGuard} from "./utils/ReentrancyGuard.sol"; import {LaunchRewardVault} from "./LaunchRewardVault.sol"; /// @title STANK Holder Rewards Distributor /// @notice Streams paired-asset community rewards to holders who stake the launched token. /// @dev Pending stakes mature for 24 hours before earning; newly synced rewards stream for seven days. /// There is no owner, and a principal-only emergency exit never depends on the paired asset or vault. contract HolderRewardsDistributor is ReentrancyGuard { using SafeTransferLib for IERC20Minimal; /// @dev Q128 precision is at least as large as the factory-capped active stake supply. uint256 public constant REWARD_PRECISION = 1 << 128; uint256 public constant STAKE_ACTIVATION_DELAY = 1 days; uint256 public constant REWARD_STREAM_DURATION = 7 days; IERC20Minimal public immutable launchToken; IERC20Minimal public immutable pairedAsset; address public immutable binder; LaunchRewardVault public rewardVault; bool public vaultBound; uint256 public totalActiveStake; uint256 public rewardPerTokenStored; uint256 public rewardRate; uint256 public streamPeriodFinish; uint256 public lastIndexUpdate; /// @notice Remainder of `emitted * REWARD_PRECISION / totalActiveStake` carried across updates. uint256 public indexNumeratorRemainder; uint256 public queuedRewards; uint256 public streamRemainder; uint256 public accountedRewardBalance; mapping(address account => uint256 amount) public pendingStake; mapping(address account => uint256 activationTime) public pendingActivationTime; mapping(address account => uint256 amount) public activeStake; mapping(address account => uint256 rewardPerTokenPaid) public userRewardPerTokenPaid; mapping(address account => uint256 amount) public accruedRewards; error NotBinder(); error VaultAlreadyBound(); error VaultNotBound(); error InvalidVault(); error ZeroAmount(); error ZeroAddress(); error StakeNotMature(); error StakeSupplyTooLarge(); error InsufficientStake(); error NothingToClaim(); error RewardBalanceInvariantBroken(uint256 actual, uint256 accounted); event RewardVaultBound(address indexed rewardVault); event StakePending(address indexed account, uint256 amount, uint256 activationTime); event StakeActivated(address indexed account, uint256 amount); event Unstaked(address indexed account, uint256 pendingAmount, uint256 activeAmount); event EmergencyPrincipalExit(address indexed account, address indexed to, uint256 amount); event HolderRewardsSynced(uint256 newlyReceived, uint256 rewardRate, uint256 periodFinish); event HolderRewardClaimed(address indexed account, address indexed to, uint256 amount); constructor(IERC20Minimal launchToken_, IERC20Minimal pairedAsset_, address binder_) { if ( address(launchToken_) == address(0) || address(pairedAsset_) == address(0) || binder_ == address(0) ) revert ZeroAddress(); launchToken = launchToken_; pairedAsset = pairedAsset_; binder = binder_; } /// @notice One-time binding performed atomically by the component deployer. function bindRewardVault(LaunchRewardVault rewardVault_) external { if (msg.sender != binder) revert NotBinder(); if (vaultBound) revert VaultAlreadyBound(); if ( address(rewardVault_) == address(0) || rewardVault_.pairedAsset() != address(pairedAsset) || rewardVault_.holderRewardsDistributor() != address(this) ) revert InvalidVault(); rewardVault = rewardVault_; vaultBound = true; lastIndexUpdate = block.timestamp; emit RewardVaultBound(address(rewardVault_)); } /// @notice Deposits launched tokens into a 24-hour pending state. /// @dev Adding pending stake resets only that pending tranche's activation clock; active stake is untouched. function stake(uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); _syncGlobal(); _updateAccount(msg.sender); launchToken.safeTransferFrom(msg.sender, address(this), amount); pendingStake[msg.sender] += amount; uint256 activationTime = block.timestamp + STAKE_ACTIVATION_DELAY; pendingActivationTime[msg.sender] = activationTime; emit StakePending(msg.sender, amount, activationTime); } /// @notice Activates all matured pending stake so it begins earning future streamed rewards. function activateStake() external nonReentrant returns (uint256 amount) { amount = pendingStake[msg.sender]; if (amount == 0) revert ZeroAmount(); if (block.timestamp < pendingActivationTime[msg.sender]) revert StakeNotMature(); _syncGlobal(); _updateAccount(msg.sender); pendingStake[msg.sender] = 0; pendingActivationTime[msg.sender] = 0; activeStake[msg.sender] += amount; uint256 previousSupply = totalActiveStake; uint256 nextSupply = previousSupply + amount; if (nextSupply > type(uint128).max) revert StakeSupplyTooLarge(); totalActiveStake = nextSupply; _restartStreamIfQueued(); emit StakeActivated(msg.sender, amount); } /// @notice Withdraws selected pending and active principal after syncing rewards. function unstake(uint256 pendingAmount, uint256 activeAmount) external nonReentrant { if (pendingAmount == 0 && activeAmount == 0) revert ZeroAmount(); if (pendingStake[msg.sender] < pendingAmount || activeStake[msg.sender] < activeAmount) { revert InsufficientStake(); } _syncGlobal(); _updateAccount(msg.sender); _removeStake(msg.sender, pendingAmount, activeAmount); launchToken.safeTransfer(msg.sender, pendingAmount + activeAmount); emit Unstaked(msg.sender, pendingAmount, activeAmount); } /// @notice Withdraws all launch-token principal without touching the vault or paired asset. /// @dev This remains usable if reward syncing or paired-asset transfers are broken. Earned rewards stay recorded. function emergencyExitPrincipal(address to) external nonReentrant returns (uint256 amount) { if (to == address(0)) revert ZeroAddress(); _updateGlobalIndex(); _updateAccount(msg.sender); uint256 pendingAmount = pendingStake[msg.sender]; uint256 activeAmount = activeStake[msg.sender]; amount = pendingAmount + activeAmount; if (amount == 0) revert ZeroAmount(); _removeStake(msg.sender, pendingAmount, activeAmount); launchToken.safeTransfer(to, amount); emit EmergencyPrincipalExit(msg.sender, to, amount); } /// @notice Pulls paired rewards from the vault and streams them over seven days. function syncRewards() external nonReentrant returns (uint256 newlyReceived) { newlyReceived = _syncGlobal(); } /// @notice Claims all paired-asset rewards earned by the caller. function claimRewards(address to) external nonReentrant returns (uint256 amount) { if (to == address(0)) revert ZeroAddress(); _syncGlobal(); _updateAccount(msg.sender); amount = accruedRewards[msg.sender]; if (amount == 0) revert NothingToClaim(); accruedRewards[msg.sender] = 0; accountedRewardBalance -= amount; pairedAsset.safeTransfer(to, amount); emit HolderRewardClaimed(msg.sender, to, amount); } /// @notice Pushes earned rewards to each listed holder, paid to the holder itself. /// @dev Permissionless "distribute" action for the app/operator: it settles and pays out the /// rewards of holders who have not claimed yet. Funds always go to the account that earned /// them, so this can never redirect anyone's rewards; accounts with nothing owed are /// skipped, and a duplicate address in the list is paid at most once. The caller supplies /// the holder list (indexed off-chain from stake events). function distribute(address[] calldata accounts) external nonReentrant returns (uint256 totalDistributed) { _syncGlobal(); uint256 length = accounts.length; for (uint256 i; i < length; ++i) { address account = accounts[i]; _updateAccount(account); uint256 amount = accruedRewards[account]; if (amount == 0) continue; accruedRewards[account] = 0; accountedRewardBalance -= amount; pairedAsset.safeTransfer(account, amount); emit HolderRewardClaimed(account, account, amount); totalDistributed += amount; } } /// @notice Current paired-asset earnings, including elapsed but not yet stored stream rewards. function earned(address account) external view returns (uint256) { uint256 currentIndex = _previewRewardPerToken(); return accruedRewards[account] + FullMath.mulDiv( activeStake[account], currentIndex - userRewardPerTokenPaid[account], REWARD_PRECISION ); } function _syncGlobal() private returns (uint256 newlyReceived) { if (!vaultBound) revert VaultNotBound(); _updateGlobalIndex(); uint256 vaultCredit = rewardVault.claimable(address(this)); if (vaultCredit != 0) rewardVault.claim(address(this)); uint256 actualBalance = pairedAsset.balanceOf(address(this)); if (actualBalance < accountedRewardBalance) { revert RewardBalanceInvariantBroken(actualBalance, accountedRewardBalance); } newlyReceived = actualBalance - accountedRewardBalance; accountedRewardBalance = actualBalance; if (newlyReceived != 0) _restartStream(newlyReceived); emit HolderRewardsSynced(newlyReceived, rewardRate, streamPeriodFinish); } function _updateGlobalIndex() private { uint256 applicableTime = block.timestamp < streamPeriodFinish ? block.timestamp : streamPeriodFinish; uint256 previousUpdate = lastIndexUpdate; if (applicableTime <= previousUpdate) return; uint256 emitted = (applicableTime - previousUpdate) * rewardRate; if (applicableTime == streamPeriodFinish && streamRemainder != 0) { emitted += streamRemainder; streamRemainder = 0; } uint256 stakeSupply = totalActiveStake; if (emitted != 0) { if (stakeSupply == 0) { queuedRewards += emitted; } else { _indexEmittedRewards(emitted, stakeSupply); } } lastIndexUpdate = applicableTime; } function _restartStreamIfQueued() private { if (queuedRewards != 0 && totalActiveStake != 0) _restartStream(0); } function _restartStream(uint256 newRewards) private { uint256 leftover = block.timestamp < streamPeriodFinish ? (streamPeriodFinish - block.timestamp) * rewardRate : 0; uint256 totalToStream = newRewards + leftover + queuedRewards + streamRemainder; queuedRewards = 0; rewardRate = totalToStream / REWARD_STREAM_DURATION; streamRemainder = totalToStream % REWARD_STREAM_DURATION; streamPeriodFinish = block.timestamp + REWARD_STREAM_DURATION; lastIndexUpdate = block.timestamp; } function _updateAccount(address account) private { uint256 currentIndex = rewardPerTokenStored; uint256 delta = currentIndex - userRewardPerTokenPaid[account]; if (delta != 0) { accruedRewards[account] += FullMath.mulDiv(activeStake[account], delta, REWARD_PRECISION); userRewardPerTokenPaid[account] = currentIndex; } } function _removeStake(address account, uint256 pendingAmount, uint256 activeAmount) private { if (pendingAmount != 0) { pendingStake[account] -= pendingAmount; if (pendingStake[account] == 0) pendingActivationTime[account] = 0; } if (activeAmount != 0) { activeStake[account] -= activeAmount; uint256 previousSupply = totalActiveStake; totalActiveStake = previousSupply - activeAmount; } } function _previewRewardPerToken() private view returns (uint256 index) { index = rewardPerTokenStored; uint256 stakeSupply = totalActiveStake; if (stakeSupply == 0) return index; uint256 applicableTime = block.timestamp < streamPeriodFinish ? block.timestamp : streamPeriodFinish; if (applicableTime <= lastIndexUpdate) return index; uint256 emitted = (applicableTime - lastIndexUpdate) * rewardRate; if (applicableTime == streamPeriodFinish) emitted += streamRemainder; uint256 indexIncrement = FullMath.mulDiv(emitted, REWARD_PRECISION, stakeSupply); uint256 combinedRemainder = mulmod(emitted, REWARD_PRECISION, stakeSupply) + indexNumeratorRemainder; index += indexIncrement + combinedRemainder / stakeSupply; } function _indexEmittedRewards(uint256 emitted, uint256 stakeSupply) private { uint256 indexIncrement = FullMath.mulDiv(emitted, REWARD_PRECISION, stakeSupply); uint256 combinedRemainder = mulmod(emitted, REWARD_PRECISION, stakeSupply) + indexNumeratorRemainder; rewardPerTokenStored += indexIncrement + combinedRemainder / stakeSupply; indexNumeratorRemainder = combinedRemainder % stakeSupply; } }