// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; /// @notice Fixed-beneficiary, non-cancellable ERC-20 vesting. No administrator. /// @dev Only conventional, non-rebasing, non-taxed ERC-20 tokens are supported. contract Veylock is ReentrancyGuard { using SafeERC20 for IERC20; struct Schedule { address token; address creator; address beneficiary; uint256 total; uint256 released; uint64 start; uint64 cliff; uint64 end; } uint256 public scheduleCount; mapping(uint256 => Schedule) public schedules; mapping(address => uint256[]) private accountSchedules; error InvalidSchedule(); error UnsupportedToken(); error NothingToClaim(); event ScheduleCreated(uint256 indexed id, address indexed creator, address indexed beneficiary, address token, uint256 amount); event Claimed(uint256 indexed id, address indexed beneficiary, uint256 amount); function createSchedule(address token, address beneficiary, uint256 amount, uint64 start, uint64 cliff, uint64 end) external nonReentrant returns (uint256 id) { if (token.code.length == 0 || beneficiary == address(0) || beneficiary == address(this) || amount == 0 || start < block.timestamp || end <= start || cliff < start || cliff > end) revert InvalidSchedule(); IERC20 asset = IERC20(token); uint256 beforeBalance = asset.balanceOf(address(this)); uint256 creatorBalance = asset.balanceOf(msg.sender); asset.safeTransferFrom(msg.sender, address(this), amount); if (asset.balanceOf(address(this)) - beforeBalance != amount || creatorBalance - asset.balanceOf(msg.sender) != amount) revert UnsupportedToken(); id = ++scheduleCount; schedules[id] = Schedule(token, msg.sender, beneficiary, amount, 0, start, cliff, end); accountSchedules[msg.sender].push(id); if (beneficiary != msg.sender) accountSchedules[beneficiary].push(id); emit ScheduleCreated(id, msg.sender, beneficiary, token, amount); } /// @notice At the cliff, vesting catches up to elapsed time since start. function vestedAmount(uint256 id, uint256 timestamp) public view returns (uint256) { Schedule storage s = schedules[id]; if (s.total == 0 || timestamp < s.cliff || timestamp <= s.start) return 0; if (timestamp >= s.end) return s.total; return Math.mulDiv(s.total, timestamp - s.start, s.end - s.start); } function claimable(uint256 id) public view returns (uint256) { return vestedAmount(id, block.timestamp) - schedules[id].released; } /// @notice Anyone can trigger a claim, but payment always goes to the fixed beneficiary. function claim(uint256 id) external nonReentrant returns (uint256 amount) { amount = claimable(id); if (amount == 0) revert NothingToClaim(); Schedule storage s = schedules[id]; s.released += amount; IERC20 asset = IERC20(s.token); uint256 beforeBalance = asset.balanceOf(s.beneficiary); uint256 escrowBalance = asset.balanceOf(address(this)); asset.safeTransfer(s.beneficiary, amount); if (asset.balanceOf(s.beneficiary) - beforeBalance != amount || escrowBalance - asset.balanceOf(address(this)) != amount) revert UnsupportedToken(); emit Claimed(id, s.beneficiary, amount); } function scheduleIds(address account, uint256 offset, uint256 limit) external view returns (uint256[] memory ids) { uint256[] storage all = accountSchedules[account]; if (offset >= all.length) return new uint256[](0); uint256 size = Math.min(Math.min(limit, 100), all.length - offset); ids = new uint256[](size); for (uint256 i; i < size; ++i) ids[i] = all[offset + i]; } }