Skip to content

Commit b2fceb7

Browse files
committed
feat: vesting distribution
1 parent 321b479 commit b2fceb7

File tree

9 files changed

+592
-2
lines changed

9 files changed

+592
-2
lines changed

lib/openzeppelin-contracts

remappings.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
ds-test/=lib/solmate/lib/ds-test/src/
22
forge-std/=lib/forge-std/src/
3-
solmate/=lib/solmate/src/
3+
solmate/=lib/solmate/src/
4+
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity 0.8.28;
3+
4+
import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
5+
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
6+
7+
import {MetaToken} from "src/MetaToken.sol";
8+
9+
import {IMetaTokenDistributor} from "./interfaces/IMetaTokenDistributor.sol";
10+
import {Vesting, IVesting} from "./vesting/Vesting.sol";
11+
import {IVestingParams} from "./vesting/interfaces/IVestingParams.sol";
12+
import {Schedule, Beneficiary, VestingType} from "./utils/Common.sol";
13+
14+
contract MetaTokenDistributor is IMetaTokenDistributor {
15+
using SafeERC20 for IERC20;
16+
17+
address private _vestingImpl;
18+
IERC20 private _META;
19+
20+
IVestingParams private _vestingParams;
21+
uint64 private _vestingStartTime;
22+
23+
constructor(address vestingParams, uint64 vestingStartTime) {
24+
if (vestingStartTime < block.timestamp) {
25+
revert InvalidVestingStartTime();
26+
}
27+
28+
if (address(vestingParams) == address(0)) {
29+
revert ZeroAddress();
30+
}
31+
32+
_vestingStartTime = vestingStartTime;
33+
_vestingParams = IVestingParams(vestingParams);
34+
35+
_vestingImpl = address(new Vesting());
36+
_META = IERC20(address(new MetaToken(address(this))));
37+
38+
if (_META.balanceOf(address(this)) != _META.totalSupply()) {
39+
revert IncorrectMetaAmountForDistribution();
40+
}
41+
42+
emit MetaTokenDeployed(address(_META));
43+
}
44+
45+
function startVesting(VestingType vestingType) external returns (address vesting) {
46+
if (block.timestamp < _vestingStartTime) {
47+
revert VestingStartTimeHasNotArrived();
48+
}
49+
50+
vesting = Clones.clone(_vestingImpl);
51+
52+
// TODO: Можно две функции объединить
53+
(
54+
Beneficiary[] memory beneficiaries,
55+
Schedule memory schedule
56+
) = _vestingParams.getBeneficiariesAndSchedule(vestingType);
57+
58+
(uint256 vestingAmount,,,,) = _vestingParams.getVestingParams(vestingType);
59+
60+
_META.safeTransfer(address(vesting), vestingAmount);
61+
IVesting(vesting).initialize(_META, schedule, beneficiaries);
62+
63+
emit VestingStarted(vesting);
64+
}
65+
66+
function getMETA() external view returns (address) {
67+
return address(_META);
68+
}
69+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity 0.8.28;
3+
4+
import {VestingType} from "../utils/Common.sol";
5+
6+
interface IMetaTokenDistributor {
7+
event VestingStarted(address vesting);
8+
event MetaTokenDeployed(address META);
9+
10+
error InvalidVestingStartTime();
11+
error VestingStartTimeHasNotArrived();
12+
error IncorrectMetaAmountForDistribution();
13+
error ZeroAddress();
14+
15+
function startVesting(VestingType vestingType) external returns (address vesting);
16+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity 0.8.28;
3+
4+
struct Period {
5+
uint256 endTime;
6+
uint256 portion;
7+
}
8+
9+
struct Schedule {
10+
uint256 startTime;
11+
Period[] periods;
12+
}
13+
14+
struct Beneficiary {
15+
address account;
16+
uint256 amount;
17+
}
18+
19+
enum VestingType {
20+
TEAM,
21+
LIQUIDITY
22+
}
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity 0.8.28;
3+
4+
import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
5+
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
6+
7+
import {IVesting} from "./interfaces/IVesting.sol";
8+
import {Schedule, Period, Beneficiary} from "../utils/Common.sol";
9+
10+
// TODO: Проверить отчет на предмет замечаний
11+
12+
/**
13+
* @title Vesting contract of the base token
14+
* @dev Each new vesting instance is created through a factory using the Minimal Clones pattern.
15+
* Vesting settings are set at the time of deploying a new instance in the initialize() function.
16+
* Beneficiaries can claim the token according to the schedule by calling the claim() function
17+
*/
18+
contract Vesting is IVesting, Initializable {
19+
using SafeERC20 for IERC20;
20+
21+
/// @notice The factor used to calculate percentages for the vesting schedule period.
22+
/// It is set to 10_000 to handle basis points (0.01% increments)
23+
uint256 private constant _BASIS_POINTS = 10_000; // ex: 10% = 1_000
24+
25+
/// @notice Structure of the vesting schedule params
26+
Schedule private _schedule;
27+
28+
/// @notice Base token that will be vested
29+
IERC20 private _baseToken;
30+
31+
/// @notice The initial amount of tokens in the account that was locked
32+
mapping(address account => uint256 amount) private _initialLocked;
33+
34+
/// @notice The total initial amount of tokens that was locked
35+
uint256 private _initialTotalLocked;
36+
37+
/// @notice The amount of released tokens by the account
38+
mapping(address account => uint256 amount) private _released;
39+
40+
/// @custom:oz-upgrades-unsafe-allow constructor
41+
constructor() {
42+
_disableInitializers();
43+
}
44+
45+
/**
46+
* @notice Initialization function for the vesting contract
47+
* @param baseToken Address of the base token that will be vested
48+
* @param schedule The structure that defines the vesting schedule
49+
* @param beneficiaries List of accounts and amounts to be distributed
50+
*/
51+
function initialize(IERC20 baseToken, Schedule calldata schedule, Beneficiary[] calldata beneficiaries) external initializer {
52+
if (address(baseToken) == address(0)) {
53+
revert ZeroAddress();
54+
}
55+
56+
_baseToken = baseToken;
57+
58+
_initializeSchedule(schedule);
59+
_initializeBeneficiaries(beneficiaries, baseToken);
60+
}
61+
62+
/**
63+
* @notice Claims the base token according to the vesting schedule
64+
* @dev The claimed amount will be store in _released mapping
65+
*/
66+
function claim() external {
67+
Schedule memory schedule = _schedule;
68+
69+
if (block.timestamp < schedule.startTime) {
70+
revert VestingHasNotStarted();
71+
}
72+
73+
uint256 unlockedAmount = availableToClaim(msg.sender);
74+
if (unlockedAmount > 0) {
75+
_released[msg.sender] += unlockedAmount;
76+
_baseToken.safeTransfer(msg.sender, unlockedAmount);
77+
78+
emit Claimed(msg.sender, unlockedAmount);
79+
}
80+
}
81+
82+
// region - Public view function -
83+
84+
/// @notice Retrieves the vesting schedule structure
85+
function getSchedule() external view returns (Schedule memory) {
86+
return _schedule;
87+
}
88+
89+
/// @notice Returns the base token address
90+
function getBaseToken() external view returns (address) {
91+
return address(_baseToken);
92+
}
93+
94+
/// @notice Returns the total amount of unlocked tokens
95+
function totalUnlocked() public view returns (uint256) {
96+
return _computeUnlocked(_initialTotalLocked);
97+
}
98+
99+
/// @notice Returns the total amount of locked tokens
100+
function totalLocked() external view returns (uint256) {
101+
return _initialTotalLocked - totalUnlocked();
102+
}
103+
104+
/// @notice Returns the amount of unlocked tokens for a specific account
105+
function unlockedOf(address account) public view returns (uint256) {
106+
return _computeUnlocked(_initialLocked[account]);
107+
}
108+
109+
/// @notice Returns the amount of locked tokens for a specific account
110+
function lockedOf(address account) external view returns (uint256) {
111+
return _initialLocked[account] - unlockedOf(account);
112+
}
113+
114+
/// @notice Returns the available amount of unlocked tokens that can be claimed by a specific account
115+
function availableToClaim(address account) public view returns (uint256) {
116+
return unlockedOf(account) - _released[account];
117+
}
118+
119+
// endregion
120+
121+
// region - Private functions
122+
123+
/**
124+
* @notice Validates and sets the vesting schedule parameters
125+
* @param schedule The structure that defines the vesting schedule
126+
*/
127+
function _initializeSchedule(Schedule calldata schedule) private {
128+
if (schedule.startTime < block.timestamp) {
129+
revert InvalidStartTime();
130+
}
131+
132+
// Check that every period portion param is valid.
133+
// Variable to keep track of the total percentage of the vesting schedule periods
134+
uint256 totalPercentageOfPortions;
135+
uint256 numberOfPeriods = schedule.periods.length;
136+
for (uint256 i = 0; i < numberOfPeriods; i++) {
137+
Period memory period = schedule.periods[i];
138+
139+
if (period.portion > _BASIS_POINTS) {
140+
revert InvalidPortion();
141+
}
142+
143+
totalPercentageOfPortions += period.portion;
144+
145+
if (i > 0) {
146+
Period memory prevPeriod = schedule.periods[i - 1];
147+
148+
if (prevPeriod.endTime >= period.endTime) {
149+
revert IncorrectPeriodTime();
150+
}
151+
}
152+
}
153+
154+
if (totalPercentageOfPortions != _BASIS_POINTS) {
155+
revert IncorrectTotalPeriodPortions();
156+
}
157+
158+
_schedule.startTime = schedule.startTime;
159+
160+
for (uint256 i = 0; i < schedule.periods.length; i++) {
161+
Period memory period = schedule.periods[i];
162+
_schedule.periods.push(period);
163+
}
164+
165+
emit ScheduleInitialized(schedule);
166+
}
167+
168+
/**
169+
* @notice Validates and sets the list of beneficiaries
170+
* @param beneficiaries List of accounts and amounts to be distributed
171+
* @param baseToken Address of base token for vesting
172+
*/
173+
function _initializeBeneficiaries(Beneficiary[] calldata beneficiaries, IERC20 baseToken) private {
174+
if (beneficiaries.length == 0) {
175+
revert ZeroBeneficiaries();
176+
}
177+
178+
for (uint256 i = 0; i < beneficiaries.length; i++) {
179+
Beneficiary memory beneficiary = beneficiaries[i];
180+
181+
if (beneficiary.account == address(0)) {
182+
revert ZeroAddress();
183+
}
184+
185+
if (beneficiary.amount == 0) {
186+
revert ZeroAmount();
187+
}
188+
189+
if (_initialLocked[beneficiary.account] > 0) {
190+
revert DuplicateBeneficiary(beneficiary.account);
191+
}
192+
193+
_initialLocked[beneficiary.account] = beneficiary.amount;
194+
_initialTotalLocked += beneficiary.amount;
195+
}
196+
197+
if (_initialTotalLocked != baseToken.balanceOf(address(this))) {
198+
revert IncorrectAmountOfBeneficiaries();
199+
}
200+
201+
emit BeneficiariesInitialized(beneficiaries);
202+
}
203+
204+
/**
205+
* @notice Computes the amount of unlocked tokens based on the initial locked amount
206+
* @param initialLockedAmount The total amount of initially locked tokens
207+
* @return unlockedAmount The total amount of unlocked tokens
208+
*/
209+
function _computeUnlocked(uint256 initialLockedAmount) private view returns (uint256 unlockedAmount) {
210+
if (block.timestamp < _schedule.startTime) {
211+
return 0;
212+
}
213+
214+
uint256 startTime = _schedule.startTime;
215+
Period[] memory periods = _schedule.periods;
216+
217+
for (uint256 i = 0; i < periods.length; i++) {
218+
Period memory period = _schedule.periods[i];
219+
220+
uint256 endTime = period.endTime;
221+
uint256 portion = period.portion;
222+
223+
if (block.timestamp < endTime) {
224+
uint256 partPeriod = block.timestamp - startTime;
225+
uint256 fullPeriod = endTime - startTime;
226+
227+
// Calculate the unlocked tokens based on the elapsed time within the period
228+
unlockedAmount += (initialLockedAmount * partPeriod * portion) / (fullPeriod * _BASIS_POINTS);
229+
230+
// Exit the loop since the unlocked tokens for the current period
231+
// have been calculated
232+
break;
233+
} else {
234+
// All tokens for the current period are unlocked
235+
unlockedAmount += (initialLockedAmount * portion) / _BASIS_POINTS;
236+
startTime = endTime;
237+
}
238+
}
239+
}
240+
241+
// endregion
242+
}

0 commit comments

Comments
 (0)