// SPDX-License-Identifier: MIT pragma solidity 0.8.30; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; /// @notice Fully funded, non-cancellable token allocations. Use standard non-rebasing ERC20s. abstract contract VestroveEscrow is ReentrancyGuard { using SafeERC20 for IERC20; IERC20 public token; address public creator; uint256 public totalAllocation; bool public funded; uint256 public totalClaimed; event Funded(address indexed creator, address indexed token, uint256 amount); event Claimed(address indexed recipient, uint256 amount); error InvalidConfiguration(); error NotCreator(); error AlreadyFunded(); error UnsupportedToken(); error NothingClaimable(); constructor(address token_, uint256 allocation_) { if (token_ == address(0) || token_.code.length == 0 || allocation_ == 0) revert InvalidConfiguration(); token = IERC20(token_); creator = msg.sender; totalAllocation = allocation_; } /// @notice Pulls exactly the allocation from the creator once. No unlimited approval is needed. function fund() external nonReentrant { if (msg.sender != creator) revert NotCreator(); if (funded) revert AlreadyFunded(); funded = true; uint256 beforeBalance = token.balanceOf(address(this)); uint256 creatorBefore = token.balanceOf(msg.sender); token.safeTransferFrom(msg.sender, address(this), totalAllocation); if (token.balanceOf(address(this)) != beforeBalance + totalAllocation || token.balanceOf(msg.sender) != creatorBefore - totalAllocation) revert UnsupportedToken(); emit Funded(msg.sender, address(token), totalAllocation); } // Checks-effects-interactions plus the guard protect claims. Recipient is always fixed by allocation. function _pay(address recipient, uint256 amount) internal { if (amount == 0) revert NothingClaimable(); totalClaimed += amount; uint256 recipientBefore = token.balanceOf(recipient); uint256 escrowBefore = token.balanceOf(address(this)); token.safeTransfer(recipient, amount); if (token.balanceOf(recipient) != recipientBefore + amount || token.balanceOf(address(this)) != escrowBefore - amount) revert UnsupportedToken(); emit Claimed(recipient, amount); } } /// @notice Equal monthly installments at explicit UTC dates. No admin withdrawal or cancellation. contract VestroveVesting is VestroveEscrow { address public beneficiary; uint64[] private releaseTimes; event VestingCreated(address indexed creator, address indexed token, address indexed beneficiary, uint256 totalAllocation); constructor(address token_, address beneficiary_, uint256 allocation_, uint64[] memory times_) VestroveEscrow(token_, allocation_) { if (beneficiary_ == address(0) || beneficiary_ == address(this) || times_.length == 0 || times_.length > 48) revert InvalidConfiguration(); uint256 previous = block.timestamp; for (uint256 i = 0; i < times_.length; ++i) { if (times_[i] <= previous) revert InvalidConfiguration(); previous = times_[i]; releaseTimes.push(times_[i]); } beneficiary = beneficiary_; emit VestingCreated(msg.sender, token_, beneficiary_, allocation_); } function contractKind() external pure returns (string memory) { return "VESTROVE_VESTING_V1"; } function schedule() external view returns (uint64[] memory) { return releaseTimes; } function vestedAmount(uint256 timestamp) public view returns (uint256) { uint256 low; uint256 high = releaseTimes.length; while (low < high) { uint256 mid = (low + high) / 2; if (releaseTimes[mid] <= timestamp) low = mid + 1; else high = mid; } return Math.mulDiv(totalAllocation, low, releaseTimes.length); } function claimable() public view returns (uint256) { if (!funded) return 0; return vestedAmount(block.timestamp) - totalClaimed; } /// @notice Anyone may trigger release; all tokens go to the fixed beneficiary. function claim() external nonReentrant { _pay(beneficiary, claimable()); } } /// @notice Claim distribution with up to 100 explicitly allocated recipients. No expiry or admin sweep. contract VestroveDistribution is VestroveEscrow { mapping(address => uint256) public allocations; mapping(address => bool) public claimed; address[] private recipients; event DistributionCreated(address indexed creator, address indexed token, uint256 totalAllocation, uint256 recipientCount); constructor(address token_, address[] memory recipients_, uint256[] memory amounts_) VestroveEscrow(token_, _sum(amounts_)) { if (recipients_.length == 0 || recipients_.length > 100 || recipients_.length != amounts_.length) revert InvalidConfiguration(); for (uint256 i = 0; i < recipients_.length; ++i) { address recipient = recipients_[i]; if (recipient == address(0) || recipient == address(this) || amounts_[i] == 0 || allocations[recipient] != 0) revert InvalidConfiguration(); allocations[recipient] = amounts_[i]; recipients.push(recipient); } emit DistributionCreated(msg.sender, token_, totalAllocation, recipients_.length); } function _sum(uint256[] memory amounts) private pure returns (uint256 total) { for (uint256 i = 0; i < amounts.length; ++i) total += amounts[i]; } function contractKind() external pure returns (string memory) { return "VESTROVE_DISTRIBUTION_V1"; } function recipientList() external view returns (address[] memory) { return recipients; } function claimable(address recipient) public view returns (uint256) { return funded && !claimed[recipient] ? allocations[recipient] : 0; } /// @notice Callers cannot redirect another recipient's allocation. function claim(address recipient) external nonReentrant { uint256 amount = claimable(recipient); if (amount == 0) revert NothingClaimable(); claimed[recipient] = true; _pay(recipient, amount); } }