diff --git a/.github/workflows/extendstakingcron.yml b/.github/workflows/extendstakingcron.yml new file mode 100644 index 000000000..6d40ed8dc --- /dev/null +++ b/.github/workflows/extendstakingcron.yml @@ -0,0 +1,51 @@ +name: Extend Staking + +on: + schedule: + # The cron job should run every four weeks from the creation of four year vesting contract + # and only for 52 weeks + - cron: "30 10 * * FRI" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - name: Setup node.js + uses: actions/setup-node@v1 + with: + node-version: "14.x" + - name: Cache node modules + uses: actions/cache@v2 + env: + cache-name: cache-node-modules + with: + # npm cache files are stored in `~/.npm` on Linux/macOS + path: ~/.npm + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + + - name: Cache compiler installations + uses: actions/cache@v2 + with: + path: | + ~/.solcx + ~/.vvm + key: ${{ runner.os }}-compiler-cache + + - name: Install python dependencies + run: pip install -r requirements.txt + + - name: Extend Staking + run: echo $REWARDS_CRON && brownie networks import network-config.yaml true && brownie run scripts/fouryearvesting/extendStakingCron.py --network=rsk-mainnet + env: + REWARDS_CRON: 1 + FEE_CLAIMER: ${{secrets.FEE_CLAIMER}} diff --git a/contracts/governance/Vesting/VestingRegistryLogic.sol b/contracts/governance/Vesting/VestingRegistryLogic.sol index 57e1f5e7a..11114d79c 100644 --- a/contracts/governance/Vesting/VestingRegistryLogic.sol +++ b/contracts/governance/Vesting/VestingRegistryLogic.sol @@ -102,6 +102,45 @@ contract VestingRegistryLogic is VestingRegistryStorage { } } + /** + * @notice adds four year vestings to vesting registry logic + * @param _tokenOwners array of token owners + * @param _vestingAddresses array of vesting addresses + */ + function addFourYearVestings( + address[] calldata _tokenOwners, + address[] calldata _vestingAddresses + ) external onlyAuthorized { + require(_tokenOwners.length == _vestingAddresses.length, "arrays mismatch"); + uint256 vestingCreationType = 4; + uint256 cliff = 4 weeks; + uint256 duration = 156 weeks; + for (uint256 i = 0; i < _tokenOwners.length; i++) { + require(!isVesting[_vestingAddresses[i]], "vesting exists"); + require(_tokenOwners[i] != address(0), "token owner cannot be 0 address"); + require(_vestingAddresses[i] != address(0), "vesting cannot be 0 address"); + uint256 uid = + uint256( + keccak256( + abi.encodePacked( + _tokenOwners[i], + uint256(VestingType.Vesting), + cliff, + duration, + vestingCreationType + ) + ) + ); + vestings[uid] = Vesting( + uint256(VestingType.Vesting), + vestingCreationType, + _vestingAddresses[i] + ); + vestingsOf[_tokenOwners[i]].push(uid); + isVesting[_vestingAddresses[i]] = true; + } + } + /** * @notice creates Vesting contract * @param _tokenOwner the owner of the tokens diff --git a/contracts/governance/Vesting/fouryear/FourYearVesting.sol b/contracts/governance/Vesting/fouryear/FourYearVesting.sol new file mode 100644 index 000000000..5dee260eb --- /dev/null +++ b/contracts/governance/Vesting/fouryear/FourYearVesting.sol @@ -0,0 +1,72 @@ +pragma solidity ^0.5.17; +pragma experimental ABIEncoderV2; + +import "../../../openzeppelin/Ownable.sol"; +import "../../../interfaces/IERC20.sol"; +import "../../Staking/Staking.sol"; +import "../../IFeeSharingProxy.sol"; +import "../../ApprovalReceiver.sol"; +import "./FourYearVestingStorage.sol"; +import "../../../proxy/UpgradableProxy.sol"; +import "../../../openzeppelin/Address.sol"; + +/** + * @title Four Year Vesting Contract. + * + * @notice A four year vesting contract. + * + * @dev Vesting contract is upgradable, + * Make sure the vesting owner is multisig otherwise it will be + * catastrophic. + * */ +contract FourYearVesting is FourYearVestingStorage, UpgradableProxy { + /** + * @notice Setup the vesting schedule. + * @param _logic The address of logic contract. + * @param _SOV The SOV token address. + * @param _tokenOwner The owner of the tokens. + * @param _feeSharingProxy Fee sharing proxy address. + * @param _extendDurationFor Duration till the unlocked tokens are extended. + * */ + constructor( + address _logic, + address _SOV, + address _stakingAddress, + address _tokenOwner, + address _feeSharingProxy, + uint256 _extendDurationFor + ) public { + require(Address.isContract(_logic), "_logic not a contract"); + require(_SOV != address(0), "SOV address invalid"); + require(Address.isContract(_SOV), "_SOV not a contract"); + require(_stakingAddress != address(0), "staking address invalid"); + require(Address.isContract(_stakingAddress), "_stakingAddress not a contract"); + require(_tokenOwner != address(0), "token owner address invalid"); + require(_feeSharingProxy != address(0), "feeSharingProxy address invalid"); + require(Address.isContract(_feeSharingProxy), "_feeSharingProxy not a contract"); + require((_extendDurationFor % FOUR_WEEKS) == 0, "invalid duration"); + + _setImplementation(_logic); + SOV = IERC20(_SOV); + staking = Staking(_stakingAddress); + tokenOwner = _tokenOwner; + feeSharingProxy = IFeeSharingProxy(_feeSharingProxy); + maxInterval = 18 * FOUR_WEEKS; + extendDurationFor = _extendDurationFor; + } + + /** + * @notice Set address of the implementation - vesting owner. + * @dev Overriding setImplementation function of UpgradableProxy. The logic can only be + * modified when both token owner and veting owner approve. Since + * setImplementation can only be called by vesting owner, we also need to check + * if the new logic is already approved by the token owner. + * @param _implementation Address of the implementation. Must match with what is set by token owner. + * */ + function setImplementation(address _implementation) public onlyProxyOwner { + require(Address.isContract(_implementation), "_implementation not a contract"); + require(newImplementation == _implementation, "address mismatch"); + _setImplementation(_implementation); + newImplementation = address(0); + } +} diff --git a/contracts/governance/Vesting/fouryear/FourYearVestingFactory.sol b/contracts/governance/Vesting/fouryear/FourYearVestingFactory.sol new file mode 100644 index 000000000..4bbef07fe --- /dev/null +++ b/contracts/governance/Vesting/fouryear/FourYearVestingFactory.sol @@ -0,0 +1,52 @@ +pragma solidity ^0.5.17; + +import "../../../openzeppelin/Ownable.sol"; +import "./FourYearVesting.sol"; +import "./IFourYearVestingFactory.sol"; + +/** + * @title Four Year Vesting Factory: Contract to deploy four year vesting contracts. + * @notice Factory pattern allows to create multiple instances + * of the same contract and keep track of them easier. + * */ +contract FourYearVestingFactory is IFourYearVestingFactory, Ownable { + /// @dev Added an event to keep track of the vesting contract created for a token owner + event FourYearVestingCreated(address indexed tokenOwner, address indexed vestingAddress); + + /** + * @notice Deploys four year vesting contract. + * @param _SOV the address of SOV token. + * @param _staking The address of staking contract. + * @param _tokenOwner The owner of the tokens. + * @param _feeSharing The address of fee sharing contract. + * @param _vestingOwnerMultisig The address of an owner of vesting contract. + * @dev _vestingOwnerMultisig should ALWAYS be multisig. + * @param _fourYearVestingLogic The implementation contract. + * @param _extendDurationFor Duration till the unlocked tokens are extended. + * @return The four year vesting contract address. + * */ + function deployFourYearVesting( + address _SOV, + address _staking, + address _tokenOwner, + address _feeSharing, + address _vestingOwnerMultisig, + address _fourYearVestingLogic, + uint256 _extendDurationFor + ) external onlyOwner returns (address) { + address fourYearVesting = + address( + new FourYearVesting( + _fourYearVestingLogic, + _SOV, + _staking, + _tokenOwner, + _feeSharing, + _extendDurationFor + ) + ); + Ownable(fourYearVesting).transferOwnership(_vestingOwnerMultisig); + emit FourYearVestingCreated(_tokenOwner, fourYearVesting); + return fourYearVesting; + } +} diff --git a/contracts/governance/Vesting/fouryear/FourYearVestingLogic.sol b/contracts/governance/Vesting/fouryear/FourYearVestingLogic.sol new file mode 100644 index 000000000..d82039c8d --- /dev/null +++ b/contracts/governance/Vesting/fouryear/FourYearVestingLogic.sol @@ -0,0 +1,361 @@ +pragma solidity ^0.5.17; +pragma experimental ABIEncoderV2; + +import "./IFourYearVesting.sol"; +import "../../ApprovalReceiver.sol"; +import "./FourYearVestingStorage.sol"; +import "../../../openzeppelin/SafeMath.sol"; + +/** + * @title Four Year Vesting Logic contract. + * @notice Staking, delegating and withdrawal functionality. + * @dev Deployed by FourYearVestingFactory contract. + * */ +contract FourYearVestingLogic is IFourYearVesting, FourYearVestingStorage, ApprovalReceiver { + using SafeMath for uint256; + + /* Events */ + event TokensStaked(address indexed caller, uint256 amount); + event VotesDelegated(address indexed caller, address delegatee); + event TokensWithdrawn(address indexed caller, address receiver); + event DividendsCollected( + address indexed caller, + address loanPoolToken, + address receiver, + uint32 maxCheckpoints + ); + event MigratedToNewStakingContract(address indexed caller, address newStakingContract); + event TokenOwnerChanged(address indexed newOwner, address indexed oldOwner); + + /* Modifiers */ + /** + * @dev Throws if called by any account other than the token owner or the contract owner. + */ + modifier onlyOwners() { + require(msg.sender == tokenOwner || isOwner(), "unauthorized"); + _; + } + + /** + * @dev Throws if called by any account other than the token owner. + */ + modifier onlyTokenOwner() { + require(msg.sender == tokenOwner, "unauthorized"); + _; + } + + /* Functions */ + + /** + * @notice Sets the max interval. + * @param _interval Max interval for which tokens scheduled shall be staked. + * */ + function setMaxInterval(uint256 _interval) external onlyOwner { + require(_interval.mod(FOUR_WEEKS) == 0, "invalid interval"); + maxInterval = _interval; + } + + /** + * @notice Stakes tokens according to the vesting schedule. + * @param _amount The amount of tokens to stake. + * @param _restartStakeSchedule The time from which staking schedule restarts. + * The issue is that we can only stake tokens for a max duration. Thus, we need to restart + * from the lastSchedule. + * @return lastSchedule The max duration for which tokens were staked. + * @return remainingAmount The amount outstanding - to be staked. + * */ + function stakeTokens(uint256 _amount, uint256 _restartStakeSchedule) + external + returns (uint256 lastSchedule, uint256 remainingAmount) + { + (lastSchedule, remainingAmount) = _stakeTokens(msg.sender, _amount, _restartStakeSchedule); + } + + /** + * @notice Stakes tokens according to the vesting schedule. + * @dev This function will be invoked from receiveApproval. + * @dev SOV.approveAndCall -> this.receiveApproval -> this.stakeTokensWithApproval + * @param _sender The sender of SOV.approveAndCall + * @param _amount The amount of tokens to stake. + * @param _restartStakeSchedule The time from which staking schedule restarts. + * The issue is that we can only stake tokens for a max duration. Thus, we need to restart + * from the lastSchedule. + * @return lastSchedule The max duration for which tokens were staked. + * @return remainingAmount The amount outstanding - to be staked. + * */ + function stakeTokensWithApproval( + address _sender, + uint256 _amount, + uint256 _restartStakeSchedule + ) external onlyThisContract returns (uint256 lastSchedule, uint256 remainingAmount) { + (lastSchedule, remainingAmount) = _stakeTokens(_sender, _amount, _restartStakeSchedule); + } + + /** + * @notice Delegate votes from `msg.sender` which are locked until lockDate + * to `delegatee`. + * @param _delegatee The address to delegate votes to. + * */ + function delegate(address _delegatee) external onlyTokenOwner { + require(_delegatee != address(0), "delegatee address invalid"); + uint256 stakingEndDate = endDate; + /// @dev Withdraw for each unlocked position. + /// @dev Don't change FOUR_WEEKS to TWO_WEEKS, a lot of vestings already deployed with FOUR_WEEKS + /// workaround found, but it doesn't work with TWO_WEEKS + for (uint256 i = startDate.add(cliff); i <= stakingEndDate; i += FOUR_WEEKS) { + staking.delegate(_delegatee, i); + } + emit VotesDelegated(msg.sender, _delegatee); + } + + /** + * @notice Withdraws unlocked tokens from the staking contract and + * forwards them to an address specified by the token owner. + * @param receiver The receiving address. + * */ + function withdrawTokens(address receiver) external onlyTokenOwner { + _withdrawTokens(receiver, false); + } + + /** + * @notice Collect dividends from fee sharing proxy. + * @param _loanPoolToken The loan pool token address. + * @param _maxCheckpoints Maximum number of checkpoints to be processed. + * @param _receiver The receiver of tokens or msg.sender + * */ + function collectDividends( + address _loanPoolToken, + uint32 _maxCheckpoints, + address _receiver + ) external onlyTokenOwner { + require(_receiver != address(0), "receiver address invalid"); + + /// @dev Invokes the fee sharing proxy. + feeSharingProxy.withdraw(_loanPoolToken, _maxCheckpoints, _receiver); + + emit DividendsCollected(msg.sender, _loanPoolToken, _receiver, _maxCheckpoints); + } + + /** + * @notice Change token owner - only vesting owner is allowed to change. + * @dev Modifies token owner. This must be followed by approval + * from token owner. + * @param _newTokenOwner Address of new token owner. + * */ + function changeTokenOwner(address _newTokenOwner) public onlyOwner { + require(_newTokenOwner != address(0), "invalid new token owner address"); + require(_newTokenOwner != tokenOwner, "same owner not allowed"); + newTokenOwner = _newTokenOwner; + } + + /** + * @notice Approve token owner change - only token Owner. + * @dev Token owner can only be modified + * when both vesting owner and token owner have approved. This + * function ascertains the approval of token owner. + * */ + function approveOwnershipTransfer() public onlyTokenOwner { + require(newTokenOwner != address(0), "invalid address"); + tokenOwner = newTokenOwner; + newTokenOwner = address(0); + emit TokenOwnerChanged(tokenOwner, msg.sender); + } + + /** + * @notice Set address of the implementation - only Token Owner. + * @dev This function sets the new implementation address. + * It must also be approved by the Vesting owner. + * @param _newImplementation Address of the new implementation. + * */ + function setImpl(address _newImplementation) public onlyTokenOwner { + require(_newImplementation != address(0), "invalid new implementation address"); + newImplementation = _newImplementation; + } + + /** + * @notice Allows the owners to migrate the positions + * to a new staking contract. + * */ + function migrateToNewStakingContract() external onlyOwners { + staking.migrateToNewStakingContract(); + staking = Staking(staking.newStakingContract()); + emit MigratedToNewStakingContract(msg.sender, address(staking)); + } + + /** + * @notice Extends stakes(unlocked till timeDuration) for four year vesting contracts. + * @dev Tokens are vested for 4 years. Since the max staking + * period is 3 years and the tokens are unlocked only after the first year(timeDuration) is + * passed, hence, we usually extend the duration of staking for all unlocked tokens for the first + * year by 3 years. In some cases, the timeDuration can differ. + * */ + function extendStaking() external { + uint256 timeDuration = startDate.add(extendDurationFor); + uint256[] memory dates; + uint96[] memory stakes; + (dates, stakes) = staking.getStakes(address(this)); + + for (uint256 i = 0; i < dates.length; i++) { + if ((dates[i] < block.timestamp) && (dates[i] <= timeDuration) && (stakes[i] > 0)) { + staking.extendStakingDuration(dates[i], dates[i].add(156 weeks)); + endDate = dates[i].add(156 weeks); + } else { + break; + } + } + } + + /** + * @notice Stakes tokens according to the vesting schedule. Low level function. + * @dev Once here the allowance of tokens is taken for granted. + * @param _sender The sender of tokens to stake. + * @param _amount The amount of tokens to stake. + * @param _restartStakeSchedule The time from which staking schedule restarts. + * The issue is that we can only stake tokens for a max duration. Thus, we need to restart + * from the lastSchedule. + * @return lastSchedule The max duration for which tokens were staked. + * @return remainingAmount The amount outstanding - to be staked. + * */ + function _stakeTokens( + address _sender, + uint256 _amount, + uint256 _restartStakeSchedule + ) internal returns (uint256 lastSchedule, uint256 remainingAmount) { + // Creating a new staking schedule for the same vesting contract is disallowed unlike normal vesting + require( + (startDate == 0) || + (startDate > 0 && remainingStakeAmount > 0 && _restartStakeSchedule > 0), + "create new vesting address" + ); + uint256 restartDate; + uint256 relativeAmount; + // Calling the _stakeTokens function first time for the vesting contract + // Runs for maxInterval only (consider maxInterval = 18 * 4 = 72 weeks) + if (startDate == 0 && _restartStakeSchedule == 0) { + startDate = staking.timestampToLockDate(block.timestamp); // Set only once + durationLeft = duration; // We do not touch duration and cliff as they are used throughout + cliffAdded = cliff; // Hence, durationLeft and cliffAdded is created + } + // Calling the _stakeTokens second/third time - we start from the end of previous interval + // and the remaining amount(amount left after tokens are staked in the previous interval) + if (_restartStakeSchedule > 0) { + require( + _restartStakeSchedule == lastStakingSchedule && _amount == remainingStakeAmount, + "invalid params" + ); + restartDate = _restartStakeSchedule; + } else { + restartDate = startDate; + } + // Runs only once when the _stakeTokens is called for the first time + if (endDate == 0) { + endDate = staking.timestampToLockDate(block.timestamp.add(duration)); + } + uint256 addedMaxInterval = restartDate.add(maxInterval); // run for maxInterval + if (addedMaxInterval < endDate) { + // Runs for max interval + lastStakingSchedule = addedMaxInterval; + relativeAmount = (_amount.mul(maxInterval)).div(durationLeft); // (_amount * 18) / 39 + durationLeft = durationLeft.sub(maxInterval); // durationLeft - 18 periods(72 weeks) + remainingStakeAmount = _amount.sub(relativeAmount); // Amount left to be staked in subsequent intervals + } else { + // Normal run + lastStakingSchedule = endDate; // if staking intervals left < 18 periods(72 weeks) + remainingStakeAmount = 0; + durationLeft = 0; + relativeAmount = _amount; // Stake all amount left + } + + /// @dev Transfer the tokens to this contract. + bool success = SOV.transferFrom(_sender, address(this), relativeAmount); + require(success, "transfer failed"); + + /// @dev Allow the staking contract to access them. + SOV.approve(address(staking), relativeAmount); + + staking.stakesBySchedule( + relativeAmount, + cliffAdded, + duration.sub(durationLeft), + FOUR_WEEKS, + address(this), + tokenOwner + ); + if (durationLeft == 0) { + // All tokens staked + cliffAdded = 0; + } else { + cliffAdded = cliffAdded.add(maxInterval); // Add cliff to the end of previous maxInterval + } + + emit TokensStaked(_sender, relativeAmount); + return (lastStakingSchedule, remainingStakeAmount); + } + + /** + * @notice Withdraws tokens from the staking contract and forwards them + * to an address specified by the token owner. Low level function. + * @dev Once here the caller permission is taken for granted. + * @param receiver The receiving address. + * @param isGovernance Whether all tokens (true) + * or just unlocked tokens (false). + * */ + function _withdrawTokens(address receiver, bool isGovernance) internal { + require(receiver != address(0), "receiver address invalid"); + + uint96 stake; + + /// @dev Usually we just need to iterate over the possible dates until now. + uint256 end; + + /// @dev In the unlikely case that all tokens have been unlocked early, + /// allow to withdraw all of them. + if (staking.allUnlocked() || isGovernance) { + end = endDate; + } else { + end = block.timestamp; + } + + /// @dev Withdraw for each unlocked position. + /// @dev Don't change FOUR_WEEKS to TWO_WEEKS, a lot of vestings already deployed with FOUR_WEEKS + /// workaround found, but it doesn't work with TWO_WEEKS + /// @dev For four year vesting, withdrawal of stakes for the first year is not allowed. These + /// stakes are extended for three years. In some cases the withdrawal may be allowed at a different + /// time and hence we use extendDurationFor. + for (uint256 i = startDate.add(extendDurationFor); i <= end; i += FOUR_WEEKS) { + /// @dev Read amount to withdraw. + stake = staking.getPriorUserStakeByDate(address(this), i, block.number.sub(1)); + + /// @dev Withdraw if > 0 + if (stake > 0) { + if (isGovernance) { + staking.governanceWithdraw(stake, i, receiver); + } else { + staking.withdraw(stake, i, receiver); + } + } + } + + emit TokensWithdrawn(msg.sender, receiver); + } + + /** + * @notice Overrides default ApprovalReceiver._getToken function to + * register SOV token on this contract. + * @return The address of SOV token. + * */ + function _getToken() internal view returns (address) { + return address(SOV); + } + + /** + * @notice Overrides default ApprovalReceiver._getSelectors function to + * register stakeTokensWithApproval selector on this contract. + * @return The array of registered selectors on this contract. + * */ + function _getSelectors() internal view returns (bytes4[] memory) { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = this.stakeTokensWithApproval.selector; + return selectors; + } +} diff --git a/contracts/governance/Vesting/fouryear/FourYearVestingStorage.sol b/contracts/governance/Vesting/fouryear/FourYearVestingStorage.sol new file mode 100644 index 000000000..3339b0c5c --- /dev/null +++ b/contracts/governance/Vesting/fouryear/FourYearVestingStorage.sol @@ -0,0 +1,71 @@ +pragma solidity ^0.5.17; + +import "../../../openzeppelin/Ownable.sol"; +import "../../../interfaces/IERC20.sol"; +import "../../Staking/Staking.sol"; +import "../../IFeeSharingProxy.sol"; + +/** + * @title Four Year Vesting Storage Contract. + * + * @notice This contract is just the storage required for four year vesting. + * It is parent of FourYearVestingLogic and FourYearVesting. + * + * @dev Use Ownable as a parent to align storage structure for Logic and Proxy contracts. + * */ +contract FourYearVestingStorage is Ownable { + /// @notice The SOV token contract. + IERC20 public SOV; + + /// @notice The staking contract address. + Staking public staking; + + /// @notice The owner of the vested tokens. + address public tokenOwner; + + /// @notice Fee sharing Proxy. + IFeeSharingProxy public feeSharingProxy; + + // Used lower case for cliff and duration to maintain consistency with normal vesting + /// @notice The cliff. After this time period the tokens begin to unlock. + uint256 public constant cliff = 4 weeks; + + /// @notice The duration. After this period all tokens will have been unlocked. + uint256 public constant duration = 156 weeks; + + /// @notice The start date of the vesting. + uint256 public startDate; + + /// @notice The end date of the vesting. + uint256 public endDate; + + /// @notice Constant used for computing the vesting dates. + uint256 public constant FOUR_WEEKS = 4 weeks; + + /// @notice Maximum interval to stake tokens at one go + uint256 public maxInterval; + + /// @notice End of previous staking schedule. + uint256 public lastStakingSchedule; + + /// @notice Amount of shares left to be staked. + uint256 public remainingStakeAmount; + + /// @notice Durations left. + uint256 public durationLeft; + + /// @notice Cliffs added. + uint256 public cliffAdded; + + /// @notice Address of new token owner. + address public newTokenOwner; + + /// @notice Address of new implementation. + address public newImplementation; + + /// @notice Duration(from start) till the time unlocked tokens are extended(for 3 years) + uint256 public extendDurationFor; + + /// @dev Please add new state variables below this line. Mark them internal and + /// add a getter function while upgrading the contracts. +} diff --git a/contracts/governance/Vesting/fouryear/IFourYearVesting.sol b/contracts/governance/Vesting/fouryear/IFourYearVesting.sol new file mode 100644 index 000000000..2d34fe61e --- /dev/null +++ b/contracts/governance/Vesting/fouryear/IFourYearVesting.sol @@ -0,0 +1,16 @@ +pragma solidity ^0.5.17; + +/** + * @title Interface for Four Year Vesting contract. + * @dev Interfaces are used to cast a contract address into a callable instance. + * This interface is used by FourYearVestingLogic contract to implement stakeTokens function + * and on VestingRegistry contract to call IFourYearVesting(vesting).stakeTokens function + * at a vesting instance. + */ +interface IFourYearVesting { + function endDate() external returns (uint256); + + function stakeTokens(uint256 _amount, uint256 _restartStakeSchedule) + external + returns (uint256 lastSchedule, uint256 remainingAmount); +} diff --git a/contracts/governance/Vesting/fouryear/IFourYearVestingFactory.sol b/contracts/governance/Vesting/fouryear/IFourYearVestingFactory.sol new file mode 100644 index 000000000..fd02295a4 --- /dev/null +++ b/contracts/governance/Vesting/fouryear/IFourYearVestingFactory.sol @@ -0,0 +1,20 @@ +pragma solidity ^0.5.17; + +/** + * @title Interface for Four Year Vesting Factory contract. + * @dev Interfaces are used to cast a contract address into a callable instance. + * This interface is used by FourYearVestingFactory contract to override empty + * implemention of deployFourYearVesting function + * and use an instance of FourYearVestingFactory. + */ +interface IFourYearVestingFactory { + function deployFourYearVesting( + address _SOV, + address _staking, + address _tokenOwner, + address _feeSharing, + address _vestingOwnerMultisig, + address _fourYearVestingLogic, + uint256 _extendDurationFor + ) external returns (address); +} diff --git a/contracts/mockup/MockFourYearVestingLogic.sol b/contracts/mockup/MockFourYearVestingLogic.sol new file mode 100644 index 000000000..dfc442459 --- /dev/null +++ b/contracts/mockup/MockFourYearVestingLogic.sol @@ -0,0 +1,12 @@ +pragma solidity ^0.5.17; + +import "../governance/Vesting/fouryear/FourYearVestingLogic.sol"; + +contract MockFourYearVestingLogic is FourYearVestingLogic { + /** + * @notice gets duration left + */ + function getDurationLeft() external view returns (uint256) { + return durationLeft; + } +} diff --git a/scripts/contractInteraction/mainnet_contracts.json b/scripts/contractInteraction/mainnet_contracts.json index c20f59279..ce027642b 100644 --- a/scripts/contractInteraction/mainnet_contracts.json +++ b/scripts/contractInteraction/mainnet_contracts.json @@ -100,7 +100,7 @@ "FeeSharingProxy": "0x115cAF168c51eD15ec535727F64684D33B7b08D1", "FeeSharingLogic": "0x8289AF920cA3d63245740a20116e13aAe0F978e3", "VestingRegistryProxy": "0xe24ABdB7DcaB57F3cbe4cBDDd850D52F143eE920", - "VestingRegistryLogic": "0x536416A9fbAc10A3EA0D7f7396d0eE8FaE8146D0", + "VestingRegistryLogic": "0xc47E977d30fa553412897d0B1f97247F635c09b2", "VestingCreator": "0xa003D9F781a498D90f489328612E74Af1027417f", "TimelockOwner": "0x967c84b731679E36A344002b8E3CE50620A7F69f", "GovernorOwner": "0x6496DF39D000478a7A7352C01E0E713835051CcD", @@ -109,6 +109,8 @@ "GovernorAdmin": "0xfF25f66b7D7F385503D70574AE0170b6B1622dAd", "GovernorVaultAdmin": "0x51C754330c6cD04B810014E769Dab0343E31409E", "VestingLogic": "0x24fbA2281202C3aaE95A3440C08C0050448508A6", + "FourYearVestingLogic": "0xfA0888E2Cd5b045496A63E230910B9EC16EFA073", + "FourYearVestingFactory": "0xD5564a16f356dD45e445beC725F54496700b5C5A", "VestingRegistry": "0x80B036ae59B3e38B573837c01BB1DB95515b7E6B", "AdoptionFund": "0x0f31cfd6aAb4d378668Ad74DeFa89d3f4DB26633", "DevelopmentFund": "0x617866cC4a089c3653ddC31a618b078291839AeB", diff --git a/scripts/contractInteraction/staking_vesting.py b/scripts/contractInteraction/staking_vesting.py index 8c813e02c..20790fd63 100644 --- a/scripts/contractInteraction/staking_vesting.py +++ b/scripts/contractInteraction/staking_vesting.py @@ -48,6 +48,22 @@ def readAllVestingContractsForAddress(userAddress): addresses = vestingRegistry.getVestingsOf(userAddress) print(addresses) +def addVestingAdmin(admin): + multisig = Contract.from_abi("MultiSig", address=conf.contracts['multisig'], abi=MultiSigWallet.abi, owner=conf.acct) + vestingRegistry = Contract.from_abi("VestingRegistryLogic", address=conf.contracts['VestingRegistryProxy'], abi=VestingRegistryLogic.abi, owner=conf.acct) + data = vestingRegistry.addAdmin.encode_input(admin) + sendWithMultisig(conf.contracts['multisig'], vestingRegistry.address, data, conf.acct) + +def removeVestingAdmin(admin): + multisig = Contract.from_abi("MultiSig", address=conf.contracts['multisig'], abi=MultiSigWallet.abi, owner=conf.acct) + vestingRegistry = Contract.from_abi("VestingRegistryLogic", address=conf.contracts['VestingRegistryProxy'], abi=VestingRegistryLogic.abi, owner=conf.acct) + data = vestingRegistry.removeAdmin.encode_input(admin) + sendWithMultisig(conf.contracts['multisig'], vestingRegistry.address, data, conf.acct) + +def isVestingAdmin(admin): + vestingRegistry = Contract.from_abi("VestingRegistryLogic", address=conf.contracts['VestingRegistryProxy'], abi=VestingRegistryLogic.abi, owner=conf.acct) + print(vestingRegistry.admins(admin)) + def readStakingKickOff(): staking = Contract.from_abi("Staking", address=conf.contracts['Staking'], abi=Staking.abi, owner=conf.acct) print(staking.kickoffTS()) @@ -192,11 +208,11 @@ def upgradeVesting(): print("New vesting registry logic address:", vestingRegistryLogic.address) # Get the proxy contract instance - vestingRegistryProxy = Contract.from_abi("VestingRegistryProxy", address=conf.contracts['VestingRegistryLogic'], abi=VestingRegistryProxy.abi, owner=conf.acct) + vestingRegistryProxy = Contract.from_abi("VestingRegistryProxy", address=conf.contracts['VestingRegistryProxy'], abi=VestingRegistryProxy.abi, owner=conf.acct) # Register logic in Proxy data = vestingRegistryProxy.setImplementation.encode_input(vestingRegistryLogic.address) - sendWithMultisig(conf.contracts['multisig'], conf.contracts['VestingRegistryLogic'], data, conf.acct) + sendWithMultisig(conf.contracts['multisig'], conf.contracts['VestingRegistryProxy'], data, conf.acct) # Set Vesting Registry Address for Staking diff --git a/scripts/contractInteraction/testnet_contracts.json b/scripts/contractInteraction/testnet_contracts.json index ba3bc6211..b822416ff 100644 --- a/scripts/contractInteraction/testnet_contracts.json +++ b/scripts/contractInteraction/testnet_contracts.json @@ -99,6 +99,8 @@ "VestingRegistry2": "0x068fbb3Bef062C3daBA7a4B12f53Cd614FBcBF1d", "VestingRegistry3": "0x52E4419b9D33C6e0ceb2e7c01D3aA1a04b21668C", "VestingLogic": "0xc1cECAC06c7a5d5480F158043A150acf06e206cD", + "FourYearVestingLogic": "0x75B8faC8907f196Bd791dB57D8492a779cdE09b5", + "FourYearVestingFactory": "0x2AC0b13c174f03B4bE8C174Daf9d86C05b21496e", "OriginInvestorsClaim": "0x9FBe4Bf89521088F790a4dD2F3e495B4f0dA7F42", "TokenSender": "0x4D1903BaAd894Fc6Ff70483d8518Db78F163F9ff", "RBTCWrapperProxy": "0x6b1a4735b1E25ccE9406B2d5D7417cE53d1cf90e", @@ -122,7 +124,7 @@ "WatcherContract": "0x3583155D5e87491dACDc15f7D0032C12D5D0ece0", "StakingRewardsProxy": "0x18eF0ff12f1b4D30104B4680D485D026C26D164D", "StakingRewards": "0x9762e0aA49248A58e14a5C09B4edE5c185b0d178", - "VestingRegistryProxy": "0x8eE1254c1b95FFaD975Ac6f348Bc1cd25CB0c22F", - "VestingRegistryLogic": "0x8Ea3bF5C621FFb93f874047a0c1eE6DffB00053E", + "VestingRegistryProxy": "0x09e8659B6d204C6b1bED2BFF8E3F43F834A5Bbc4", + "VestingRegistryLogic": "0x38B729f1c42095EEd5A7c22A56d186987F75CED5", "SovrynSwapFormula": "0x7FF1C363b5600834bce7c514B01109eF1c103507" } diff --git a/scripts/fouryearvesting/add_to_registry.py b/scripts/fouryearvesting/add_to_registry.py new file mode 100644 index 000000000..011ba5b52 --- /dev/null +++ b/scripts/fouryearvesting/add_to_registry.py @@ -0,0 +1,69 @@ +from brownie import * +from brownie.network.contract import InterfaceContainer + +import json +import csv + +def main(): + global contracts, acct + thisNetwork = network.show_active() + + # == Load config ======================================================================================================================= + if thisNetwork == "development": + acct = accounts[0] + configFile = open('./scripts/contractInteraction/testnet_contracts.json') + elif thisNetwork == "testnet": + acct = accounts.load("rskdeployer") + configFile = open('./scripts/contractInteraction/testnet_contracts.json') + elif thisNetwork == "rsk-testnet": + acct = accounts.load("rskdeployer") + configFile = open('./scripts/contractInteraction/testnet_contracts.json') + elif thisNetwork == "rsk-mainnet": + acct = accounts.load("rskdeployer") + configFile = open('./scripts/contractInteraction/mainnet_contracts.json') + else: + raise Exception("network not supported") + + # load deployed contracts addresses + contracts = json.load(configFile) + + balanceBefore = acct.balance() + + vestingRegistryLogic = Contract.from_abi( + "VestingRegistryLogic", + address=contracts['VestingRegistryProxy'], + abi=VestingRegistryLogic.abi, + owner=acct) + + # open the file in universal line ending mode + with open('./scripts/fouryearvesting/addfouryearvestingstoregistry.csv', 'rU') as infile: + #read the file as a dictionary for each row ({header : value}) + reader = csv.DictReader(infile) + data = {} + for row in reader: + for header, value in row.items(): + try: + data[header].append(value) + except KeyError: + data[header] = [value] + + # extract the variables you want + tokenOwners = data['tokenOwner'] + vestingAddresses = data['vestingAddress'] + print(tokenOwners) + print(vestingAddresses) + + vestingRegistryLogic.addFourYearVestings(tokenOwners, vestingAddresses) + + print("deployment cost:") + print((balanceBefore - acct.balance()) / 10**18) + + # data = vestingRegistryLogic.addFourYearVestings.encode_input(tokenOwners, vestingAddresses) + # print(data) + + # multisig = Contract.from_abi("MultiSig", address=contracts['multisig'], abi=MultiSigWallet.abi, owner=acct) + # print(multisig) + # tx = multisig.submitTransaction(vestingRegistryLogic.address, 0, data, {'allow_revert':True}) + # print(tx.revert_msg) + # txId = tx.events["Submission"]["transactionId"] + # print(txId) \ No newline at end of file diff --git a/scripts/fouryearvesting/addfouryearvestingstoregistry.csv b/scripts/fouryearvesting/addfouryearvestingstoregistry.csv new file mode 100644 index 000000000..ffee384af --- /dev/null +++ b/scripts/fouryearvesting/addfouryearvestingstoregistry.csv @@ -0,0 +1,3 @@ +tokenOwner,vestingAddress +0x616cc7A216dBB411f0632e6b65CE2B1A9D9a05F3,0x8bA06b52B0b9d86381329E73499d9138F4d34569 +0x9E0816a71B53ca67201a5088df960fE90910DE55,0x71d6240d87ec6c8dac09852dEad7e7192E138C9C \ No newline at end of file diff --git a/scripts/fouryearvesting/create_four_year_vestings.py b/scripts/fouryearvesting/create_four_year_vestings.py new file mode 100644 index 000000000..193e42b7c --- /dev/null +++ b/scripts/fouryearvesting/create_four_year_vestings.py @@ -0,0 +1,94 @@ +from brownie import * + +import time +import json +import csv +import math + +def main(): + thisNetwork = network.show_active() + + # == Load config ======================================================================================================================= + if thisNetwork == "development": + acct = accounts[0] + configFile = open( + './scripts/contractInteraction/testnet_contracts.json') + elif thisNetwork == "testnet": + acct = accounts.load("rskdeployer") + configFile = open( + './scripts/contractInteraction/testnet_contracts.json') + elif thisNetwork == "rsk-testnet": + acct = accounts.load("rskdeployer") + configFile = open('./scripts/contractInteraction/testnet_contracts.json') + elif thisNetwork == "rsk-mainnet": + acct = accounts.load("rskdeployer") + configFile = open( + './scripts/contractInteraction/mainnet_contracts.json') + else: + raise Exception("network not supported") + + # load deployed contracts addresses + contracts = json.load(configFile) + multisig = contracts['multisig'] + stakingAddress = contracts['Staking'] + feeSharingAddress = contracts['FeeSharingProxy'] + fourYearVestingLogic = contracts['FourYearVestingLogic'] + SOVtoken = Contract.from_abi("SOV", address=contracts['SOV'], abi=SOV.abi, owner=acct) + staking = Contract.from_abi("Staking", address=stakingAddress, abi=Staking.abi, owner=acct) + fourYearVestingFactory = Contract.from_abi("FourYearVestingFactory", address=contracts['FourYearVestingFactory'], abi=FourYearVestingFactory.abi, owner=acct) + + MULTIPLIER = 10**16 # Expecting two decimals + DAY = 24 * 60 * 60 + FOUR_WEEKS = 4 * 7 * DAY + cliff = FOUR_WEEKS + duration = 39 * FOUR_WEEKS + + balanceBefore = acct.balance() + + print("SOV Balance Before:") + print(SOVtoken.balanceOf(acct) / 10**18) + + # == Vesting contracts creation and staking tokens ============================================================================== + # TODO check fouryearvestinglist.csv + dataFile = 'scripts/fouryearvesting/fouryearvestinglist.csv' + with open(dataFile, 'r') as file: + reader = csv.reader(file) + for row in reader: + tokenOwner = row[0].replace(" ", "") + amount = row[1].replace(",", "").replace(".", "") + amount = int(amount) * MULTIPLIER + extendDurationFor = row[2].replace(" ", "") + tx = fourYearVestingFactory.deployFourYearVesting(SOVtoken.address, stakingAddress, tokenOwner, feeSharingAddress, multisig, fourYearVestingLogic, extendDurationFor) + event = tx.events["FourYearVestingCreated"] + vestingAddress = event["vestingAddress"] + print("=======================================") + print("Token Owner: ", tokenOwner) + print("Vesting Contract Address: ", vestingAddress) + print("Staked Amount: ", amount) + fourYearVesting = Contract.from_abi("FourYearVestingLogic", address=vestingAddress, abi=FourYearVestingLogic.abi, owner=acct) + + SOVtoken.approve(vestingAddress, amount) + + remainingAmount = amount + lastSchedule = 0 + while remainingAmount > 0: + fourYearVesting.stakeTokens(remainingAmount, lastSchedule) + time.sleep(10) + lastSchedule = fourYearVesting.lastStakingSchedule() + print('lastSchedule:', lastSchedule) + remainingAmount = fourYearVesting.remainingStakeAmount() + print('remainingAmount:', remainingAmount) + + stakes = staking.getStakes(vestingAddress) + print("Staking Details") + print("=======================================") + print(stakes) + + # == Transfer ownership to multisig ============================================================================================= + #fourYearVestingFactory.transferOwnership(multisig) + + print("SOV Balance After:") + print(SOVtoken.balanceOf(acct) / 10**18) + + print("deployment cost:") + print((balanceBefore - acct.balance()) / 10**18) diff --git a/scripts/fouryearvesting/deploy_four_year_vesting.py b/scripts/fouryearvesting/deploy_four_year_vesting.py new file mode 100644 index 000000000..ab42d3c74 --- /dev/null +++ b/scripts/fouryearvesting/deploy_four_year_vesting.py @@ -0,0 +1,44 @@ +from brownie import * + +import time +import json +import csv +import math + + +def main(): + thisNetwork = network.show_active() + + # == Load config ======================================================================================================================= + if thisNetwork == "development": + acct = accounts[0] + configFile = open( + './scripts/contractInteraction/testnet_contracts.json') + elif thisNetwork == "testnet": + acct = accounts.load("rskdeployer") + configFile = open( + './scripts/contractInteraction/testnet_contracts.json') + elif thisNetwork == "rsk-testnet": + acct = accounts.load("rskdeployer") + configFile = open('./scripts/contractInteraction/testnet_contracts.json') + elif thisNetwork == "rsk-mainnet": + acct = accounts.load("rskdeployer") + configFile = open( + './scripts/contractInteraction/mainnet_contracts.json') + else: + raise Exception("network not supported") + + # load deployed contracts addresses + contracts = json.load(configFile) + + balanceBefore = acct.balance() + + #deploy VestingFactory + fourYearVestingLogic = acct.deploy(FourYearVestingLogic) + fourYearVestingFactory = acct.deploy(FourYearVestingFactory) + + # Transfer ownership of VestingFactory to multisig after creating vesting contracts and staking tokens + # Don't forget to add the contract addresses to json files before running the script + + print("deployment cost:") + print((balanceBefore - acct.balance()) / 10**18) diff --git a/scripts/fouryearvesting/extendStakingCron.py b/scripts/fouryearvesting/extendStakingCron.py new file mode 100644 index 000000000..ef0e84867 --- /dev/null +++ b/scripts/fouryearvesting/extendStakingCron.py @@ -0,0 +1,79 @@ +from brownie import * +from brownie.network.contract import InterfaceContainer + +import json +import csv +import time +import math + +def main(): + global contracts, acct + thisNetwork = network.show_active() + + # == Load config ======================================================================================================================= + if thisNetwork == "development": + acct = accounts[0] + configFile = open('./scripts/contractInteraction/testnet_contracts.json') + elif thisNetwork == "testnet": + acct = accounts.load("rskdeployer") + configFile = open('./scripts/contractInteraction/testnet_contracts.json') + elif thisNetwork == "rsk-testnet": + acct = accounts.load("rskdeployer") + configFile = open('./scripts/contractInteraction/testnet_contracts.json') + elif thisNetwork == "rsk-mainnet": + acct = accounts.load("rskdeployer") + configFile = open('./scripts/contractInteraction/mainnet_contracts.json') + else: + raise Exception("network not supported") + + # load deployed contracts addresses + contracts = json.load(configFile) + + # Read last staking timestamp + def readLockDate(timestamp): + staking = Contract.from_abi("Staking", address=contracts['Staking'], abi=Staking.abi, owner=acct) + return staking.timestampToLockDate(timestamp) + + # open the file in universal line ending mode + with open('./scripts/fouryearvesting/addfouryearvestingstoregistry.csv', 'rU') as infile: + #read the file as a dictionary for each row ({header : value}) + reader = csv.DictReader(infile) + data = {} + for row in reader: + for header, value in row.items(): + try: + data[header].append(value) + except KeyError: + data[header] = [value] + + # extract the variables you want + tokenOwners = data['tokenOwner'] + vestingAddresses = data['vestingAddress'] + + for i in vestingAddresses: + print('vestingAddress:', i) + fourYearVestingLogic = Contract.from_abi( + "FourYearVestingLogic", + address=i, + abi=FourYearVestingLogic.abi, + owner=acct) + startDate = fourYearVestingLogic.startDate() + print('startDate:', startDate) + datenow = time.time() + timeLockDate = readLockDate(datenow) + print('timeLockDate:', timeLockDate) + extendDurationFor = fourYearVestingLogic.extendDurationFor() + print('extendDurationFor:', extendDurationFor) + maxIterations = extendDurationFor / FOUR_WEEKS + print('maxIterations:', maxIterations) + DAY = 24 * 60 * 60 + FOUR_WEEKS = 4 * 7 * DAY + result = ((timeLockDate - startDate) % FOUR_WEEKS) # the cron should run every four weeks from start date + newResult = ((timeLockDate - startDate) / FOUR_WEEKS) # the cron should run for maxIterations only + timediff = datenow - timeLockDate # To avoid execution on consecutive weeks + print('result:', result) + print('newResult:', math.floor(newResult)) + print('timediff:', timediff) + print('-----------------------------------------------------') + if ((result == 0) and (math.floor(newResult) >= 1) and (math.floor(newResult) <= maxIterations) and (timediff < 86400) ): + fourYearVestingLogic.extendStaking() \ No newline at end of file diff --git a/scripts/fouryearvesting/fouryearvestinglist.csv b/scripts/fouryearvesting/fouryearvestinglist.csv new file mode 100644 index 000000000..9a8c42adc --- /dev/null +++ b/scripts/fouryearvesting/fouryearvestinglist.csv @@ -0,0 +1,5 @@ +0x616cc7A216dBB411f0632e6b65CE2B1A9D9a05F3,0.20,31449600 +0x9E0816a71B53ca67201a5088df960fE90910DE55,0.02,31449600 + + + diff --git a/tests/vesting/FourYearVesting.js b/tests/vesting/FourYearVesting.js new file mode 100644 index 000000000..a3c636cd7 --- /dev/null +++ b/tests/vesting/FourYearVesting.js @@ -0,0 +1,1255 @@ +const { expect } = require("chai"); +const { expectRevert, expectEvent, constants, BN } = require("@openzeppelin/test-helpers"); +const { increaseTime, lastBlock } = require("../Utils/Ethereum"); + +const StakingLogic = artifacts.require("Staking"); +const StakingProxy = artifacts.require("StakingProxy"); +const SOV = artifacts.require("SOV"); +const TestWrbtc = artifacts.require("TestWrbtc"); +const FeeSharingProxy = artifacts.require("FeeSharingProxyMockup"); +const VestingLogic = artifacts.require("FourYearVestingLogic"); +const Vesting = artifacts.require("FourYearVesting"); +const VestingFactory = artifacts.require("FourYearVestingFactory"); +//Upgradable Vesting Registry +const VestingRegistryLogic = artifacts.require("VestingRegistryLogicMockup"); +const VestingRegistryProxy = artifacts.require("VestingRegistryProxy"); + +const MAX_DURATION = new BN(24 * 60 * 60).mul(new BN(1092)); +const WEEK = new BN(7 * 24 * 60 * 60); + +const TOTAL_SUPPLY = "10000000000000000000000000"; +const ONE_MILLON = "1000000000000000000000000"; +const ONE_ETHER = "1000000000000000000"; + +contract("FourYearVesting", (accounts) => { + let root, a1, a2, a3; + let token, staking, stakingLogic, feeSharingProxy; + let vestingLogic; + let vestingFactory; + let kickoffTS; + + let cliff = 4 * WEEK; + let duration = 156 * WEEK; + + before(async () => { + [root, a1, a2, a3, ...accounts] = accounts; + token = await SOV.new(TOTAL_SUPPLY); + wrbtc = await TestWrbtc.new(); + + vestingLogic = await VestingLogic.new(); + vestingFactory = await VestingFactory.new(); + + feeSharingProxy = await FeeSharingProxy.new( + constants.ZERO_ADDRESS, + constants.ZERO_ADDRESS + ); + + stakingLogic = await StakingLogic.new(token.address); + staking = await StakingProxy.new(token.address); + await staking.setImplementation(stakingLogic.address); + staking = await StakingLogic.at(staking.address); + //Upgradable Vesting Registry + vestingRegistryLogic = await VestingRegistryLogic.new(); + vestingReg = await VestingRegistryProxy.new(); + await vestingReg.setImplementation(vestingRegistryLogic.address); + vestingReg = await VestingRegistryLogic.at(vestingReg.address); + await staking.setVestingRegistry(vestingReg.address); + + await token.transfer(a2, "1000"); + await token.approve(staking.address, "1000", { from: a2 }); + + kickoffTS = await staking.kickoffTS.call(); + }); + + describe("vestingfactory", () => { + it("sets the expected values", async () => { + let vestingInstance = await vestingFactory.deployFourYearVesting( + token.address, + staking.address, + a1, + feeSharingProxy.address, + root, + vestingLogic.address, + 52 * WEEK + ); + vestingInstance = await VestingLogic.at(vestingInstance.logs[0].address); + + // Check data + let _sov = await vestingInstance.SOV(); + let _stackingAddress = await vestingInstance.staking(); + let _tokenOwner = await vestingInstance.tokenOwner(); + let _cliff = await vestingInstance.cliff(); + let _duration = await vestingInstance.duration(); + let _feeSharingProxy = await vestingInstance.feeSharingProxy(); + let _extendDurationFor = await vestingInstance.extendDurationFor(); + + assert.equal(_sov, token.address); + assert.equal(_stackingAddress, staking.address); + assert.equal(_tokenOwner, a1); + assert.equal(_cliff.toString(), cliff); + assert.equal(_duration.toString(), duration); + assert.equal(_feeSharingProxy, feeSharingProxy.address); + assert.equal(_extendDurationFor, 52 * WEEK); + }); + }); + + describe("constructor", () => { + it("sets the expected values", async () => { + let vestingInstance = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vestingInstance = await VestingLogic.at(vestingInstance.address); + + // Check data + let _sov = await vestingInstance.SOV(); + let _stackingAddress = await vestingInstance.staking(); + let _tokenOwner = await vestingInstance.tokenOwner(); + let _cliff = await vestingInstance.cliff(); + let _duration = await vestingInstance.duration(); + let _feeSharingProxy = await vestingInstance.feeSharingProxy(); + let _extendDurationFor = await vestingInstance.extendDurationFor(); + + assert.equal(_sov, token.address); + assert.equal(_stackingAddress, staking.address); + assert.equal(_tokenOwner, root); + assert.equal(_cliff.toString(), cliff); + assert.equal(_duration.toString(), duration); + assert.equal(_feeSharingProxy, feeSharingProxy.address); + assert.equal(_extendDurationFor, 52 * WEEK); + }); + + it("fails if the 0 address is passed as SOV address", async () => { + await expectRevert( + Vesting.new( + vestingLogic.address, + constants.ZERO_ADDRESS, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ), + "SOV address invalid" + ); + }); + + it("fails if the 0 address is passed as token owner address", async () => { + await expectRevert( + Vesting.new( + vestingLogic.address, + token.address, + staking.address, + constants.ZERO_ADDRESS, + feeSharingProxy.address, + 52 * WEEK + ), + "token owner address invalid" + ); + }); + + it("fails if the 0 address is passed as staking address", async () => { + await expectRevert( + Vesting.new( + vestingLogic.address, + token.address, + constants.ZERO_ADDRESS, + root, + feeSharingProxy.address, + 52 * WEEK + ), + "staking address invalid" + ); + }); + + it("fails if the 0 address is passed as feeSharingProxy address", async () => { + await expectRevert( + Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + constants.ZERO_ADDRESS, + 52 * WEEK + ), + "feeSharingProxy address invalid" + ); + }); + + it("fails if logic is not a contract address", async () => { + await expectRevert( + Vesting.new( + a1, + token.address, + staking.address, + a1, + feeSharingProxy.address, + 52 * WEEK + ), + "_logic not a contract" + ); + }); + + it("fails if SOV is not a contract address", async () => { + await expectRevert( + Vesting.new( + vestingLogic.address, + a1, + staking.address, + a1, + feeSharingProxy.address, + 52 * WEEK + ), + "_SOV not a contract" + ); + }); + + it("fails if staking address is not a contract address", async () => { + await expectRevert( + Vesting.new( + vestingLogic.address, + token.address, + a1, + a1, + feeSharingProxy.address, + 52 * WEEK + ), + "_stakingAddress not a contract" + ); + }); + + it("fails if fee sharing is not a contract address", async () => { + await expectRevert( + Vesting.new( + vestingLogic.address, + token.address, + staking.address, + a1, + a1, + 52 * WEEK + ), + "_feeSharingProxy not a contract" + ); + }); + + it("fails if extendDurationFor is not rounding to month", async () => { + await expectRevert( + Vesting.new( + vestingLogic.address, + token.address, + staking.address, + a1, + feeSharingProxy.address, + 6 * WEEK + ), + "invalid duration" + ); + }); + }); + + describe("delegate", () => { + let vesting; + it("should stake tokens and delegate voting power", async () => { + let toStake = ONE_MILLON; + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + a2, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + + await token.approve(vesting.address, toStake); + let remainingStakeAmount = ONE_MILLON; + let lastStakingSchedule = 0; + while (remainingStakeAmount > 0) { + await vesting.stakeTokens(remainingStakeAmount, lastStakingSchedule); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + } + + // check delegatee + let data = await staking.getStakes.call(vesting.address); + /// @dev Optimization: This loop through 40 steps is a bottleneck + for (let i = 0; i < data.dates.length; i++) { + let delegatee = await staking.delegates(vesting.address, data.dates[i]); + expect(delegatee).equal(a2); + } + + // delegate + let tx = await vesting.delegate(a1, { from: a2 }); + + expectEvent(tx, "VotesDelegated", { + caller: a2, + delegatee: a1, + }); + + // check new delegatee + data = await staking.getStakes.call(vesting.address); + /// @dev Optimization: This loop through 40 steps is a bottleneck + for (let i = 0; i < data.dates.length; i++) { + let delegatee = await staking.delegates(vesting.address, data.dates[i]); + expect(delegatee).equal(a1); + } + }); + + it("fails if delegatee is zero address", async () => { + await expectRevert( + vesting.delegate(constants.ZERO_ADDRESS, { from: a2 }), + "delegatee address invalid" + ); + }); + + it("fails if not a token owner", async () => { + await expectRevert(vesting.delegate(a1, { from: a1 }), "unauthorized"); + }); + }); + + describe("stakeTokens; using Ganache", () => { + // Check random scenarios + let vesting; + it("should stake 1,000,000 SOV with a duration of 156 weeks and a 4 week cliff", async () => { + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + await token.approve(vesting.address, ONE_MILLON); + let remainingStakeAmount = ONE_MILLON; + let lastStakingSchedule = 0; + while (remainingStakeAmount > 0) { + let tx = await vesting.stakeTokens(remainingStakeAmount, lastStakingSchedule); + expectEvent(tx, "TokensStaked"); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + } + + // check delegatee + let data = await staking.getStakes.call(vesting.address); + for (let i = 0; i < data.dates.length; i++) { + let delegatee = await staking.delegates(vesting.address, data.dates[i]); + expect(delegatee).equal(root); + } + }); + + it("should stake 1,000,000 SOV with a duration of 156 weeks and a 4 week cliff", async () => { + let block = await lastBlock(); + let timestamp = parseInt(block.timestamp); + + let kickoffTS = await staking.kickoffTS(); + + let start = timestamp + 4 * WEEK; + let end = timestamp + 156 * WEEK; + + let numIntervals = Math.floor((end - start) / (4 * WEEK)) + 1; + let stakedPerInterval = ONE_MILLON / numIntervals; + + // positive case + for (let i = start; i <= end; i += 4 * WEEK) { + let periodFromKickoff = Math.floor((i - kickoffTS.toNumber()) / (2 * WEEK)); + let startBuf = periodFromKickoff * 2 * WEEK + kickoffTS.toNumber(); + let userStakingCheckpoints = await staking.userStakingCheckpoints( + vesting.address, + startBuf, + 0 + ); + + assert.equal(userStakingCheckpoints.stake.toString(), stakedPerInterval); + + let numUserStakingCheckpoints = await staking.numUserStakingCheckpoints( + vesting.address, + startBuf + ); + assert.equal(numUserStakingCheckpoints.toString(), "1"); + } + + // negative cases + + // start-10 to avoid coming to active checkpoint + let periodFromKickoff = Math.floor((start - 10 - kickoffTS.toNumber()) / (2 * WEEK)); + let startBuf = periodFromKickoff * 2 * WEEK + kickoffTS.toNumber(); + let userStakingCheckpoints = await staking.userStakingCheckpoints( + vesting.address, + startBuf, + 0 + ); + + assert.equal(userStakingCheckpoints.fromBlock.toNumber(), 0); + assert.equal(userStakingCheckpoints.stake.toString(), 0); + + let numUserStakingCheckpoints = await staking.numUserStakingCheckpoints( + vesting.address, + startBuf + ); + assert.equal(numUserStakingCheckpoints.toString(), "0"); + periodFromKickoff = Math.floor((end + 3 * WEEK - kickoffTS.toNumber()) / (2 * WEEK)); + startBuf = periodFromKickoff * 2 * WEEK + kickoffTS.toNumber(); + userStakingCheckpoints = await staking.userStakingCheckpoints( + vesting.address, + startBuf, + 0 + ); + + assert.equal(userStakingCheckpoints.fromBlock.toNumber(), 0); + assert.equal(userStakingCheckpoints.stake.toString(), 0); + + numUserStakingCheckpoints = await staking.numUserStakingCheckpoints( + vesting.address, + startBuf + ); + assert.equal(numUserStakingCheckpoints.toString(), "0"); + }); + + it("should not allow to stake 2 times 1,000,000 SOV with a duration of 156 weeks and a 4 week cliff", async () => { + let amount = ONE_MILLON; + let cliff = 4 * WEEK; + let duration = 156 * WEEK; + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + + await token.approve(vesting.address, amount); + let remainingStakeAmount = amount; + let lastStakingSchedule = 0; + while (remainingStakeAmount > 0) { + await vesting.stakeTokens(remainingStakeAmount, lastStakingSchedule); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + } + + await increaseTime(52 * WEEK); + await token.approve(vesting.address, amount); + await expectRevert(vesting.stakeTokens(amount, 0), "create new vesting address"); + }); + }); + + describe("stakeTokensWithApproval", () => { + let vesting; + + it("fails if invoked directly", async () => { + let amount = 1000; + let cliff = 4 * WEEK; + let duration = 39 * 4 * WEEK; + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + await expectRevert(vesting.stakeTokensWithApproval(root, amount, 0), "unauthorized"); + }); + + it("fails if pass wrong method in data", async () => { + let amount = 1000; + let cliff = 4 * WEEK; + let duration = 39 * 4 * WEEK; + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + + let contract = new web3.eth.Contract(vesting.abi, vesting.address); + let sender = root; + let data = contract.methods.stakeTokens(amount, 0).encodeABI(); + + await expectRevert( + token.approveAndCall(vesting.address, amount, data, { from: sender }), + "method is not allowed" + ); + }); + + it("should stake ONE MILLION tokens with a duration of 156 weeks and a 4 week cliff", async () => { + let amount = ONE_MILLON; + let cliff = 4 * WEEK; + let duration = 39 * 4 * WEEK; + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + + let contract = new web3.eth.Contract(vesting.abi, vesting.address); + let sender = root; + let data = contract.methods.stakeTokensWithApproval(sender, amount, 0).encodeABI(); + let tx = await token.approveAndCall(vesting.address, amount, data, { from: sender }); + let lastStakingSchedule = await vesting.lastStakingSchedule(); + let remainingStakeAmount = await vesting.remainingStakeAmount(); + + data = contract.methods + .stakeTokensWithApproval(sender, remainingStakeAmount, lastStakingSchedule) + .encodeABI(); + await token.approveAndCall(vesting.address, remainingStakeAmount, data, { + from: sender, + }); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + + data = contract.methods + .stakeTokensWithApproval(sender, remainingStakeAmount, lastStakingSchedule) + .encodeABI(); + await token.approveAndCall(vesting.address, remainingStakeAmount, data, { + from: sender, + }); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + assert.equal(remainingStakeAmount, 0); + }); + + it("should stake 39000 tokens with a duration of 156 weeks and a 4 week cliff", async () => { + let amount = 39000; + let cliff = 4 * WEEK; + let duration = 156 * WEEK; + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + + let contract = new web3.eth.Contract(vesting.abi, vesting.address); + let sender = root; + let data = contract.methods.stakeTokensWithApproval(sender, amount, 0).encodeABI(); + await token.approveAndCall(vesting.address, amount, data, { from: sender }); + let lastStakingSchedule = await vesting.lastStakingSchedule(); + let remainingStakeAmount = await vesting.remainingStakeAmount(); + + data = contract.methods + .stakeTokensWithApproval(sender, remainingStakeAmount, lastStakingSchedule) + .encodeABI(); + await token.approveAndCall(vesting.address, remainingStakeAmount, data, { + from: sender, + }); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + + data = contract.methods + .stakeTokensWithApproval(sender, remainingStakeAmount, lastStakingSchedule) + .encodeABI(); + await token.approveAndCall(vesting.address, remainingStakeAmount, data, { + from: sender, + }); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + assert.equal(remainingStakeAmount, 0); + + let block = await web3.eth.getBlock("latest"); + let timestamp = block.timestamp; + + let start = timestamp + cliff; + let end = timestamp + duration; + + let numIntervals = Math.floor((end - start) / (4 * WEEK)) + 1; + let stakedPerInterval = Math.floor(amount / numIntervals); + + // positive case + for (let i = start; i <= end; i += 4 * WEEK) { + let periodFromKickoff = Math.floor((i - kickoffTS.toNumber()) / (2 * WEEK)); + let startBuf = periodFromKickoff * 2 * WEEK + kickoffTS.toNumber(); + let userStakingCheckpoints = await staking.userStakingCheckpoints( + vesting.address, + startBuf, + 0 + ); + + assert.equal(userStakingCheckpoints.stake.toString(), stakedPerInterval); + + let numUserStakingCheckpoints = await staking.numUserStakingCheckpoints( + vesting.address, + startBuf + ); + assert.equal(numUserStakingCheckpoints.toString(), "1"); + } + }); + }); + + describe("withdrawTokens", () => { + let vesting; + it("should withdraw unlocked tokens", async () => { + // Save current amount + let previousAmount = await token.balanceOf(root); + let toStake = ONE_MILLON; + + // Stake + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + + await token.approve(vesting.address, toStake); + let remainingStakeAmount = ONE_MILLON; + let lastStakingSchedule = 0; + while (remainingStakeAmount > 0) { + await vesting.stakeTokens(remainingStakeAmount, lastStakingSchedule); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + } + + let amountAfterStake = await token.balanceOf(root); + + // time travel + await increaseTime(104 * WEEK); + + // withdraw + tx = await vesting.withdrawTokens(root); + + // check event + expectEvent(tx, "TokensWithdrawn", { + caller: root, + receiver: root, + }); + + // verify amount + let amount = await token.balanceOf(root); + + assert.equal( + previousAmount.sub(new BN(toStake)).toString(), + amountAfterStake.toString() + ); + expect(previousAmount).to.be.bignumber.greaterThan(amount); + expect(amount).to.be.bignumber.greaterThan(amountAfterStake); + }); + + it("should not withdraw unlocked tokens in the first year", async () => { + // Save current amount + let previousAmount = await token.balanceOf(root); + let toStake = ONE_MILLON; + + // Stake + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + + await token.approve(vesting.address, toStake); + let remainingStakeAmount = ONE_MILLON; + let lastStakingSchedule = 0; + while (remainingStakeAmount > 0) { + await vesting.stakeTokens(remainingStakeAmount, lastStakingSchedule); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + } + + let amountAfterStake = await token.balanceOf(root); + + // time travel + await increaseTime(34 * WEEK); + + // withdraw + tx = await vesting.withdrawTokens(root); + + // verify amount + let amount = await token.balanceOf(root); + + assert.equal( + previousAmount.sub(new BN(toStake)).toString(), + amountAfterStake.toString() + ); + expect(previousAmount).to.be.bignumber.greaterThan(amount); + assert.equal(amountAfterStake.toString(), amount.toString()); + }); + + it("should not allow for 2 stakes and withdrawal for the first year", async () => { + // Save current amount + let previousAmount = await token.balanceOf(root); + let toStake = ONE_ETHER; + + // Stake + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + + await token.approve(vesting.address, toStake); + await vesting.stakeTokens(toStake, 0); + let amountAfterStake = await token.balanceOf(root); + + // time travel + await increaseTime(20 * WEEK); + await token.approve(vesting.address, toStake); + await expectRevert(vesting.stakeTokens(toStake, 0), "create new vesting address"); + + // withdraw + tx = await vesting.withdrawTokens(root); + + // verify amount + let amount = await token.balanceOf(root); + + expect(previousAmount).to.be.bignumber.greaterThan(amount); + assert.equal(amountAfterStake.toString(), amount.toString()); + }); + + it("should do nothing if withdrawing a second time", async () => { + let amountOld = await token.balanceOf(root); + // withdraw + tx = await vesting.withdrawTokens(root); + + // verify amount + let amount = await token.balanceOf(root); + assert.equal(amountOld.toString(), amount.toString()); + }); + + it("should do nothing if withdrawing before reaching the cliff", async () => { + let toStake = ONE_MILLON; + + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + a1, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + + await token.approve(vesting.address, toStake); + let remainingStakeAmount = ONE_MILLON; + let lastStakingSchedule = 0; + while (remainingStakeAmount > 0) { + await vesting.stakeTokens(remainingStakeAmount, lastStakingSchedule); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + } + let amountOld = await token.balanceOf(root); + + // time travel + await increaseTime(2 * WEEK); + + // withdraw + tx = await vesting.withdrawTokens(a2, { from: a1 }); + + // verify amount + let amount = await token.balanceOf(root); + assert.equal(amountOld.toString(), amount.toString()); + }); + + it("should fail if the caller is not token owner", async () => { + await expectRevert(vesting.withdrawTokens(root, { from: a2 }), "unauthorized"); + await expectRevert(vesting.withdrawTokens(root, { from: a3 }), "unauthorized"); + + await expectRevert(vesting.withdrawTokens(root, { from: root }), "unauthorized"); + await increaseTime(30 * WEEK); + await expectRevert(vesting.withdrawTokens(root, { from: a2 }), "unauthorized"); + }); + + it("shouldn't be possible to use governanceWithdrawVesting by anyone but owner", async () => { + let toStake = ONE_MILLON; + + // Stake + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + + await token.approve(vesting.address, toStake); + let remainingStakeAmount = ONE_MILLON; + let lastStakingSchedule = 0; + while (remainingStakeAmount > 0) { + await vesting.stakeTokens(remainingStakeAmount, lastStakingSchedule); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + } + + await expectRevert( + staking.governanceWithdrawVesting(vesting.address, root, { from: a1 }), + "WS01" + ); + }); + + it("shouldn't be possible to use governanceWithdraw by user", async () => { + await expectRevert( + staking.governanceWithdraw(100, kickoffTS.toNumber() + 52 * WEEK, root), + "S07" + ); + }); + }); + + describe("collectDividends", async () => { + let vesting; + it("should fail if the caller is neither owner nor token owner", async () => { + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + a1, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + await expectRevert( + vesting.collectDividends(root, 10, a1, { from: a2 }), + "unauthorized" + ); + await expectRevert( + vesting.collectDividends(root, 10, a1, { from: a3 }), + "unauthorized" + ); + }); + + it("should fail if receiver address is invalid", async () => { + let maxCheckpoints = new BN(10); + await expectRevert( + vesting.collectDividends(a1, maxCheckpoints, constants.ZERO_ADDRESS, { from: a1 }), + "receiver address invalid" + ); + }); + + it("should collect dividends", async () => { + let maxCheckpoints = new BN(10); + await expectRevert(vesting.collectDividends(a1, maxCheckpoints, a2), "unauthorized"); + let tx = await vesting.collectDividends(a1, maxCheckpoints, a2, { from: a1 }); + + let testData = await feeSharingProxy.testData.call(); + expect(testData.loanPoolToken).to.be.equal(a1); + expect(testData.maxCheckpoints).to.be.bignumber.equal(maxCheckpoints); + expect(testData.receiver).to.be.equal(a2); + + expectEvent(tx, "DividendsCollected", { + caller: a1, + loanPoolToken: a1, + receiver: a2, + maxCheckpoints: maxCheckpoints, + }); + }); + }); + + describe("migrateToNewStakingContract", async () => { + let vesting; + it("should set the new staking contract", async () => { + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + a1, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + // 1. set new staking contract address on staking contract + + let newStaking = await StakingProxy.new(token.address); + await newStaking.setImplementation(stakingLogic.address); + newStaking = await StakingLogic.at(newStaking.address); + + await staking.setNewStakingContract(newStaking.address); + + // 2. call migrateToNewStakingContract + let tx = await vesting.migrateToNewStakingContract(); + expectEvent(tx, "MigratedToNewStakingContract", { + caller: root, + newStakingContract: newStaking.address, + }); + let _staking = await vesting.staking(); + assert.equal(_staking, newStaking.address); + }); + + it("should fail if there is no new staking contract set", async () => { + let newStaking = await StakingProxy.new(token.address); + await newStaking.setImplementation(stakingLogic.address); + newStaking = await StakingLogic.at(newStaking.address); + + vesting = await Vesting.new( + vestingLogic.address, + token.address, + newStaking.address, + a1, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + await expectRevert(vesting.migrateToNewStakingContract(), "S19"); + }); + + it("should fail if the caller is neither owner nor token owner", async () => { + let newStaking = await StakingProxy.new(token.address); + await newStaking.setImplementation(stakingLogic.address); + newStaking = await StakingLogic.at(newStaking.address); + + vesting = await Vesting.new( + vestingLogic.address, + token.address, + newStaking.address, + a1, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + + await newStaking.setNewStakingContract(newStaking.address); + + await expectRevert(vesting.migrateToNewStakingContract({ from: a2 }), "unauthorized"); + await expectRevert(vesting.migrateToNewStakingContract({ from: a3 }), "unauthorized"); + + await vesting.migrateToNewStakingContract(); + await vesting.migrateToNewStakingContract({ from: a1 }); + }); + }); + + describe("fouryearvesting", async () => { + let vesting, dates0, dates3, dates5; + it("staking schedule must fail if sufficient tokens aren't approved", async () => { + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + await token.approve(vesting.address, 1000); + await expectRevert( + vesting.stakeTokens(ONE_MILLON, 0), + "transfer amount exceeds allowance" + ); + }); + + it("staking schedule must fail for incorrect parameters", async () => { + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + await token.approve(vesting.address, ONE_MILLON); + + let remainingStakeAmount = ONE_MILLON; + let lastStakingSchedule = 0; + await vesting.stakeTokens(remainingStakeAmount, lastStakingSchedule); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + await expectRevert( + vesting.stakeTokens(remainingStakeAmount + 100, lastStakingSchedule), + "invalid params" + ); + await expectRevert( + vesting.stakeTokens(remainingStakeAmount, lastStakingSchedule + 100), + "invalid params" + ); + }); + + it("staking schedule must run for max duration", async () => { + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + await token.approve(vesting.address, ONE_MILLON); + + let remainingStakeAmount = ONE_MILLON; + let lastStakingSchedule = 0; + while (remainingStakeAmount > 0) { + await vesting.stakeTokens(remainingStakeAmount, lastStakingSchedule); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + } + + let data = await staking.getStakes.call(vesting.address); + assert.equal(data.dates.length, 39); + assert.equal(data.stakes.length, 39); + expect(data.stakes[0]).to.be.bignumber.equal(data.stakes[15]); + dates0 = data.dates[0]; + dates5 = data.dates[5]; + }); + + it("should extend duration of first 5 staking periods", async () => { + await increaseTime(20 * WEEK); + tx = await vesting.extendStaking(); + data = await staking.getStakes.call(vesting.address); + expect(data.stakes[0]).to.be.bignumber.equal(data.stakes[15]); + expect(dates0).to.be.bignumber.not.equal(data.dates[0]); + expect(dates5).to.be.bignumber.equal(data.dates[0]); + dates0 = data.dates[0]; + dates5 = data.dates[5]; + }); + + it("should extend duration of next 5 staking periods", async () => { + await increaseTime(20 * WEEK); + tx = await vesting.extendStaking(); + data = await staking.getStakes.call(vesting.address); + expect(data.stakes[0]).to.be.bignumber.equal(data.stakes[15]); + expect(dates0).to.be.bignumber.not.equal(data.dates[0]); + expect(dates5).to.be.bignumber.equal(data.dates[0]); + dates0 = data.dates[0]; + dates3 = data.dates[3]; + }); + + it("should extend duration of next 3 staking periods only", async () => { + await increaseTime(20 * WEEK); + tx = await vesting.extendStaking(); + data = await staking.getStakes.call(vesting.address); + expect(data.stakes[0]).to.be.bignumber.equal(data.stakes[15]); + expect(dates0).to.be.bignumber.not.equal(data.dates[0]); + expect(dates3).to.be.bignumber.equal(data.dates[0]); + }); + + it("should not withdraw unlocked tokens if receiver address is 0", async () => { + // withdraw + await expectRevert( + vesting.withdrawTokens(constants.ZERO_ADDRESS), + "receiver address invalid" + ); + }); + + it("should withdraw unlocked tokens for four year vesting after first year", async () => { + // time travel + await increaseTime(104 * WEEK); + + // withdraw + tx = await vesting.withdrawTokens(root); + + // check event + expectEvent(tx, "TokensWithdrawn", { + caller: root, + receiver: root, + }); + }); + }); + + describe("setMaxInterval", async () => { + it("should set/alter maxInterval", async () => { + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + a2, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + let maxIntervalOld = await vesting.maxInterval(); + await vesting.setMaxInterval(60 * WEEK); + let maxIntervalNew = await vesting.maxInterval(); + expect(maxIntervalOld).to.be.bignumber.not.equal(maxIntervalNew); + }); + + it("should not set/alter maxInterval", async () => { + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + a2, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + await expectRevert(vesting.setMaxInterval(7 * WEEK), "invalid interval"); + }); + }); + + describe("extend duration and delegate", async () => { + it("must delegate for all intervals", async () => { + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + root, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + await token.approve(vesting.address, ONE_MILLON); + + let remainingStakeAmount = ONE_MILLON; + let lastStakingSchedule = 0; + while (remainingStakeAmount > 0) { + await vesting.stakeTokens(remainingStakeAmount, lastStakingSchedule); + lastStakingSchedule = await vesting.lastStakingSchedule(); + remainingStakeAmount = await vesting.remainingStakeAmount(); + } + + let data = await staking.getStakes.call(vesting.address); + for (let i = 0; i < data.dates.length; i++) { + let delegatee = await staking.delegates(vesting.address, data.dates[i]); + expect(delegatee).equal(root); + } + + await increaseTime(80 * WEEK); + let tx = await vesting.extendStaking(); + // delegate + tx = await vesting.delegate(a1); + console.log("gasUsed: " + tx.receipt.gasUsed); + expectEvent(tx, "VotesDelegated", { + caller: root, + delegatee: a1, + }); + data = await staking.getStakes.call(vesting.address); + for (let i = 0; i < data.dates.length; i++) { + let delegatee = await staking.delegates(vesting.address, data.dates[i]); + expect(delegatee).equal(a1); + } + }); + }); + + describe("changeTokenOwner", async () => { + let vesting; + it("should not change token owner if vesting owner didn't approve", async () => { + vesting = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + a1, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vesting.address); + await expectRevert(vesting.changeTokenOwner(a2, { from: a1 }), "unauthorized"); + }); + + it("changeTokenOwner should revert if address is zero", async () => { + await expectRevert( + vesting.changeTokenOwner(constants.ZERO_ADDRESS), + "invalid new token owner address" + ); + }); + + it("changeTokenOwner should revert if new owner is the same owner", async () => { + await expectRevert(vesting.changeTokenOwner(a1), "same owner not allowed"); + }); + + it("approveOwnershipTransfer should revert if new token address is zero", async () => { + await expectRevert(vesting.approveOwnershipTransfer({ from: a1 }), "invalid address"); + }); + + it("should not change token owner if token owner hasn't approved", async () => { + await vesting.changeTokenOwner(a2, { from: root }); + let newTokenOwner = await vesting.tokenOwner(); + expect(newTokenOwner).to.be.not.equal(a2); + }); + + it("approveOwnershipTransfer should revert if not signed by vesting owner", async () => { + await expectRevert(vesting.approveOwnershipTransfer({ from: a2 }), "unauthorized"); + }); + + it("should be able to change token owner", async () => { + let tx = await vesting.approveOwnershipTransfer({ from: a1 }); + // check event + expectEvent(tx, "TokenOwnerChanged", { + newOwner: a2, + oldOwner: a1, + }); + let newTokenOwner = await vesting.tokenOwner(); + assert.equal(newTokenOwner, a2); + }); + }); + + describe("setImplementation", async () => { + let vesting, newVestingLogic, vestingObject; + const NewVestingLogic = artifacts.require("MockFourYearVestingLogic"); + it("should not change implementation if token owner didn't sign", async () => { + vestingObject = await Vesting.new( + vestingLogic.address, + token.address, + staking.address, + a1, + feeSharingProxy.address, + 52 * WEEK + ); + vesting = await VestingLogic.at(vestingObject.address); + newVestingLogic = await NewVestingLogic.new(); + await expectRevert( + vesting.setImpl(newVestingLogic.address, { from: a3 }), + "unauthorized" + ); + await expectRevert(vesting.setImpl(newVestingLogic.address), "unauthorized"); + await expectRevert( + vesting.setImpl(constants.ZERO_ADDRESS, { from: a1 }), + "invalid new implementation address" + ); + }); + + it("should not change implementation if still unauthorized by vesting owner", async () => { + await vesting.setImpl(newVestingLogic.address, { from: a1 }); + let newImplementation = await vestingObject.getImplementation(); + expect(newImplementation).to.not.equal(newVestingLogic.address); + }); + + it("setImplementation should revert if not signed by vesting owner", async () => { + await expectRevert( + vestingObject.setImplementation(newVestingLogic.address, { from: a1 }), + "Proxy:: access denied" + ); + }); + + it("setImplementation should revert if logic address is not a contract", async () => { + await expectRevert( + vestingObject.setImplementation(a3, { from: root }), + "_implementation not a contract" + ); + }); + + it("setImplementation should revert if address mismatch", async () => { + await expectRevert( + vestingObject.setImplementation(vestingLogic.address, { from: root }), + "address mismatch" + ); + }); + + it("should be able to change implementation", async () => { + await vestingObject.setImplementation(newVestingLogic.address); + vesting = await NewVestingLogic.at(vesting.address); + + let durationLeft = await vesting.getDurationLeft(); + await token.approve(vesting.address, ONE_MILLON); + await vesting.stakeTokens(ONE_MILLON, 0); + let durationLeftNew = await vesting.getDurationLeft(); + expect(durationLeft).to.be.bignumber.not.equal(durationLeftNew); + }); + }); +}); diff --git a/tests/vesting/VestingRegistryMigrations.js b/tests/vesting/VestingRegistryMigrations.js index 6e7465294..21d381974 100644 --- a/tests/vesting/VestingRegistryMigrations.js +++ b/tests/vesting/VestingRegistryMigrations.js @@ -16,6 +16,8 @@ const VestingRegistry = artifacts.require("VestingRegistry"); const VestingRegistry2 = artifacts.require("VestingRegistry2"); const VestingRegistry3 = artifacts.require("VestingRegistry3"); const TestToken = artifacts.require("TestToken"); +const FourYearVesting = artifacts.require("FourYearVesting"); +const FourYearVestingLogic = artifacts.require("FourYearVestingLogic"); const FOUR_WEEKS = new BN(4 * 7 * 24 * 60 * 60); const TEAM_VESTING_CLIFF = FOUR_WEEKS.mul(new BN(6)); @@ -23,6 +25,7 @@ const TEAM_VESTING_DURATION = FOUR_WEEKS.mul(new BN(36)); const TOTAL_SUPPLY = "100000000000000000000000000"; const ZERO_ADDRESS = constants.ZERO_ADDRESS; const pricsSats = "2500"; +const ONE_MILLON = "1000000000000000000000000"; contract("VestingRegistryMigrations", (accounts) => { let root, account1, account2, account3, account4; @@ -34,12 +37,13 @@ contract("VestingRegistryMigrations", (accounts) => { let vestingTeamAddress, vestingTeamAddress2, vestingTeamAddress3; let newVestingAddress, newVestingAddress2, newVestingAddress3; let newTeamVestingAddress, newTeamVestingAddress2, newTeamVestingAddress3; + let fourYearVestingLogic, fourYearVesting; let cliff = 1; // This is in 4 weeks. i.e. 1 * 4 weeks. let duration = 11; // This is in 4 weeks. i.e. 11 * 4 weeks. before(async () => { - [root, account1, account2, account3, accounts4, ...accounts] = accounts; + [root, account1, account2, account3, account4, ...accounts] = accounts; }); beforeEach(async () => { @@ -65,6 +69,18 @@ contract("VestingRegistryMigrations", (accounts) => { lockedSOV = await LockedSOV.new(SOV.address, vesting.address, cliff, duration, [root]); await vesting.addAdmin(lockedSOV.address); + + // Deploy four year vesting contracts + fourYearVestingLogic = await FourYearVestingLogic.new(); + fourYearVesting = await FourYearVesting.new( + fourYearVestingLogic.address, + SOV.address, + staking.address, + account4, + feeSharingProxy.address, + 13 * FOUR_WEEKS + ); + fourYearVesting = await FourYearVestingLogic.at(fourYearVesting.address); }); describe("addDeployedVestings", () => { @@ -265,5 +281,77 @@ contract("VestingRegistryMigrations", (accounts) => { "unauthorized" ); }); + + it("adds deployed four year vestings ", async () => { + // Stake tokens + await SOV.approve(fourYearVesting.address, ONE_MILLON); + + let remainingStakeAmount = ONE_MILLON; + let lastStakingSchedule = 0; + while (remainingStakeAmount > 0) { + await fourYearVesting.stakeTokens(remainingStakeAmount, lastStakingSchedule); + lastStakingSchedule = await fourYearVesting.lastStakingSchedule(); + remainingStakeAmount = await fourYearVesting.remainingStakeAmount(); + } + + // Verify the vesting is created correctly + let data = await staking.getStakes.call(fourYearVesting.address); + assert.equal(data.dates.length, 39); + assert.equal(data.stakes.length, 39); + expect(data.stakes[0]).to.be.bignumber.equal(data.stakes[38]); + + // Add deployed four year vesting to registry + let tx = await vesting.addFourYearVestings([account4], [fourYearVesting.address]); + console.log("gasUsed = " + tx.receipt.gasUsed); + + // Verify that it is added to the registry + let cliff = FOUR_WEEKS; + let duration = FOUR_WEEKS.mul(new BN(39)); + let newVestingAddress = await vesting.getVestingAddr(account4, cliff, duration, 4); + expect(fourYearVesting.address).equal(newVestingAddress); + expect(await vesting.isVestingAdress(newVestingAddress)).equal(true); + + let vestingAddresses = await vesting.getVestingsOf(account4); + assert.equal(vestingAddresses.length.toString(), "1"); + assert.equal(vestingAddresses[0].vestingType, 1); + assert.equal(vestingAddresses[0].vestingCreationType, 4); + assert.equal(vestingAddresses[0].vestingAddress, newVestingAddress); + }); + + it("fails adding four year vesting if already added", async () => { + // Add deployed four year vesting to registry + await vesting.addFourYearVestings([account4], [fourYearVesting.address]); + await expectRevert( + vesting.addFourYearVestings([account4], [fourYearVesting.address]), + "vesting exists" + ); + }); + + it("fails adding four year vesting if array mismatch", async () => { + await expectRevert(vesting.addFourYearVestings([account4], []), "arrays mismatch"); + }); + + it("fails adding four year vesting if token owner is zero address", async () => { + await expectRevert( + vesting.addFourYearVestings([ZERO_ADDRESS], [fourYearVesting.address]), + "token owner cannot be 0 address" + ); + }); + + it("fails adding four year vesting if vesting is zero address", async () => { + await expectRevert( + vesting.addFourYearVestings([account4], [ZERO_ADDRESS]), + "vesting cannot be 0 address" + ); + }); + + it("fails adding four year vesting if sender isn't an owner", async () => { + await expectRevert( + vesting.addFourYearVestings([account1], [fourYearVesting.address], { + from: account2, + }), + "unauthorized" + ); + }); }); });