// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {IERC20Minimal} from "./interfaces/IERC20Minimal.sol"; import {SafeTransferLib} from "./libraries/SafeTransferLib.sol"; import {ReentrancyGuard} from "./utils/ReentrancyGuard.sol"; /// @title STANK Launch Reward Vault /// @notice Splits paired-asset rewards and lets protocol, creator, and holder distributor pull them. /// @dev Launch-token fees are never credited here; the locker must convert them to `pairedAsset` first. contract LaunchRewardVault is ReentrancyGuard { using SafeTransferLib for IERC20Minimal; uint16 public constant BPS_DENOMINATOR = 10_000; uint16 public constant PROTOCOL_BPS = 2_000; uint16 public constant CREATOR_AND_HOLDERS_BPS = 8_000; address public immutable pairedAsset; address public immutable protocolTreasury; address public immutable creator; address public immutable holderRewardsDistributor; uint16 public immutable creatorBps; uint16 public immutable holderBps; struct SplitRemainders { uint16 protocol; uint16 creator; uint16 holders; } /// @notice Paired-asset balance already observed by a checkpoint. uint256 public accountedBalance; /// @notice Accounted base units awaiting enough fractional entitlement for allocation. uint256 public unallocatedBalance; SplitRemainders public splitRemainders; mapping(address beneficiary => uint256 amount) public claimable; error InvalidSplit(); error ZeroAddress(); error BalanceInvariantBroken(uint256 actual, uint256 accounted); error AllocationInvariantBroken(); error NothingToClaim(); event RewardsCheckpointed( uint256 newlyReceived, uint256 protocolAmount, uint256 creatorAmount, uint256 holderAmount ); event ProtocolRewardForwarded(address indexed protocolTreasury, uint256 amount); event RewardClaimed(address indexed beneficiary, address indexed to, uint256 amount); constructor( address pairedAsset_, address protocolTreasury_, address creator_, address holderRewardsDistributor_, uint16 creatorBps_, uint16 holderBps_ ) { if ( pairedAsset_ == address(0) || protocolTreasury_ == address(0) || creator_ == address(0) || holderRewardsDistributor_ == address(0) ) revert ZeroAddress(); if (uint256(creatorBps_) + holderBps_ != CREATOR_AND_HOLDERS_BPS) revert InvalidSplit(); pairedAsset = pairedAsset_; protocolTreasury = protocolTreasury_; creator = creator_; holderRewardsDistributor = holderRewardsDistributor_; creatorBps = creatorBps_; holderBps = holderBps_; } /// @notice Allocates newly received paired asset using grouping-independent bps remainders. /// @dev Permissionless. Fractional remainders prevent checkpoint frequency from biasing the split. function checkpoint() external nonReentrant returns (uint256 newlyReceived) { uint256 actualBalance = IERC20Minimal(pairedAsset).balanceOf(address(this)); uint256 previousAccounted = accountedBalance; if (actualBalance < previousAccounted) { revert BalanceInvariantBroken(actualBalance, previousAccounted); } newlyReceived = actualBalance - previousAccounted; if (newlyReceived == 0) return 0; SplitRemainders memory remainders = splitRemainders; uint16 nextProtocolRemainder; uint16 nextCreatorRemainder; uint16 nextHolderRemainder; uint256 protocolAmount; uint256 creatorAmount; uint256 holderAmount; (protocolAmount, nextProtocolRemainder) = _allocateBps(newlyReceived, PROTOCOL_BPS, remainders.protocol); (creatorAmount, nextCreatorRemainder) = _allocateBps(newlyReceived, creatorBps, remainders.creator); (holderAmount, nextHolderRemainder) = _allocateBps(newlyReceived, holderBps, remainders.holders); uint256 availableToAllocate = newlyReceived + unallocatedBalance; uint256 allocated = protocolAmount + creatorAmount + holderAmount; if (allocated > availableToAllocate) revert AllocationInvariantBroken(); // The protocol's 20% is pushed to the treasury immediately, so it is never held here as a // pull balance. Creator and holder shares stay as pull payments they claim when they choose. accountedBalance = actualBalance - protocolAmount; unallocatedBalance = availableToAllocate - allocated; splitRemainders = SplitRemainders({ protocol: nextProtocolRemainder, creator: nextCreatorRemainder, holders: nextHolderRemainder }); claimable[creator] += creatorAmount; claimable[holderRewardsDistributor] += holderAmount; emit RewardsCheckpointed(newlyReceived, protocolAmount, creatorAmount, holderAmount); if (protocolAmount != 0) { IERC20Minimal(pairedAsset).safeTransfer(protocolTreasury, protocolAmount); emit ProtocolRewardForwarded(protocolTreasury, protocolAmount); } } /// @notice Pulls the caller's full paired-asset allocation to `to`. function claim(address to) external nonReentrant returns (uint256 amount) { if (to == address(0)) revert ZeroAddress(); amount = claimable[msg.sender]; if (amount == 0) revert NothingToClaim(); claimable[msg.sender] = 0; accountedBalance -= amount; IERC20Minimal(pairedAsset).safeTransfer(to, amount); emit RewardClaimed(msg.sender, to, amount); } function _allocateBps(uint256 amount, uint16 bps, uint16 previousRemainder) private pure returns (uint256 share, uint16 nextRemainder) { share = (amount / BPS_DENOMINATOR) * bps; uint256 fractional = (amount % BPS_DENOMINATOR) * bps + previousRemainder; share += fractional / BPS_DENOMINATOR; nextRemainder = uint16(fractional % BPS_DENOMINATOR); } }