diff --git a/.github/workflows/artifacts-dev.yaml b/.github/workflows/artifacts-dev.yaml index 6726add..51a9d21 100644 --- a/.github/workflows/artifacts-dev.yaml +++ b/.github/workflows/artifacts-dev.yaml @@ -35,6 +35,7 @@ jobs: mkdir -p ./artifacts/zend-token/$COMMIT_HASH cp ./out/ZendToken.sol/ZendToken.json ./artifacts/zend-token/$COMMIT_HASH/ + cp ./out/TokenEscrow.sol/TokenEscrow.json ./artifacts/zend-token/$COMMIT_HASH/ cp ./out/TransparentUpgradeableProxy.sol/TransparentUpgradeableProxy.json ./artifacts/zend-token/$COMMIT_HASH/ cp ./out/ProxyAdmin.sol/ProxyAdmin.json ./artifacts/zend-token/$COMMIT_HASH/ (cd ./artifacts/zend-token/ && rm -rf ./latest && ln -s ./$COMMIT_HASH ./latest) diff --git a/.github/workflows/artifacts-release.yaml b/.github/workflows/artifacts-release.yaml index 9c64227..c9e1ca2 100644 --- a/.github/workflows/artifacts-release.yaml +++ b/.github/workflows/artifacts-release.yaml @@ -35,6 +35,7 @@ jobs: mkdir -p ./artifacts/zend-token/$COMMIT_HASH cp ./out/ZendToken.sol/ZendToken.json ./artifacts/zend-token/$COMMIT_HASH/ + cp ./out/TokenEscrow.sol/TokenEscrow.json ./artifacts/zend-token/$COMMIT_HASH/ cp ./out/TransparentUpgradeableProxy.sol/TransparentUpgradeableProxy.json ./artifacts/zend-token/$COMMIT_HASH/ cp ./out/ProxyAdmin.sol/ProxyAdmin.json ./artifacts/zend-token/$COMMIT_HASH/ (cd ./artifacts/zend-token/ && rm -rf ./latest && ln -s ./$COMMIT_HASH ./latest) diff --git a/src/TokenEscrow.sol b/src/TokenEscrow.sol new file mode 100644 index 0000000..fe6de9b --- /dev/null +++ b/src/TokenEscrow.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity =0.8.21; + +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/math/SafeMathUpgradeable.sol"; + +/** + * @title TokenEscrow + * + * @dev An upgradeable token escrow contract for releasing ERC20 tokens based on + * schedule. + */ +contract TokenEscrow is OwnableUpgradeable { + using SafeMathUpgradeable for uint256; + + event VestingScheduleAdded(address indexed user, uint256 amount, uint256 startTime, uint256 endTime, uint256 step); + event VestingScheduleRemoved(address indexed user); + event TokenVested(address indexed user, uint256 amount); + + /** + * @param amount Total amount to be vested over the complete period + * @param startTime Unix timestamp in seconds for the period start time + * @param endTime Unix timestamp in seconds for the period end time + * @param step Interval in seconds at which vestable amounts are accumulated + * @param lastClaimTime Unix timestamp in seconds for the last claim time + */ + struct VestingSchedule { + uint128 amount; + uint32 startTime; + uint32 endTime; + uint32 step; + uint32 lastClaimTime; + } + + IERC20Upgradeable public token; + mapping(address => VestingSchedule) public vestingSchedules; + + function getWithdrawableAmount(address user) external view returns (uint256) { + (uint256 withdrawableFromVesting,,) = calculateWithdrawableFromVesting(user); + + return withdrawableFromVesting; + } + + function __TokenEscrow_init(IERC20Upgradeable _token) public initializer { + __Ownable_init(); + + require(address(_token) != address(0), "TokenEscrow: zero address"); + token = _token; + } + + function setVestingSchedule(address user, uint256 amount, uint256 startTime, uint256 endTime, uint256 step) + external + onlyOwner + { + require(user != address(0), "TokenEscrow: zero address"); + require(amount > 0, "TokenEscrow: zero amount"); + require(startTime < endTime, "TokenEscrow: invalid time range"); + require(step > 0 && endTime.sub(startTime) % step == 0, "TokenEscrow: invalid step"); + require(vestingSchedules[user].amount == 0, "TokenEscrow: vesting schedule already exists"); + + // Overflow checks + require(uint256(uint128(amount)) == amount, "TokenEscrow: amount overflow"); + require(uint256(uint32(startTime)) == startTime, "TokenEscrow: startTime overflow"); + require(uint256(uint32(endTime)) == endTime, "TokenEscrow: endTime overflow"); + require(uint256(uint32(step)) == step, "TokenEscrow: step overflow"); + + vestingSchedules[user] = VestingSchedule({ + amount: uint128(amount), + startTime: uint32(startTime), + endTime: uint32(endTime), + step: uint32(step), + lastClaimTime: uint32(startTime) + }); + + emit VestingScheduleAdded(user, amount, startTime, endTime, step); + } + + function removeVestingSchedule(address user) external onlyOwner { + require(vestingSchedules[user].amount != 0, "TokenEscrow: vesting schedule not set"); + + delete vestingSchedules[user]; + + emit VestingScheduleRemoved(user); + } + + function withdraw() external { + uint256 withdrawableFromVesting; + + // Withdraw from vesting + { + uint256 newClaimTime; + bool allVested; + (withdrawableFromVesting, newClaimTime, allVested) = calculateWithdrawableFromVesting(msg.sender); + + if (withdrawableFromVesting > 0) { + if (allVested) { + // Remove storage slot to save gas + delete vestingSchedules[msg.sender]; + } else { + vestingSchedules[msg.sender].lastClaimTime = uint32(newClaimTime); + } + } + } + + uint256 totalAmountToSend = withdrawableFromVesting; + require(totalAmountToSend > 0, "TokenEscrow: nothing to withdraw"); + + if (withdrawableFromVesting > 0) { + emit TokenVested(msg.sender, withdrawableFromVesting); + } + + token.transfer(msg.sender, totalAmountToSend); + } + + function calculateWithdrawableFromVesting(address user) + private + view + returns (uint256 amount, uint256 newClaimTime, bool allVested) + { + VestingSchedule memory vestingSchedule = vestingSchedules[user]; + + if (vestingSchedule.amount == 0) { + return (0, 0, false); + } + if (block.timestamp < uint256(vestingSchedule.startTime)) { + return (0, 0, false); + } + + uint256 currentStepTime = MathUpgradeable.min( + block.timestamp.sub(uint256(vestingSchedule.startTime)).div(uint256(vestingSchedule.step)).mul( + uint256(vestingSchedule.step) + ).add(uint256(vestingSchedule.startTime)), + uint256(vestingSchedule.endTime) + ); + + if (currentStepTime <= uint256(vestingSchedule.lastClaimTime)) { + return (0, 0, false); + } + + uint256 totalSteps = + uint256(vestingSchedule.endTime).sub(uint256(vestingSchedule.startTime)).div(vestingSchedule.step); + + if (currentStepTime == uint256(vestingSchedule.endTime)) { + // All vested + + uint256 stepsVested = + uint256(vestingSchedule.lastClaimTime).sub(uint256(vestingSchedule.startTime)).div(vestingSchedule.step); + uint256 amountToVest = + uint256(vestingSchedule.amount).sub(uint256(vestingSchedule.amount).div(totalSteps).mul(stepsVested)); + + return (amountToVest, currentStepTime, true); + } else { + // Partially vested + uint256 stepsToVest = currentStepTime.sub(uint256(vestingSchedule.lastClaimTime)).div(vestingSchedule.step); + uint256 amountToVest = uint256(vestingSchedule.amount).div(totalSteps).mul(stepsToVest); + + return (amountToVest, currentStepTime, false); + } + } +}