Skip to content

Commit

Permalink
feat(supplementary-contracts): refactor TokenUnlocking for simplicity…
Browse files Browse the repository at this point in the history
… and enable delegation (#16892)
  • Loading branch information
dantaik authored Apr 29, 2024
1 parent 882457b commit bcb0067
Show file tree
Hide file tree
Showing 3 changed files with 278 additions and 497 deletions.
245 changes: 68 additions & 177 deletions packages/supplementary-contracts/contracts/TokenUnlocking.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@ import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "../contracts/TokenUnlocking.sol";

contract DeployTokenUnlocking is Script {
address public CONTRACT_OWNER = vm.envAddress("TAIKO_LABS_MULTISIG");
address public OWNER = vm.envAddress("OWNER");
address public TAIKO_TOKEN = vm.envAddress("TAIKO_TOKEN");
address public SHARED_TOKEN_VAULT = vm.envAddress("SHARED_TOKEN_VAULT");
address public GRANTEE = vm.envAddress("GRANTEE");
address public RECIPIENT = vm.envAddress("RECIPIENT");
uint256 public TGE = vm.envUint("TGE_TIMESTAMP");

address tokenUnlocking;
Expand All @@ -23,10 +22,7 @@ contract DeployTokenUnlocking is Script {
vm.startBroadcast();
tokenUnlocking = deployProxy({
impl: address(new TokenUnlocking()),
data: abi.encodeCall(
TokenUnlocking.init,
(CONTRACT_OWNER, TAIKO_TOKEN, SHARED_TOKEN_VAULT, GRANTEE, uint64(TGE))
)
data: abi.encodeCall(TokenUnlocking.init, (OWNER, TAIKO_TOKEN, RECIPIENT, uint64(TGE)))
});
vm.stopBroadcast();
}
Expand Down
Loading

0 comments on commit bcb0067

Please sign in to comment.