1
// SPDX-License-Identifier: MIT
2
pragma solidity ^0.8.20;
3
4
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
7
8
/**
9
* @title LinearVesting
10
* @author LockItIn DAO
11
* @notice Simple linear token vesting over a fixed duration
12
* @dev Solidity 0.8.20, optimizer ON, runs = 777
13
*
14
* Used for:
15
* - Founder vesting: 40M LOCKIT over 2,777 days (beneficiary = founder wallet)
16
* - DAO vesting: 350M LOCKIT over 4,356 days (beneficiary = DAO Timelock)
17
*
18
* Features:
19
* - Linear release: tokens unlock proportionally over time
20
* - Immutable: token, duration, and minVestingAmount set at deploy
21
* - Pull pattern: beneficiary claims when ready
22
* - Beneficiary transferable (for DAO governance flexibility)
23
* - Excess token rescue (tokens sent after start)
24
* - Dust attack prevention via minVestingAmount
25
*
26
* Deployment:
27
* 1. Deploy with beneficiary, token, duration, and minVestingAmount
28
* 2. Transfer exact vesting amount to this contract
29
* 3. Anyone calls startVesting() to begin the clock (requires minVestingAmount present)
30
* 4. Beneficiary calls release() periodically to claim
31
*
32
* WARNING: Tokens sent AFTER startVesting() are NOT included in vesting.
33
* Use rescueExcessTokens() to recover them.
34
*
35
* VERSION: 1.2 (Final)
36
* CHANGES FROM v1.1:
37
* - Added minVestingAmount constructor param to prevent "dust initialization" bypass
38
* - Made startVesting() permissionless again (safe due to minVestingAmount check)
39
* - This allows DAO vesting to start at TGE without requiring governance proposal
40
* CHANGES FROM v1.0:
41
* - Fixed getVestingInfo() to correctly report _unvested vs _remaining
42
* - Added _vested field to getVestingInfo()
43
*/
44
contract LinearVesting is ReentrancyGuard {
45
using SafeERC20 for IERC20;
46
47
// ═══════════════════════════════════════════════════════════════════════════
48
// ERRORS
49
// ═══════════════════════════════════════════════════════════════════════════
50
51
error InvalidAddress();
52
error InvalidDuration();
53
error InvalidMinAmount();
54
error NothingToRelease();
55
error VestingNotStarted();
56
error VestingAlreadyStarted();
57
error NotBeneficiary();
58
error NoExcessTokens();
59
error BelowMinVestingAmount();
60
61
// ═══════════════════════════════════════════════════════════════════════════
62
// EVENTS
63
// ═══════════════════════════════════════════════════════════════════════════
64
65
event VestingContractDeployed(
66
address indexed beneficiary,
67
address indexed token,
68
uint256 duration,
69
uint256 minVestingAmount
70
);
71
event VestingStarted(
72
uint256 startTime,
73
uint256 endTime,
74
uint256 totalAmount
75
);
76
event TokensReleased(
77
address indexed beneficiary,
78
uint256 amount,
79
uint256 totalReleased
80
);
81
event BeneficiaryUpdated(
82
address indexed oldBeneficiary,
83
address indexed newBeneficiary
84
);
85
event ExcessTokensRescued(
86
address indexed token,
87
uint256 amount,
88
address indexed recipient
89
);
90
91
// ═══════════════════════════════════════════════════════════════════════════
92
// IMMUTABLE STATE
93
// ═══════════════════════════════════════════════════════════════════════════
94
95
IERC20 public immutable token;
96
uint256 public immutable duration; // Vesting duration in seconds
97
uint256 public immutable minVestingAmount; // Minimum required to start vesting
98
99
// ═══════════════════════════════════════════════════════════════════════════
100
// MUTABLE STATE
101
// ═══════════════════════════════════════════════════════════════════════════
102
103
address public beneficiary;
104
uint256 public startTime; // Set when startVesting() called
105
uint256 public totalVestingAmount; // Snapshot of balance at start
106
uint256 public released; // Tokens already released
107
108
// ═══════════════════════════════════════════════════════════════════════════
109
// CONSTRUCTOR
110
// ═══════════════════════════════════════════════════════════════════════════
111
112
/**
113
* @notice Deploy vesting contract
114
* @param _beneficiary Address that receives vested tokens
115
* @param _token ERC20 token to vest
116
* @param _durationSeconds Vesting duration in SECONDS (not days!)
117
* - Founder: 2,777 days = 239,932,800 seconds
118
* - DAO: 4,356 days = 376,358,400 seconds
119
* @param _minVestingAmount Minimum tokens required to start vesting
120
* This prevents "dust initialization" attacks where someone starts
121
* vesting with 1 wei, then the real allocation arrives and becomes
122
* "excess" that can be immediately withdrawn.
123
* Set to exact expected allocation (e.g., 40_000_000e18 for founder)
124
*/
125
constructor(
126
address _beneficiary,
127
address _token,
128
uint256 _durationSeconds,
129
uint256 _minVestingAmount
130
) {
131
if (_beneficiary == address(0)) revert InvalidAddress();
132
if (_token == address(0)) revert InvalidAddress();
133
if (_durationSeconds == 0) revert InvalidDuration();
134
if (_minVestingAmount == 0) revert InvalidMinAmount();
135
136
beneficiary = _beneficiary;
137
token = IERC20(_token);
138
duration = _durationSeconds;
139
minVestingAmount = _minVestingAmount;
140
141
emit VestingContractDeployed(_beneficiary, _token, _durationSeconds, _minVestingAmount);
142
}
143
144
// ═══════════════════════════════════════════════════════════════════════════
145
// VESTING INITIALIZATION
146
// ═══════════════════════════════════════════════════════════════════════════
147
148
/**
149
* @notice Start vesting with current token balance
150
* @dev Can only be called once. Tokens must be transferred before calling.
151
* ANYONE can call - this allows DAO vesting to start at TGE without
152
* requiring a governance proposal.
153
*
154
* Security: The minVestingAmount check prevents "dust initialization"
155
* attacks. Even if an attacker sends 1 wei and tries to start, it reverts.
156
* Vesting can only start when the real allocation is present.
157
*
158
* The contract snapshots whatever balance is present (must be >= min).
159
* Any tokens sent AFTER this call are "excess" and can be rescued.
160
*/
161
function startVesting() external {
162
if (startTime != 0) revert VestingAlreadyStarted();
163
164
uint256 balance = token.balanceOf(address(this));
165
if (balance < minVestingAmount) revert BelowMinVestingAmount();
166
167
startTime = block.timestamp;
168
totalVestingAmount = balance;
169
170
emit VestingStarted(startTime, startTime + duration, balance);
171
}
172
173
// ═══════════════════════════════════════════════════════════════════════════
174
// RELEASE
175
// ═══════════════════════════════════════════════════════════════════════════
176
177
/**
178
* @notice Release vested tokens to beneficiary
179
* @dev Anyone can call, but tokens always go to beneficiary
180
*/
181
function release() external nonReentrant {
182
if (startTime == 0) revert VestingNotStarted();
183
184
uint256 releasable = _releasableAmount();
185
if (releasable == 0) revert NothingToRelease();
186
187
released += releasable;
188
token.safeTransfer(beneficiary, releasable);
189
190
emit TokensReleased(beneficiary, releasable, released);
191
}
192
193
// ═══════════════════════════════════════════════════════════════════════════
194
// BENEFICIARY MANAGEMENT
195
// ═══════════════════════════════════════════════════════════════════════════
196
197
/**
198
* @notice Transfer beneficiary rights to new address
199
* @dev Only current beneficiary can call. Single-step transfer.
200
* For DAO vesting (beneficiary = Timelock), changing this requires
201
* a full governance proposal → vote → timelock delay, which provides
202
* sufficient protection against mistakes.
203
* @param _newBeneficiary New address to receive vested tokens
204
*/
205
function transferBeneficiary(address _newBeneficiary) external {
206
if (msg.sender != beneficiary) revert NotBeneficiary();
207
if (_newBeneficiary == address(0)) revert InvalidAddress();
208
209
emit BeneficiaryUpdated(beneficiary, _newBeneficiary);
210
beneficiary = _newBeneficiary;
211
}
212
213
// ═══════════════════════════════════════════════════════════════════════════
214
// RESCUE FUNCTIONS
215
// ═══════════════════════════════════════════════════════════════════════════
216
217
/**
218
* @notice Rescue excess vesting tokens sent after vesting started
219
* @dev Only beneficiary can call. Only rescues tokens above vesting obligation.
220
* This handles the case where someone accidentally sends more tokens
221
* after startVesting() was called.
222
*/
223
function rescueExcessTokens() external nonReentrant {
224
if (msg.sender != beneficiary) revert NotBeneficiary();
225
if (startTime == 0) revert VestingNotStarted();
226
227
uint256 currentBalance = token.balanceOf(address(this));
228
uint256 remaining = totalVestingAmount - released;
229
230
if (currentBalance <= remaining) revert NoExcessTokens();
231
232
uint256 excess = currentBalance - remaining;
233
token.safeTransfer(beneficiary, excess);
234
235
emit ExcessTokensRescued(address(token), excess, beneficiary);
236
}
237
238
/**
239
* @notice Rescue other tokens accidentally sent to this contract
240
* @dev Only beneficiary can call. Cannot rescue the vesting token.
241
* @param _token Token to rescue
242
*/
243
function rescueOtherTokens(address _token) external nonReentrant {
244
if (msg.sender != beneficiary) revert NotBeneficiary();
245
if (_token == address(token)) revert InvalidAddress(); // Use rescueExcessTokens
246
247
IERC20 otherToken = IERC20(_token);
248
uint256 balance = otherToken.balanceOf(address(this));
249
if (balance == 0) revert NoExcessTokens();
250
251
otherToken.safeTransfer(beneficiary, balance);
252
253
emit ExcessTokensRescued(_token, balance, beneficiary);
254
}
255
256
// ═══════════════════════════════════════════════════════════════════════════
257
// VIEW FUNCTIONS
258
// ═══════════════════════════════════════════════════════════════════════════
259
260
/**
261
* @notice Calculate currently releasable amount
262
*/
263
function releasableAmount() external view returns (uint256) {
264
if (startTime == 0) return 0;
265
return _releasableAmount();
266
}
267
268
/**
269
* @notice Calculate total vested amount (released + releasable)
270
*/
271
function vestedAmount() external view returns (uint256) {
272
if (startTime == 0) return 0;
273
return _vestedAmount();
274
}
275
276
/**
277
* @notice Get complete vesting schedule info
278
* @return _beneficiary Address receiving vested tokens
279
* @return _token Token being vested
280
* @return _startTime When vesting started (0 if not started)
281
* @return _endTime When vesting completes (0 if not started)
282
* @return _duration Vesting duration in seconds
283
* @return _totalAmount Total tokens being vested
284
* @return _released Tokens already claimed
285
* @return _releasable Tokens available to claim now
286
* @return _vested Total tokens that have vested (time-based)
287
* @return _unvested Tokens that have NOT yet vested
288
* @return _remaining Total unclaimed tokens (vested + unvested)
289
*/
290
function getVestingInfo() external view returns (
291
address _beneficiary,
292
address _token,
293
uint256 _startTime,
294
uint256 _endTime,
295
uint256 _duration,
296
uint256 _totalAmount,
297
uint256 _released,
298
uint256 _releasable,
299
uint256 _vested,
300
uint256 _unvested,
301
uint256 _remaining
302
) {
303
_beneficiary = beneficiary;
304
_token = address(token);
305
_startTime = startTime;
306
_endTime = startTime == 0 ? 0 : startTime + duration;
307
_duration = duration;
308
_totalAmount = totalVestingAmount;
309
_released = released;
310
311
if (startTime == 0) {
312
_releasable = 0;
313
_vested = 0;
314
_unvested = 0;
315
_remaining = 0;
316
} else {
317
uint256 vested = _vestedAmount();
318
_releasable = vested - released;
319
_vested = vested;
320
_unvested = totalVestingAmount - vested;
321
_remaining = totalVestingAmount - released;
322
}
323
}
324
325
/**
326
* @notice Get the minimum vesting amount required to start
327
* @dev Useful for UIs to show funding requirements
328
*/
329
function getMinVestingAmount() external view returns (uint256) {
330
return minVestingAmount;
331
}
332
333
/**
334
* @notice Check if vesting has started
335
*/
336
function isStarted() external view returns (bool) {
337
return startTime != 0;
338
}
339
340
/**
341
* @notice Check if contract is ready to start (has minimum funding)
342
*/
343
function isReadyToStart() external view returns (bool) {
344
if (startTime != 0) return false; // Already started
345
return token.balanceOf(address(this)) >= minVestingAmount;
346
}
347
348
/**
349
* @notice Check if vesting is fully complete
350
*/
351
function isComplete() external view returns (bool) {
352
if (startTime == 0) return false;
353
return block.timestamp >= startTime + duration;
354
}
355
356
/**
357
* @notice Get time remaining until fully vested
358
* @return Seconds remaining (0 if complete or not started)
359
*/
360
function timeRemaining() external view returns (uint256) {
361
if (startTime == 0) return 0;
362
uint256 endTime = startTime + duration;
363
if (block.timestamp >= endTime) return 0;
364
return endTime - block.timestamp;
365
}
366
367
/**
368
* @notice Get vesting progress as basis points (0-10000)
369
* @return Progress in bps (10000 = 100%)
370
*/
371
function progressBps() external view returns (uint256) {
372
if (startTime == 0) return 0;
373
if (block.timestamp >= startTime + duration) return 10000;
374
uint256 elapsed = block.timestamp - startTime;
375
return (elapsed * 10000) / duration;
376
}
377
378
// ═══════════════════════════════════════════════════════════════════════════
379
// INTERNAL
380
// ═══════════════════════════════════════════════════════════════════════════
381
382
function _vestedAmount() internal view returns (uint256) {
383
if (block.timestamp >= startTime + duration) {
384
return totalVestingAmount;
385
}
386
return (totalVestingAmount * (block.timestamp - startTime)) / duration;
387
}
388
389
function _releasableAmount() internal view returns (uint256) {
390
return _vestedAmount() - released;
391
}
392
}