diff --git a/packages/supplementary-contracts/contracts/TokenUnlocking.sol b/packages/supplementary-contracts/contracts/TokenUnlocking.sol index 46764149a25..25d50d4e2cf 100644 --- a/packages/supplementary-contracts/contracts/TokenUnlocking.sol +++ b/packages/supplementary-contracts/contracts/TokenUnlocking.sol @@ -1,3 +1,235 @@ +// SPDX-License-Identifier: MIT pragma solidity 0.8.24; -contract TokenUnlocking { } +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.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.) +/// @custom:security-contact security@taiko.xyz +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. + 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 + ); + + /// @notice Emitted when tokens are withdrawn. + /// @param recipient The grant recipient address. + /// @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); + + error INVALID_GRANTEE(); + error INVALID_PARAM(); + error WRONG_GRANTEE_RECIPIENT(); + + /// @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. + function init( + address _owner, + address _taikoToken, + address _sharedVault, + address _grantRecipient, + uint64 _tgeTimestamp + ) + external + initializer + { + if ( + _taikoToken == address(0) || _sharedVault == address(0) || _grantRecipient == 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); + + 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(); + + // 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 += _currentDeposit; + + emit VestTokenTriggered(_recipient, _currentDeposit, amountVested); + } + + /// @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; + + // _to address get's the tokens + IERC20(taikoToken).safeTransfer(recipient, amountToWithdraw); + + emit Withdrawn(recipient, amountToWithdraw, amountWithdrawn); + } + + /// @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_; + } + + function getTgeTimestamp() public view virtual returns (uint64) { + return tgeTimestamp; + } + + function getCliffEndTimestamp() public view virtual returns (uint64) { + return (getTgeTimestamp() + 365 days); + } + + function getUnlockPeriod() public view virtual returns (uint32) { + return (4 * 365 days); + } + + 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; + } +} diff --git a/packages/supplementary-contracts/script/DeployTokenUnlocking.s.sol b/packages/supplementary-contracts/script/DeployTokenUnlocking.s.sol new file mode 100644 index 00000000000..dc55d254f88 --- /dev/null +++ b/packages/supplementary-contracts/script/DeployTokenUnlocking.s.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "forge-std/src/Script.sol"; +import "forge-std/src/console2.sol"; + +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 TAIKO_TOKEN = vm.envAddress("TAIKO_TOKEN"); + address public SHARED_TOKEN_VAULT = vm.envAddress("SHARED_TOKEN_VAULT"); + address public GRANTEE = vm.envAddress("GRANTEE"); + uint256 public TGE = vm.envUint("TGE_TIMESTAMP"); + + address tokenUnlocking; + + function setUp() public { } + + function run() external { + vm.startBroadcast(); + tokenUnlocking = deployProxy({ + impl: address(new TokenUnlocking()), + data: abi.encodeCall( + TokenUnlocking.init, + (CONTRACT_OWNER, TAIKO_TOKEN, SHARED_TOKEN_VAULT, GRANTEE, uint64(TGE)) + ) + }); + vm.stopBroadcast(); + } + + function deployProxy(address impl, bytes memory data) public returns (address proxy) { + proxy = address(new ERC1967Proxy(impl, data)); + + console2.log(" proxy :", proxy); + console2.log(" impl :", impl); + } +} diff --git a/packages/supplementary-contracts/test/TokenUnlocking.t.sol b/packages/supplementary-contracts/test/TokenUnlocking.t.sol new file mode 100644 index 00000000000..72b9efbdba3 --- /dev/null +++ b/packages/supplementary-contracts/test/TokenUnlocking.t.sol @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "forge-std/src/Test.sol"; +import "forge-std/src/console2.sol"; + +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import "../contracts/TokenUnlocking.sol"; + +contract MyERC20 is ERC20 { + constructor(address owner) ERC20("Taiko Token", "TKO") { + _mint(owner, 1_000_000_000e18); + } +} + +contract TestTokenUnlocking is Test { + // Owner of the + address internal Owner = vm.addr(0x1); + + /* Let's assume Alice has 100_000 tokens which vests over 4 years, quarterly. + Alice started at company 1 year BEFORE TGE, so actually at TGE she has 25_000 vested (and + deposited) already */ + address internal Alice = vm.addr(0x2); + + /* Let's assume Alice has 16_000 tokens which vests over 4 years, quarterly. + Alice started at company at TGE, so actually at TGE she has 0 vested. First deposit will be + after a quarter past TGE (not considering his vesting cliff could be half year, it will be done + off-chain anyways) */ + address internal Bob = vm.addr(0x3); + + address internal Vault = vm.addr(0x4); + + uint64 tgeTimestamp = 1_713_564_000; // = 2024.04.20. 00:00:00 + + ERC20 tko = new MyERC20(Vault); + + uint128 public constant ONE_TKO_UNIT = 1e18; + + TokenUnlocking tokenUnlockingAlice; + TokenUnlocking tokenUnlockingBob; + + function setUp() public { + vm.warp(tgeTimestamp); + + tokenUnlockingAlice = TokenUnlocking( + deployProxy({ + impl: address(new TokenUnlocking()), + data: abi.encodeCall( + TokenUnlocking.init, (Owner, address(tko), Vault, Alice, tgeTimestamp) + ) + }) + ); + tokenUnlockingBob = TokenUnlocking( + deployProxy({ + impl: address(new TokenUnlocking()), + data: abi.encodeCall( + TokenUnlocking.init, (Owner, address(tko), Vault, Bob, tgeTimestamp) + ) + }) + ); + } + + function test_invalid_grantee() public { + vm.startPrank(Owner); + vm.expectRevert(TokenUnlocking.INVALID_GRANTEE.selector); + // Cannot call if not Alice is the recipient + tokenUnlockingAlice.vestToken(Bob, 25_000); + vm.stopPrank(); + } + + function test_wrong_grantee_recipient() public { + vm.startPrank(Bob); + vm.expectRevert(TokenUnlocking.WRONG_GRANTEE_RECIPIENT.selector); + // Cannot call if not Alice is the recipient + tokenUnlockingAlice.withdraw(); + vm.stopPrank(); + } + + function test_Bobs_unlocking() public { + // Vault has to approve Alice's unlocking contract before calling the vestToken() function. + vm.prank(Vault, Vault); + tko.approve(address(tokenUnlockingBob), 25_000); + + vm.startPrank(Owner); + // At TGE Bob has nothing + ( + uint128 amountVested, + uint128 amountUnlocked, + uint128 amountWithdrawn, + uint128 amountToWithdraw + ) = tokenUnlockingBob.getMyGrantSummary(Bob); + + assertEq(amountVested, 0); + assertEq(amountUnlocked, 0); + assertEq(amountWithdrawn, 0); + assertEq(amountToWithdraw, 0); + + // 1 quarter after TGE, Bob get's 1/16 of his tokens (Since vesting is querterly during the + // 4 year unlcok period after TGE) + // Vault has to approve Bob's unlocking contract before calling the vestToken() function. + vm.warp(tgeTimestamp + 90 days + 1); + + vm.stopPrank(); + + vm.prank(Vault, Vault); + tko.approve(address(tokenUnlockingBob), 1000); + + vm.startPrank(Owner); + tokenUnlockingBob.vestToken(Bob, 1000); + + // Bob has some amount vested, but not unlocked, since we are below the 1 year unlock cliff + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingBob.getMyGrantSummary(Bob); + + assertEq(amountVested, 1000); + assertEq(amountUnlocked, 0); + assertEq(amountWithdrawn, 0); + assertEq(amountToWithdraw, 0); + + vm.stopPrank(); + + // Ok, let's imitate there were 3 other tokenVests in that rest of that year + vm.prank(Vault, Vault); + tko.approve(address(tokenUnlockingBob), 3000); //The missing 3000 of 4000 per year + + vm.startPrank(Owner); + tokenUnlockingBob.vestToken(Bob, 3000); + + // 1 year elapsed, 25% of that 25K shall be unlocked + vm.warp(tgeTimestamp + 365 days + 1); + + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingBob.getMyGrantSummary(Bob); + + assertEq(amountVested, 4000); + assertEq(amountUnlocked, 4000 / 4); + assertEq(amountWithdrawn, 0); + assertEq(amountToWithdraw, 4000 / 4); + + vm.stopPrank(); + + vm.startPrank(Bob); + // Bob now withdraws + tokenUnlockingBob.withdraw(); + + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingBob.getMyGrantSummary(Bob); + + assertEq(amountVested, 4000); + assertEq(amountUnlocked, 4000 / 4); + assertEq(amountWithdrawn, 4000 / 4); + assertEq(amountToWithdraw, 0); + + vm.stopPrank(); + + // Ok, let's imitate again a quarterly vesting (but in 1 go now, for the sake of simplicity) + vm.prank(Vault, Vault); + tko.approve(address(tokenUnlockingBob), 4000); + + vm.startPrank(Owner); + tokenUnlockingBob.vestToken(Bob, 4000); + vm.stopPrank(); + + vm.startPrank(Bob); + // 2 year elapsed, 50% of that 16K shall be unlocked + vm.warp(tgeTimestamp + 2 * 365 days + 1); + + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingBob.getMyGrantSummary(Bob); + + assertEq(amountVested, 8000); + assertEq(amountUnlocked, 8000 / 2); + assertEq(amountWithdrawn, 8000 / 8); // only 1/4 of that 4K is withdrawn, so 1/8 of 8k + assertEq(amountToWithdraw, (amountUnlocked - amountWithdrawn)); + + // Bob now withdraws again after year 2 + tokenUnlockingBob.withdraw(); + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingBob.getMyGrantSummary(Bob); + + assertEq(amountVested, 8000); + assertEq(amountUnlocked, 8000 / 2); + assertEq(amountWithdrawn, 8000 / 2); + assertEq(amountToWithdraw, 0); + + vm.stopPrank(); + + // Ok, let's imitate again a quarterly vesting (for the sake of simplicity) + // Now 2 year elapses.. (So we will be at 4 year post TGE) + vm.prank(Vault, Vault); + tko.approve(address(tokenUnlockingBob), 8000); + + vm.startPrank(Owner); + tokenUnlockingBob.vestToken(Bob, 8000); + vm.stopPrank(); + + vm.startPrank(Bob); + + // 4 year elapsed, 100% of that 16K shall be unlocked + vm.warp(tgeTimestamp + 4 * 365 days + 1); + + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingBob.getMyGrantSummary(Bob); + + assertEq(amountVested, 16_000); + assertEq(amountUnlocked, 16_000); + assertEq(amountWithdrawn, 8000 / 2); // Let's assume between year2 and year4, there were no + // withdrawals + assertEq(amountToWithdraw, (amountUnlocked - amountWithdrawn)); + + // Bob now withdraws again after year 4 + tokenUnlockingBob.withdraw(); + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingBob.getMyGrantSummary(Bob); + + assertEq(amountVested, 16_000); + assertEq(amountUnlocked, 16_000); + assertEq(amountWithdrawn, 16_000); + assertEq(amountToWithdraw, 0); + + vm.stopPrank(); + } + + function test_Alice_leaves_at_tge_so_vests_only_25_percent_of_her_allocation_linearly() + public + { + // Vault has to approve Alice's unlocking contract before calling the vestToken() function. + vm.prank(Vault, Vault); + tko.approve(address(tokenUnlockingAlice), 25_000); + + vm.startPrank(Owner); + // So if Alice left at TGE, she only vested 25K tokens. (Since she joined 1 year pre TGE) + // Let's see how it unlocks + tokenUnlockingAlice.vestToken(Alice, 25_000); + + // 1 year elapsed, 25% of that 25K shall be unlocked + vm.warp(tgeTimestamp + 365 days + 1); + + ( + uint128 amountVested, + uint128 amountUnlocked, + uint128 amountWithdrawn, + uint128 amountToWithdraw + ) = tokenUnlockingAlice.getMyGrantSummary(Alice); + + assertEq(amountVested, 25_000); + assertEq(amountUnlocked, 25_000 / 4); + assertEq(amountWithdrawn, 0); + assertEq(amountToWithdraw, 25_000 / 4); + + vm.stopPrank(); + + vm.startPrank(Alice); + // Alice now withdraws + tokenUnlockingAlice.withdraw(); + + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingAlice.getMyGrantSummary(Alice); + + assertEq(amountVested, 25_000); + assertEq(amountUnlocked, 25_000 / 4); + assertEq(amountWithdrawn, 25_000 / 4); + assertEq(amountToWithdraw, 0); + + // 2 year elapsed, 50% of that 25K shall be unlocked + vm.warp(tgeTimestamp + 2 * 365 days + 1); + + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingAlice.getMyGrantSummary(Alice); + + // console2.log("After 2 years, before withdraw"); + // console2.log(amountVested); + // console2.log(amountUnlocked); + // console2.log(amountWithdrawn); + // console2.log(amountToWithdraw); + + assertEq(amountVested, 25_000); + assertEq(amountUnlocked, 25_000 / 2); + assertEq(amountWithdrawn, 25_000 / 4); + assertEq(amountToWithdraw, 25_000 / 4); + + // Alice now withdraws again after year 2 + tokenUnlockingAlice.withdraw(); + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingAlice.getMyGrantSummary(Alice); + + // console2.log("After 2 years, and after withdraw"); + // console2.log(amountVested); + // console2.log(amountUnlocked); + // console2.log(amountWithdrawn); + // console2.log(amountToWithdraw); + + assertEq(amountVested, 25_000); + assertEq(amountUnlocked, 25_000 / 2); + assertEq(amountWithdrawn, 25_000 / 2); + assertEq(amountToWithdraw, 0); + + // 3 year elapsed, 75% of that 25K shall be unlocked + vm.warp(tgeTimestamp + 3 * 365 days + 1); + + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingAlice.getMyGrantSummary(Alice); + + assertEq(amountVested, 25_000); + assertEq(amountUnlocked, (25_000 / 2) + (25_000 / 4)); // 50% + 25% = 75% + assertEq(amountWithdrawn, 25_000 / 2); + assertEq(amountToWithdraw, 25_000 / 4); + + // Alice now withdraws again after year 3 + tokenUnlockingAlice.withdraw(); + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingAlice.getMyGrantSummary(Alice); + + assertEq(amountVested, 25_000); + assertEq(amountUnlocked, (25_000 / 2) + (25_000 / 4)); + assertEq(amountWithdrawn, (25_000 / 2) + (25_000 / 4)); + assertEq(amountToWithdraw, 0); + + // 4 year elapsed, 100% of that 25K shall be unlocked + vm.warp(tgeTimestamp + 4 * 365 days + 1); + + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingAlice.getMyGrantSummary(Alice); + + assertEq(amountVested, 25_000); + assertEq(amountUnlocked, 25_000); + assertEq(amountWithdrawn, (25_000 / 2) + (25_000 / 4)); + assertEq(amountToWithdraw, 25_000 / 4); + + // Alice now withdraws again after year 3 + tokenUnlockingAlice.withdraw(); + (amountVested, amountUnlocked, amountWithdrawn, amountToWithdraw) = + tokenUnlockingAlice.getMyGrantSummary(Alice); + + assertEq(amountVested, 25_000); + assertEq(amountUnlocked, 25_000); + assertEq(amountWithdrawn, 25_000); + assertEq(amountToWithdraw, 0); + + vm.stopPrank(); + } + + function deployProxy(address impl, bytes memory data) public returns (address proxy) { + proxy = address(new ERC1967Proxy(impl, data)); + + console2.log(" proxy :", proxy); + console2.log(" impl :", impl); + } +}