// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /// @title TokenLock /// @notice Non-custodial time-lock / linear vesting for any ERC-20. /// @dev Anyone can create a lock by depositing tokens with a schedule. Only the /// beneficiary can claim, and only what has vested. There is NO owner, NO /// admin withdrawal, and NO early exit — once locked, tokens can never be /// pulled by anyone (not even the creator). One contract holds many locks, /// so a creator holding a large bag can split it into several documented /// locks (e.g. "Team", "Treasury Q1", "Founder"). Every lock is publicly /// queryable, which powers trust badges and a token dashboard. contract TokenLock is ReentrancyGuard { using SafeERC20 for IERC20; struct Lock { address token; address creator; address beneficiary; uint256 amount; // total locked (actual amount received) uint256 claimed; // amount already claimed uint64 start; // vesting start (creation time) uint64 cliff; // nothing claimable before this uint64 end; // fully vested at/after this string note; // public label, e.g. "Team — 12mo" } Lock[] public locks; mapping(address => uint256[]) private _byToken; mapping(address => uint256[]) private _byBeneficiary; event Locked(uint256 indexed id, address indexed token, address indexed beneficiary, uint256 amount, uint64 cliff, uint64 end, string note); event Claimed(uint256 indexed id, address indexed beneficiary, uint256 amount); /// @param token ERC-20 to lock /// @param beneficiary who can claim as it vests (use your own address to lock your bag) /// @param amount tokens to pull from msg.sender (approve first) /// @param cliffDuration seconds before anything is claimable /// @param duration total vesting seconds (> 0, >= cliffDuration). /// For a pure lock (all unlocks at once), set duration == cliffDuration. /// @param note optional public label function lock( address token, address beneficiary, uint256 amount, uint64 cliffDuration, uint64 duration, string calldata note ) external nonReentrant returns (uint256 id) { require(token != address(0) && beneficiary != address(0), "zero addr"); require(amount > 0, "amount 0"); require(duration > 0 && cliffDuration <= duration, "bad schedule"); // Pull tokens and measure what actually arrived (fee-on-transfer safe). IERC20 t = IERC20(token); uint256 pre = t.balanceOf(address(this)); t.safeTransferFrom(msg.sender, address(this), amount); uint256 received = t.balanceOf(address(this)) - pre; require(received > 0, "nothing received"); uint64 start = uint64(block.timestamp); id = locks.length; locks.push(Lock({ token: token, creator: msg.sender, beneficiary: beneficiary, amount: received, claimed: 0, start: start, cliff: start + cliffDuration, end: start + duration, note: note })); _byToken[token].push(id); _byBeneficiary[beneficiary].push(id); emit Locked(id, token, beneficiary, received, start + cliffDuration, start + duration, note); } /// @notice Tokens vested for `id` at `atTime` (linear after the cliff). function vestedAmount(uint256 id, uint256 atTime) public view returns (uint256) { Lock storage l = locks[id]; if (atTime < l.cliff) return 0; if (atTime >= l.end) return l.amount; return (l.amount * (atTime - l.start)) / (l.end - l.start); } /// @notice Amount `id`'s beneficiary can claim right now. function claimable(uint256 id) public view returns (uint256) { return vestedAmount(id, block.timestamp) - locks[id].claimed; } /// @notice Claim vested tokens for `id` (beneficiary only). function claim(uint256 id) external nonReentrant { Lock storage l = locks[id]; require(msg.sender == l.beneficiary, "not beneficiary"); uint256 amt = vestedAmount(id, block.timestamp) - l.claimed; require(amt > 0, "nothing to claim"); l.claimed += amt; // effects before interaction IERC20(l.token).safeTransfer(l.beneficiary, amt); emit Claimed(id, l.beneficiary, amt); } // ── Views for transparency / dashboards ── function lockCount() external view returns (uint256) { return locks.length; } function getLock(uint256 id) external view returns (Lock memory) { return locks[id]; } function locksForToken(address token) external view returns (uint256[] memory) { return _byToken[token]; } function locksForBeneficiary(address who) external view returns (uint256[] memory) { return _byBeneficiary[who]; } /// @notice Total still-locked (unclaimed) amount of `token` across all locks — /// handy for a "X% of supply locked" trust badge. function totalLocked(address token) external view returns (uint256 sum) { uint256[] storage ids = _byToken[token]; for (uint256 i = 0; i < ids.length; i++) { Lock storage l = locks[ids[i]]; sum += l.amount - l.claimed; } } }