-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(supplementary-contracts): refactor TokenUnlocking for simplicity…
… and enable delegation (#16892)
- Loading branch information
Showing
3 changed files
with
278 additions
and
497 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,233 +3,124 @@ pragma solidity 0.8.24; | |
|
||
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; | ||
import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; | ||
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; | ||
import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | ||
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; | ||
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; | ||
|
||
/// @title TokenUnlocking | ||
/// @notice Contract for managing Taiko token unlocking. | ||
/// | ||
/// It manages only unlocking and vested tokens will be deposited into this contract (through | ||
/// 'depositToGrantee()' function) when the purchase notice sent out by Taiko, is paid. Unlocking | ||
/// will be a 4-year immutable period, with a 1-year cliff, counting from TGE. | ||
/// | ||
/// Vesting will be a regular (quarterly / twice a year, TBD) 'off-chain' legal payment action, | ||
/// where those purschase notices can be exercised (and paid up-front, so before depoists made). | ||
/// Once tokens are deposited into this contract it cannot be forfeited anymore. If an | ||
/// employment is ended, the company (Taiko) simply does not send out purchase notices anymore, so | ||
/// that more tokens would not be deposited, beside the eligible proportion of that same vesting | ||
/// release period (e.g.: if Bob spent 1 month at Taiko out of that quarterly/half-yearly vesting, | ||
/// those will be deposited of course). | ||
/// | ||
/// We should deploy multiple instances of this contract per grantee and per grant. So each person | ||
/// should have the same amount of contract deployed as grants granted (grant 1, grant 2, etc.) | ||
/// @notice Manages the linear unlocking of Taiko tokens over a four-year period. | ||
/// Tokens purchased off-chain are deposited into this contract directly from the `msg.sender` | ||
/// address. Token withdrawals are permitted linearly over four years starting from the Token | ||
/// Generation Event (TGE), with no withdrawals allowed during the first year. | ||
/// A separate instance of this contract is deployed for each recipient. | ||
/// @custom:security-contact [email protected] | ||
contract TokenUnlocking is OwnableUpgradeable, ReentrancyGuardUpgradeable { | ||
using SafeERC20 for IERC20; | ||
|
||
/// @notice It is basically the same as "amount deposited" or "total amount vested" (so | ||
/// far). | ||
uint128 amountVested; | ||
/// @notice Represents how many tokens withdrawn already and helps with: withdrawable | ||
/// amount. | ||
// - The current "withdrawable amount" is determined by the help of this variable = | ||
// (amountVested *(% of unlocked) ) - amountWithdrawn | ||
uint128 amountWithdrawn; | ||
/// @notice The address of the recipient. | ||
address grantRecipient; | ||
/// @notice For tests or sub-contracts, getTgeTimestamp() can be overridden. | ||
uint64 tgeTimestamp; | ||
/// @notice The Taiko token address. | ||
uint256 public constant ONE_YEAR = 365 days; | ||
uint256 public constant FOUR_YEARS = 4 * ONE_YEAR; | ||
|
||
uint256 public amountVested; | ||
uint256 public amountWithdrawn; | ||
address public recipient; | ||
address public taikoToken; | ||
/// @notice The shared vault address, from which tko token deposits will be triggered by the | ||
/// depositToGrantee() function | ||
address public sharedVault; | ||
|
||
uint256[45] private __gap; | ||
|
||
/// @notice Emitted when the grant contract is set up with correct dates. | ||
/// @param recipient The grant recipient. | ||
/// @param unlockStartDate The TGE date. | ||
/// @param unlockCliffDate The end date of cliff period. | ||
/// @param unlockPeriod The unlock period. | ||
event GrantInitialized( | ||
address indexed recipient, | ||
uint64 unlockStartDate, | ||
uint64 unlockCliffDate, | ||
uint32 unlockPeriod | ||
); | ||
|
||
/// @notice Emitted during vesting events. | ||
/// @param recipient The grant recipient address. | ||
/// @param currentDeposit The current deposited tko amount. | ||
/// @param totalVestedAmount The total vested amount so far. | ||
event VestTokenTriggered( | ||
address indexed recipient, uint128 currentDeposit, uint128 totalVestedAmount | ||
); | ||
uint64 public tgeTimestamp; | ||
|
||
uint256[46] private __gap; | ||
|
||
/// @notice Emitted when token is vested. | ||
/// @param amount The newly vested amount. | ||
event TokenVested(uint256 amount); | ||
|
||
/// @notice Emitted when tokens are withdrawn. | ||
/// @param recipient The grant recipient address. | ||
/// @param to The address tokens will be sent to. | ||
/// @param amount The amount of tokens withdrawn. | ||
/// @param allAmountWithdrawn The all amount (including the current) already withdrawn. | ||
event Withdrawn(address indexed recipient, uint128 amount, uint128 allAmountWithdrawn); | ||
event TokenWithdrawn(address to, uint256 amount); | ||
|
||
error INVALID_GRANTEE(); | ||
error INVALID_PARAM(); | ||
error WRONG_GRANTEE_RECIPIENT(); | ||
error NOT_WITHDRAWABLE(); | ||
error PERMISSION_DENIED(); | ||
|
||
modifier onlyRecipient() { | ||
if (msg.sender != recipient) revert PERMISSION_DENIED(); | ||
_; | ||
} | ||
|
||
/// @custom:oz-upgrades-unsafe-allow constructor | ||
constructor() { | ||
_disableInitializers(); | ||
} | ||
|
||
/// @notice Initializes the contract. | ||
/// @param _owner The contract owner address. | ||
/// @param _taikoToken The Taiko token address. | ||
/// @param _sharedVault The shared vault address. | ||
/// @param _grantRecipient Who will be the grantee for this contract. | ||
/// @param _recipient Who will be the grantee for this contract. | ||
/// @param _tgeTimestamp The token generation event timestamp. | ||
function init( | ||
address _owner, | ||
address _taikoToken, | ||
address _sharedVault, | ||
address _grantRecipient, | ||
address _recipient, | ||
uint64 _tgeTimestamp | ||
) | ||
external | ||
initializer | ||
{ | ||
if ( | ||
_taikoToken == address(0) || _sharedVault == address(0) || _grantRecipient == address(0) | ||
|| _tgeTimestamp == 0 | ||
) { | ||
if (_taikoToken == address(0) || _recipient == address(0) || _tgeTimestamp == 0) { | ||
revert INVALID_PARAM(); | ||
} | ||
|
||
// OZ 4.9.6. version does not allow param setting with __Ownable_init(), so we transfer the | ||
// ownership afterwards. | ||
__Ownable_init(); | ||
_transferOwnership(_owner); | ||
|
||
recipient = _recipient; | ||
taikoToken = _taikoToken; | ||
sharedVault = _sharedVault; | ||
|
||
// Initializing here, that the contract belongs to this grant recipient, and TGE starts or | ||
// started at _tgeTimestamp. | ||
grantRecipient = _grantRecipient; | ||
tgeTimestamp = _tgeTimestamp; | ||
|
||
emit GrantInitialized( | ||
_grantRecipient, _tgeTimestamp, getCliffEndTimestamp(), getUnlockPeriod() | ||
); | ||
} | ||
|
||
/// @notice Triggers a deposits through the vault to this contract. | ||
/// This transaction should happen on a regular basis, e.g.: quarterly. | ||
/// @param _recipient The grant recipient address. | ||
/// @param _currentDeposit The current deposit. | ||
function vestToken( | ||
address _recipient, | ||
uint128 _currentDeposit | ||
) | ||
external | ||
onlyOwner | ||
nonReentrant | ||
{ | ||
if (_recipient != grantRecipient) revert INVALID_GRANTEE(); | ||
/// @notice Vest certain tokens to this contract. | ||
/// @param _amount The newly vested amount | ||
function vest(uint128 _amount) external onlyOwner nonReentrant { | ||
if (_amount == 0) revert INVALID_PARAM(); | ||
|
||
// This contract shall be appproved() on the sharedVault for the given _currentDeposit | ||
// amount | ||
// This is needed, because this is the way we can be sure, we know exactly how much vested | ||
// already. Simple transfer from TaikoTreasury will not update anything hence it does not | ||
// trigger receive() or fallback(). | ||
IERC20(taikoToken).safeTransferFrom(sharedVault, address(this), _currentDeposit); | ||
amountVested += _amount; | ||
emit TokenVested(_amount); | ||
|
||
amountVested += _currentDeposit; | ||
|
||
emit VestTokenTriggered(_recipient, _currentDeposit, amountVested); | ||
IERC20(taikoToken).safeTransferFrom(msg.sender, address(this), _amount); | ||
} | ||
|
||
/// @notice Withdraws all withdrawable tokens. | ||
function withdraw() external nonReentrant { | ||
address recipient = msg.sender; | ||
if (recipient != grantRecipient) { | ||
// This unlocking contract is not for the supplied _recipient, so revert. | ||
revert WRONG_GRANTEE_RECIPIENT(); | ||
} | ||
|
||
(,,, uint128 amountToWithdraw) = getMyGrantSummary(recipient); | ||
|
||
amountWithdrawn += amountToWithdraw; | ||
/// @param _to The address the token will be sent to. | ||
function withdraw(address _to) external onlyRecipient nonReentrant { | ||
uint256 amount = amountWithdrawable(); | ||
if (amount == 0) revert NOT_WITHDRAWABLE(); | ||
|
||
// _to address get's the tokens | ||
IERC20(taikoToken).safeTransfer(recipient, amountToWithdraw); | ||
amountWithdrawn += amount; | ||
address to = _to == address(0) ? recipient : _to; | ||
emit TokenWithdrawn(to, amount); | ||
|
||
emit Withdrawn(recipient, amountToWithdraw, amountWithdrawn); | ||
IERC20(taikoToken).safeTransfer(to, amount); | ||
} | ||
|
||
/// @notice Returns the summary of the grant for a given recipient. Does not reverts if this | ||
/// contract does not belong to _recipient, but returns all 0. | ||
/// @param _recipient The supposed recipient. | ||
/// @return amountVested_ The overall amount vested (including the already withdrawn). | ||
/// @return amountUnlocked_ The overall amount unlocked (including the already withdrawn). | ||
/// @return amountWithdrawn_ Already withdrawn amount. | ||
/// @return amountToWithdraw_ Currently withdrawable. | ||
function getMyGrantSummary(address _recipient) | ||
public | ||
view | ||
returns ( | ||
uint128 amountVested_, | ||
uint128 amountUnlocked_, | ||
uint128 amountWithdrawn_, | ||
uint128 amountToWithdraw_ | ||
) | ||
{ | ||
if (_recipient != grantRecipient) { | ||
// This unlocking contract is not for the supplied _recipient, so obviously 0 | ||
// everywhere. | ||
return (0, 0, 0, 0); | ||
} | ||
|
||
amountVested_ = amountVested; | ||
/// @notice Amount unlocked obviously represents the all unlocked per vested tokens so: | ||
/// (amountUnlocked >= amountToWithdraw) && (amountUnlocked >= amountWithdrawn) -> Always | ||
/// true. Because there might be some amount already withdrawn, but amountUnlocked does not | ||
/// take into account that amount (!). | ||
amountUnlocked_ = _calcAmountUnlocked( | ||
amountVested, getTgeTimestamp(), getCliffEndTimestamp(), getUnlockPeriod() | ||
); | ||
|
||
amountWithdrawn_ = amountWithdrawn; | ||
|
||
amountToWithdraw_ = amountUnlocked_ - amountWithdrawn_; | ||
/// @notice Delegates token voting right to a delegatee. | ||
/// @param _delegatee The delegatee to receive the voting right. | ||
function delegate(address _delegatee) external onlyRecipient nonReentrant { | ||
ERC20VotesUpgradeable(taikoToken).delegate(_delegatee); | ||
} | ||
|
||
function getTgeTimestamp() public view virtual returns (uint64) { | ||
return tgeTimestamp; | ||
/// @notice Returns the amount of token withdrawable. | ||
/// @return The amount of token withdrawable. | ||
function amountWithdrawable() public view returns (uint256) { | ||
return _getAmountUnlocked() - amountWithdrawn; | ||
} | ||
|
||
function getCliffEndTimestamp() public view virtual returns (uint64) { | ||
return (getTgeTimestamp() + 365 days); | ||
} | ||
function _getAmountUnlocked() private view returns (uint256) { | ||
uint256 _amountVested = amountVested; | ||
if (_amountVested == 0) return 0; | ||
|
||
function getUnlockPeriod() public view virtual returns (uint32) { | ||
return (4 * 365 days); | ||
} | ||
uint256 _tgeTimestamp = tgeTimestamp; | ||
|
||
function _calcAmountUnlocked( | ||
uint128 _amount, | ||
uint64 _start, | ||
uint64 _cliff, | ||
uint64 _period | ||
) | ||
private | ||
view | ||
returns (uint128) | ||
{ | ||
if (_amount == 0) return 0; | ||
if (_start == 0) return _amount; | ||
if (block.timestamp <= _start) return 0; | ||
// Remember! Cliff can be (theoretically) 0 | ||
if (_cliff != 0 && block.timestamp <= _cliff) return 0; | ||
// Remember! Period can also be theoretically 0 | ||
if (_period == 0) return _amount; | ||
if (block.timestamp >= _start + _period) return _amount; | ||
// Else, calculate the proportion | ||
return _amount * uint64(block.timestamp - _start) / _period; | ||
if (block.timestamp < _tgeTimestamp + ONE_YEAR) return 0; | ||
if (block.timestamp >= _tgeTimestamp + FOUR_YEARS) return _amountVested; | ||
return _amountVested * (block.timestamp - _tgeTimestamp) / FOUR_YEARS; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.