diff --git a/likecoin3/contracts/veLikeRewardNoLock.sol b/likecoin3/contracts/veLikeRewardNoLock.sol new file mode 100644 index 00000000..085fd314 --- /dev/null +++ b/likecoin3/contracts/veLikeRewardNoLock.sol @@ -0,0 +1,500 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +/// @custom:security-contact rickmak@oursky.com +contract veLikeRewardNoLock is + OwnableUpgradeable, + UUPSUpgradeable, + PausableUpgradeable, + ReentrancyGuardUpgradeable +{ + struct StakingCondition { + uint256 startTime; + uint256 endTime; + uint256 rewardAmount; + uint256 rewardIndex; + } + + struct StakerInfo { + uint256 stakedAmount; + uint256 rewardIndex; + uint256 rewardClaimed; // Not use for calculation, only for tracking. + } + + struct veLikeRewardStorage { + address vault; + address likecoin; + uint256 rewardPool; // Tracking the likecoin pool authorized for reward distribution. + uint256 totalStaked; + uint256 lastRewardTime; + StakingCondition currentStakingCondition; + mapping(address account => StakerInfo stakerInfo) stakerInfos; + address drawer; + bool autoSyncEnabled; // Set by initTotalStaked() to enable lazy staker sync. + } + + uint256 public constant ACC_REWARD_PRECISION = 1e18; // Precision scalar for reward index + + // keccak256(abi.encode(uint256(keccak256("veLikeReward.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant CLASS_DATA_STORAGE = + 0xe9672d2c676bb94d428d6ce523668c779079df8febe4142a9972a2a2313d2c00; + + function _getveLikeRewardData() + private + pure + returns (veLikeRewardStorage storage $) + { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := CLASS_DATA_STORAGE + } + } + + // Errors + error ErrNoRewardToClaim(); + error ErrConflictCondition(); + error ErrUnauthorized(); + error ErrNotActive(); + error ErrAlreadySynced(); + error ErrMismatchSync(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address initialOwner) public initializer { + __Pausable_init(); + __ReentrancyGuard_init(); + __Ownable_init(initialOwner); + __UUPSUpgradeable_init(); + } + + function _authorizeUpgrade( + address newImplementation + ) internal override onlyOwner {} + + modifier onlyVault() { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + if (_msgSender() != $.vault) { + revert ErrUnauthorized(); + } + _; + } + + // Start of veLikeRewardNoLock specific functions + + function setVault(address vault) public onlyOwner { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + $.vault = vault; + } + function setLikecoin(address likecoin) public onlyOwner { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + $.likecoin = likecoin; + } + function getConfig() + public + view + returns (address, address, uint256, uint256, uint256) + { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + return ( + $.vault, + $.likecoin, + $.rewardPool, + $.totalStaked, + $.lastRewardTime + ); + } + + /** + * getCurrentCondition function + * + * Get the current staking condition, it can be inactive. i.e. not started or already ended. + * + * @return currentCondition - the current staking condition + */ + function getCurrentCondition() + public + view + returns (StakingCondition memory) + { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + return $.currentStakingCondition; + } + + function getClaimedReward(address account) public view returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + StakerInfo memory stakerInfo = $.stakerInfos[account]; + return stakerInfo.rewardClaimed; + } + + /** + * getPendingReward function + * + * Get the pending reward for the account. Calculated to the query block height. + * In subsequent claim, the reward might be more as block height is updated. + * + * For un-synced stakers (pre-rotation stakers who have vault balance but + * stakedAmount == 0), the vault balance is used as the effective stake. + * + * @param account - the account to get the pending reward for + * @return pendingReward - the pending reward for the account + */ + function getPendingReward(address account) public view returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + uint256 calculatedReward = _pendingReward(account); + uint256 stakedAmount = _effectiveStakedAmount(account); + if ( + stakedAmount == 0 || + $.totalStaked == 0 || + $.currentStakingCondition.endTime <= + $.currentStakingCondition.startTime + ) { + return calculatedReward; + } + uint256 targetTime = block.timestamp; + if (targetTime > $.currentStakingCondition.endTime) { + targetTime = $.currentStakingCondition.endTime; + } + uint256 timePassed = targetTime - $.lastRewardTime; + uint256 newReward = timePassed * + _rewardPerTimeWithPrecision($.currentStakingCondition); + uint256 nonCalculatedReward = (newReward * stakedAmount) / + ($.totalStaked * ACC_REWARD_PRECISION); + return calculatedReward + nonCalculatedReward; + } + + /** + * _pendingReward function + * + * Internal function to calculate the pending reward for the account. + * Uses _effectiveStakedAmount to handle un-synced pre-rotation stakers. + * + */ + function _pendingReward(address account) internal view returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + StakerInfo memory stakerInfo = $.stakerInfos[account]; + uint256 stakedAmount = _effectiveStakedAmount(account); + return + (stakedAmount * + ($.currentStakingCondition.rewardIndex - + stakerInfo.rewardIndex)) / ACC_REWARD_PRECISION; + } + + /** + * _effectiveStakedAmount function + * + * Returns the effective staked amount for reward calculation. + * For synced users, returns stakerInfo.stakedAmount. + * For un-synced pre-rotation stakers (stakedAmount == 0 but vault balance > 0), + * returns the vault balance so they earn retroactive rewards. + * This fallback only applies when autoSyncEnabled is true (set by initTotalStaked). + */ + function _effectiveStakedAmount( + address account + ) internal view returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + uint256 stakedAmount = $.stakerInfos[account].stakedAmount; + if (stakedAmount == 0 && $.autoSyncEnabled) { + return IERC4626($.vault).balanceOf(account); + } + return stakedAmount; + } + + /** + * _syncStaker function + * + * Lazy-sync a pre-rotation staker into this reward contract. + * Only operates when autoSyncEnabled is true (set by initTotalStaked). + * If stakerInfo.stakedAmount == 0 but the user has a vault balance, + * sets stakedAmount to match the vault balance. The user's rewardIndex + * stays at 0, so they earn retroactive rewards from the period start + * (since addReward resets rewardIndex to 0). + * + * totalStaked is NOT adjusted because it was pre-initialized via + * initTotalStaked() to include all vault holders. + */ + function _syncStaker(address account) internal { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + if (!$.autoSyncEnabled) { + return; + } + StakerInfo storage stakerInfo = $.stakerInfos[account]; + if (stakerInfo.stakedAmount != 0) { + return; + } + uint256 vaultBalance = IERC4626($.vault).balanceOf(account); + if (vaultBalance == 0) { + return; + } + stakerInfo.stakedAmount = vaultBalance; + } + + function _isActive() internal view returns (bool) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + if ( + block.timestamp < $.currentStakingCondition.startTime || + block.timestamp > $.currentStakingCondition.endTime + ) { + return false; + } + return true; + } + + /** + * _updateVault function + * + * Update the vault reward index and reward debt. + * + */ + function _updateVault() internal { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + StakingCondition storage currentCondition = $.currentStakingCondition; + uint256 targetTime = block.timestamp; + if (targetTime < currentCondition.startTime) { + targetTime = currentCondition.startTime; + } + if (targetTime > currentCondition.endTime) { + targetTime = currentCondition.endTime; + } + if (targetTime == $.lastRewardTime) { + return; + } + if ($.totalStaked > 0) { + uint256 timePassed = targetTime - $.lastRewardTime; + uint256 reward = timePassed * + _rewardPerTimeWithPrecision(currentCondition); + currentCondition.rewardIndex += reward / $.totalStaked; + $.lastRewardTime = targetTime; + } + } + + function _rewardPerTimeWithPrecision( + StakingCondition memory condition + ) internal pure returns (uint256) { + return + (ACC_REWARD_PRECISION * condition.rewardAmount) / + (condition.endTime - condition.startTime); + } + + // End of veLikeRewardNoLock specific functions + + // Start of Vault functions + + function deposit( + address account, + uint256 stakedAmount + ) public whenNotPaused onlyVault { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + _syncStaker(account); + _updateVault(); + _claimReward(account, false); + $.stakerInfos[account].stakedAmount += stakedAmount; + $.totalStaked += stakedAmount; + } + + function withdraw( + address account, + uint256 amount + ) public whenNotPaused onlyVault { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + _syncStaker(account); + _updateVault(); + _claimReward(account, false); + $.totalStaked -= amount; + $.stakerInfos[account].stakedAmount -= amount; + } + + /** + * claimReward function + * + * Claim the reward for the account, only caller by vault. + * + * @param account - the account to claim the reward for + * @param restake - true if the reward should be restaked, false if the reward should be claimed + * @return reward - the reward for the account + */ + function claimReward( + address account, + bool restake + ) public whenNotPaused onlyVault returns (uint256) { + _syncStaker(account); + uint256 currentPendingReward = getPendingReward(account); + if (currentPendingReward == 0) { + revert ErrNoRewardToClaim(); + } + return _claimReward(account, restake); + } + + /** + * _claimReward function + * + * Claim the reward for the account. + * + * @param account - the account to claim the reward for + * @param restake - true if the reward should be restaked, false if the reward should be claimed + * @return reward - the reward for the account + */ + function _claimReward( + address account, + bool restake + ) public onlyVault returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + StakerInfo storage stakerInfo = $.stakerInfos[account]; + + _updateVault(); + uint256 rewardClaimed = _pendingReward(account); + stakerInfo.rewardClaimed += rewardClaimed; + stakerInfo.rewardIndex = $.currentStakingCondition.rewardIndex; + $.rewardPool -= rewardClaimed; + if (rewardClaimed == 0) { + return 0; + } + if (restake) { + stakerInfo.stakedAmount += rewardClaimed; + $.totalStaked += rewardClaimed; + // Relay on the Vault to _mint the veLIKE. + } else { + SafeERC20.safeTransferFrom( + IERC20($.likecoin), + $.drawer, + account, + rewardClaimed + ); + } + return rewardClaimed; + } + // End of Vault functions + + // Start of Admin functions + + function pause() public onlyOwner { + _pause(); + } + + function unpause() public onlyOwner { + _unpause(); + } + + /** + * getLastRewardTime function + * + * Get the last reward time. + * + * @return lastRewardTime - the last reward time + */ + function getLastRewardTime() public view returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + return $.lastRewardTime; + } + + function getRewardPool() public view returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + return $.rewardPool; + } + + /** + * initTotalStaked function + * + * Initialize totalStaked from the vault's totalSupply and enable + * auto-sync for pre-rotation stakers. Called once during deployment + * setup (after setVault) to ensure the reward accumulator uses the + * correct denominator that includes all existing vault holders. + */ + function initTotalStaked() external onlyOwner { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + require(!$.autoSyncEnabled, "Already initialized"); + $.totalStaked = IERC4626($.vault).totalSupply(); + $.autoSyncEnabled = true; + } + + /** + * syncStakers function + * + * Admin function to eagerly sync pre-rotation stakers into this reward + * contract. Must be called during the active reward period (between + * startTime and endTime). For each account, sets stakedAmount to the + * current vault balance. The staker's rewardIndex stays at 0 so they + * earn retroactive rewards from the period start. + * + * totalStaked is NOT adjusted because it was pre-initialized via + * initTotalStaked() to include all vault holders. + * + * Reverts with ErrAlreadySynced if the account is already synced and + * the stakedAmount matches the vault balance. Reverts with + * ErrMismatchSync if the account is already synced but the + * stakedAmount differs from the vault balance. + * + * @param accounts - the accounts to sync + */ + function syncStakers(address[] calldata accounts) external onlyOwner { + if (!_isActive()) { + revert ErrNotActive(); + } + veLikeRewardStorage storage $ = _getveLikeRewardData(); + for (uint256 i = 0; i < accounts.length; i++) { + address account = accounts[i]; + uint256 vaultBalance = IERC4626($.vault).balanceOf(account); + StakerInfo storage stakerInfo = $.stakerInfos[account]; + if (stakerInfo.stakedAmount != 0) { + if (stakerInfo.stakedAmount == vaultBalance) { + revert ErrAlreadySynced(); + } else { + revert ErrMismatchSync(); + } + } + stakerInfo.stakedAmount = vaultBalance; + } + } + + /** + * addReward function + * + * Admin function for authorized address too deposit asset as reward. This + * staking vault rewards is linearly over time. reward calculation is update in the current block timestamp. + * + * @param rewardAmount - the amount of reward to deposit, asset ERC20(likecoin) + * @param endTime - the end time of the staking condition + */ + function addReward( + address drawer, + uint256 rewardAmount, + uint256 startTime, + uint256 endTime + ) external onlyOwner { + if (_isActive()) { + revert ErrConflictCondition(); + } + veLikeRewardStorage storage $ = _getveLikeRewardData(); + if (startTime <= $.lastRewardTime) { + revert ErrConflictCondition(); + } + if (endTime < startTime) { + revert ErrConflictCondition(); + } + if (endTime < block.timestamp) { + revert ErrConflictCondition(); + } + $.lastRewardTime = startTime; + $.drawer = drawer; + // perform last update if needed + $.rewardPool += rewardAmount; + $.currentStakingCondition = StakingCondition({ + startTime: startTime, + endTime: endTime, + rewardAmount: rewardAmount, + rewardIndex: 0 + }); + } + + // End of Admin functions +} diff --git a/likecoin3/contracts/veLikeRewardV2.sol b/likecoin3/contracts/veLikeRewardV2.sol new file mode 100644 index 00000000..eb7aa708 --- /dev/null +++ b/likecoin3/contracts/veLikeRewardV2.sol @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +/// @custom:security-contact rickmak@oursky.com +contract veLikeReward is + OwnableUpgradeable, + UUPSUpgradeable, + PausableUpgradeable, + ReentrancyGuardUpgradeable +{ + struct StakingCondition { + uint256 startTime; + uint256 endTime; + uint256 rewardAmount; + uint256 rewardIndex; + } + + struct StakerInfo { + uint256 stakedAmount; + uint256 rewardIndex; + uint256 rewardClaimed; // Not use for calculation, only for tracking. + } + + struct veLikeRewardStorage { + address vault; + address likecoin; + uint256 rewardPool; // Tracking the likecoin pool authorized for reward distribution. + uint256 totalStaked; + uint256 lastRewardTime; + StakingCondition currentStakingCondition; + mapping(address account => StakerInfo stakerInfo) stakerInfos; + address drawer; + } + + uint256 public constant ACC_REWARD_PRECISION = 1e18; // Precision scalar for reward index + + // keccak256(abi.encode(uint256(keccak256("veLikeReward.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant CLASS_DATA_STORAGE = + 0xe9672d2c676bb94d428d6ce523668c779079df8febe4142a9972a2a2313d2c00; + + function _getveLikeRewardData() + private + pure + returns (veLikeRewardStorage storage $) + { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := CLASS_DATA_STORAGE + } + } + + // Errors + error ErrWithdrawLocked(); + error ErrNoRewardToClaim(); + error ErrConflictCondition(); + error ErrUnauthorized(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address initialOwner) public initializer { + __Pausable_init(); + __ReentrancyGuard_init(); + __Ownable_init(initialOwner); + __UUPSUpgradeable_init(); + } + + function _authorizeUpgrade( + address newImplementation + ) internal override onlyOwner {} + + modifier onlyVault() { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + if (_msgSender() != $.vault) { + revert ErrUnauthorized(); + } + _; + } + + // Start of veLikeReward specific functions + + function setVault(address vault) public onlyOwner { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + $.vault = vault; + } + function setLikecoin(address likecoin) public onlyOwner { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + $.likecoin = likecoin; + } + function getConfig() + public + view + returns (address, address, uint256, uint256, uint256) + { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + return ( + $.vault, + $.likecoin, + $.rewardPool, + $.totalStaked, + $.lastRewardTime + ); + } + + /** + * getCurrentCondition function + * + * Get the current staking condition, it can be inactive. i.e. not started or already ended. + * + * @return currentCondition - the current staking condition + */ + function getCurrentCondition() + public + view + returns (StakingCondition memory) + { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + return $.currentStakingCondition; + } + + function getClaimedReward(address account) public view returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + StakerInfo memory stakerInfo = $.stakerInfos[account]; + return stakerInfo.rewardClaimed; + } + + /** + * getPendingReward function + * + * Get the pending reward for the account. Calculated to the query block height. + * In subsequent claim, the reward might be more as block height is updated. + * + * @param account - the account to get the pending reward for + * @return pendingReward - the pending reward for the account + */ + function getPendingReward(address account) public view returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + uint256 calculatedReward = _pendingReward(account); + uint256 stakedAmount = $.stakerInfos[account].stakedAmount; + if (stakedAmount == 0) { + return calculatedReward; + } + uint256 targetTime = block.timestamp; + if (targetTime > $.currentStakingCondition.endTime) { + targetTime = $.currentStakingCondition.endTime; + } + uint256 timePassed = targetTime - $.lastRewardTime; + uint256 newReward = timePassed * + _rewardPerTimeWithPrecision($.currentStakingCondition); + uint256 nonCalculatedReward = (newReward * stakedAmount) / + ($.totalStaked * ACC_REWARD_PRECISION); + return calculatedReward + nonCalculatedReward; + } + + /** + * _pendingReward function + * + * Internal function to calculate the pending reward for the account. + * + */ + function _pendingReward(address account) internal view returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + StakerInfo memory stakerInfo = $.stakerInfos[account]; + return + (stakerInfo.stakedAmount * + ($.currentStakingCondition.rewardIndex - + stakerInfo.rewardIndex)) / ACC_REWARD_PRECISION; + } + + function _isActive() internal view returns (bool) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + if ( + block.timestamp < $.currentStakingCondition.startTime || + block.timestamp > $.currentStakingCondition.endTime + ) { + return false; + } + return true; + } + + /** + * _updateVault function + * + * Update the vault reward index and reward debt. + * + */ + function _updateVault() internal { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + StakingCondition storage currentCondition = $.currentStakingCondition; + uint256 targetTime = block.timestamp; + if (targetTime < currentCondition.startTime) { + targetTime = currentCondition.startTime; + } + if (targetTime > currentCondition.endTime) { + targetTime = currentCondition.endTime; + } + if (targetTime == $.lastRewardTime) { + return; + } + if ($.totalStaked > 0) { + uint256 timePassed = targetTime - $.lastRewardTime; + uint256 reward = timePassed * + _rewardPerTimeWithPrecision(currentCondition); + currentCondition.rewardIndex += reward / $.totalStaked; + $.lastRewardTime = targetTime; + } + } + + function _rewardPerTimeWithPrecision( + StakingCondition memory condition + ) internal pure returns (uint256) { + return + (ACC_REWARD_PRECISION * condition.rewardAmount) / + (condition.endTime - condition.startTime); + } + + // End of veLikeReward specific functions + + // Start of Vault functions + + function deposit( + address account, + uint256 stakedAmount + ) public whenNotPaused onlyVault { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + _updateVault(); + // Note, we must claim the reward, othereise the denominator will be wrong on next claim. + _claimReward(account, false); + $.stakerInfos[account].stakedAmount += stakedAmount; + $.totalStaked += stakedAmount; + } + + function withdraw( + address account, + uint256 amount + ) public whenNotPaused onlyVault { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + _updateVault(); + _claimReward(account, false); + $.totalStaked -= amount; + $.stakerInfos[account].stakedAmount -= amount; + } + + /** + * claimReward function + * + * Claim the reward for the account, only caller by vault. + * + * @param account - the account to claim the reward for + * @param restake - true if the reward should be restaked, false if the reward should be claimed + * @return reward - the reward for the account + */ + function claimReward( + address account, + bool restake + ) public whenNotPaused onlyVault returns (uint256) { + uint256 currentPendingReward = getPendingReward(account); + if (currentPendingReward == 0) { + revert ErrNoRewardToClaim(); + } + return _claimReward(account, restake); + } + + /** + * _claimReward function + * + * Claim the reward for the account. + * + * @param account - the account to claim the reward for + * @param restake - true if the reward should be restaked, false if the reward should be claimed + * @return reward - the reward for the account + */ + function _claimReward( + address account, + bool restake + ) public onlyVault returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + StakerInfo storage stakerInfo = $.stakerInfos[account]; + + _updateVault(); + uint256 rewardClaimed = _pendingReward(account); + stakerInfo.rewardClaimed += rewardClaimed; + stakerInfo.rewardIndex = $.currentStakingCondition.rewardIndex; + $.rewardPool -= rewardClaimed; + if (rewardClaimed == 0) { + return 0; + } + if (restake) { + stakerInfo.stakedAmount += rewardClaimed; + $.totalStaked += rewardClaimed; + // Relay on the Vault to _mint the veLIKE. + } else { + SafeERC20.safeTransferFrom( + IERC20($.likecoin), + $.drawer, + account, + rewardClaimed + ); + } + return rewardClaimed; + } + // End of Vault functions + + // Start of Admin functions + + function pause() public onlyOwner { + _pause(); + } + + function unpause() public onlyOwner { + _unpause(); + } + + /** + * getLastRewardTime function + * + * Get the last reward time. + * + * @return lastRewardTime - the last reward time + */ + function getLastRewardTime() public view returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + return $.lastRewardTime; + } + + function getRewardPool() public view returns (uint256) { + veLikeRewardStorage storage $ = _getveLikeRewardData(); + return $.rewardPool; + } + + /** + * addReward function + * + * Admin function for authorized address too deposit asset as reward. This + * staking vault rewards is linearly over time. reward calculation is update in the current block timestamp. + * + * @param rewardAmount - the amount of reward to deposit, asset ERC20(likecoin) + * @param endTime - the end time of the staking condition + */ + function addReward( + address drawer, + uint256 rewardAmount, + uint256 startTime, + uint256 endTime + ) external onlyOwner { + if (_isActive()) { + revert ErrConflictCondition(); + } + veLikeRewardStorage storage $ = _getveLikeRewardData(); + if (startTime <= $.lastRewardTime) { + revert ErrConflictCondition(); + } + if (endTime < startTime) { + revert ErrConflictCondition(); + } + if (endTime < block.timestamp) { + revert ErrConflictCondition(); + } + $.lastRewardTime = startTime; + $.drawer = drawer; + // perform last update if needed + $.rewardPool += rewardAmount; + $.currentStakingCondition = StakingCondition({ + startTime: startTime, + endTime: endTime, + rewardAmount: rewardAmount, + rewardIndex: 0 + }); + } + + // End of Admin functions +} diff --git a/likecoin3/contracts/veLikeV2.sol b/likecoin3/contracts/veLikeV2.sol new file mode 100644 index 00000000..85f09b55 --- /dev/null +++ b/likecoin3/contracts/veLikeV2.sol @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {Likecoin} from "./Likecoin.sol"; + +interface IRewardContract { + function getPendingReward(address account) external view returns (uint256); + function claimReward( + address account, + bool restake + ) external returns (uint256); + function deposit(address account, uint256 rewardAmount) external; + function withdraw(address account, uint256 amount) external; +} + +/// @custom:security-contact rickmak@oursky.com +contract veLike is + ERC4626Upgradeable, + OwnableUpgradeable, + UUPSUpgradeable, + PausableUpgradeable, + ReentrancyGuardUpgradeable +{ + struct veLikeStorage { + address rewardContract; + uint256 lockTime; + mapping(address => bool) isLegacyRewardContract; + } + + // keccak256(abi.encode(uint256(keccak256("veLike.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant CLASS_DATA_STORAGE = + 0xb9e14b2a89d227541697d62a06ecbf5ccc9ad849800745b40b2826662a177600; + + function _getveLikeData() private pure returns (veLikeStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := CLASS_DATA_STORAGE + } + } + + // Errors + error ErrNoRewardToClaim(); + error ErrNonTransferable(); + error ErrWithdrawLocked(); + error ErrNotLegacyRewardContract(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address initialOwner, address like) public initializer { + __ERC4626_init(IERC20(address(like))); + __ERC20_init("vote-escrowed LikeCoin", "veLIKE"); + __Pausable_init(); + __ReentrancyGuard_init(); + __Ownable_init(initialOwner); + __UUPSUpgradeable_init(); + } + + function _authorizeUpgrade( + address newImplementation + ) internal override onlyOwner {} + + // Start of veLike specific functions + + /** + * setRewardContract function + * + * Set the reward contract for the veLike. + * + * @param rewardContract - the reward contract to set + */ + function setRewardContract(address rewardContract) public onlyOwner { + veLikeStorage storage $ = _getveLikeData(); + $.rewardContract = rewardContract; + } + + /** + * setLegacyRewardContract function + * + * Add or remove a legacy reward contract from the allowlist. + * Legacy reward contracts can be claimed by users after reward rotation. + * + * @param rewardContract - the legacy reward contract address + * @param allowed - true to allow, false to disallow + */ + function setLegacyRewardContract( + address rewardContract, + bool allowed + ) public onlyOwner { + veLikeStorage storage $ = _getveLikeData(); + $.isLegacyRewardContract[rewardContract] = allowed; + } + + /** + * claimLegacyReward function + * + * Claim reward from a legacy (rotated-out) reward contract. + * The legacy reward contract must be allowlisted via setLegacyRewardContract. + * + * @param legacyReward - the legacy reward contract address + * @param account - the account to claim the reward for + * @return reward - the reward claimed + */ + function claimLegacyReward( + address legacyReward, + address account + ) public whenNotPaused nonReentrant returns (uint256) { + veLikeStorage storage $ = _getveLikeData(); + if (!$.isLegacyRewardContract[legacyReward]) { + revert ErrNotLegacyRewardContract(); + } + uint256 reward = IRewardContract(legacyReward).claimReward( + account, + false + ); + return reward; + } + + /** + * setLockTime function + * + * Set the lock time for the veLike. No withdraw will be allowed before the lock time. + * + * @param lockTime - the lock time to set + */ + function setLockTime(uint256 lockTime) public onlyOwner { + veLikeStorage storage $ = _getveLikeData(); + $.lockTime = lockTime; + } + + function getLockTime() public view returns (uint256) { + veLikeStorage storage $ = _getveLikeData(); + return $.lockTime; + } + + /** + * getCurrentCondition function + * + * Get the current staking condition, it can be inactive. i.e. not started or already ended. + * + * @return currentCondition - the current staking condition + */ + function getCurrentRewardContract() public view returns (IRewardContract) { + veLikeStorage storage $ = _getveLikeData(); + return IRewardContract($.rewardContract); + } + + /** + * getPendingReward function + * + * Get the pending reward for the account. Calculated to the query block height. + * In subsequent claim, the reward might be more as block height is updated. + * + * @param account - the account to get the pending reward for + * @return pendingReward - the pending reward for the account + */ + function getPendingReward(address account) public view returns (uint256) { + IRewardContract rewardContract = getCurrentRewardContract(); + if (rewardContract == IRewardContract(address(0))) { + return 0; + } + return rewardContract.getPendingReward(account); + } + + /** + * claimReward function + * + * Claim the reward for the account. + * + * @param account - the account to claim the reward for + * @return reward - the reward for the account + */ + function claimReward( + address account + ) public whenNotPaused nonReentrant returns (uint256) { + IRewardContract rewardContract = getCurrentRewardContract(); + if (rewardContract == IRewardContract(address(0))) { + revert ErrNoRewardToClaim(); + } + uint256 reward = rewardContract.claimReward(account, false); + return reward; + } + + /** + * restakeReward function + * + * Restake the reward for the account. + * + * @param account - the account to restake the reward + * @return reward - the amount of asset restaked + */ + function restakeReward( + address account + ) public whenNotPaused nonReentrant returns (uint256) { + IRewardContract rewardContract = getCurrentRewardContract(); + if (rewardContract == IRewardContract(address(0))) { + revert ErrNoRewardToClaim(); + } + uint256 reward = rewardContract.claimReward(account, true); + _mint(account, reward); + return reward; + } + + // End of veLike specific functions + // Start of ERC20 Overrides + + /** + * transfer function + * + * veLIKE is non-transferable voting escrow token, so it should not be transferred. + * Override ERC20 transfer function to revert. + * + * @return bool - true if the transfer is successful + */ + function transfer( + address, + uint256 + ) public virtual override(ERC20Upgradeable, IERC20) returns (bool) { + revert ErrNonTransferable(); + } + + /** + * transferFrom function + * + * veLIKE is non-transferable voting escrow token, so it should not be transferred. + * Override ERC20 transferFrom function to revert. + * + * @return bool - true if the transfer is successful + */ + function transferFrom( + address, + address, + uint256 + ) public virtual override(ERC20Upgradeable, IERC20) returns (bool) { + revert ErrNonTransferable(); + } + // End of ERC20 Overrides + + // Start of ERC4626 Overrides + /** + * totalAssets function + * + * veLike to Like should be one to one mapping, so the total supply is equal to the total assets. + * Note: Vault share is not veLike. + */ + function totalAssets() public view override returns (uint256) { + return totalSupply(); + } + /** + * _deposit function + * + * Override ERC4626 _deposit function to update staker info on vault share. mint + * + * @param caller - the caller of the deposit + * @param receiver - the receiver of the vault share + * @param assets - the amount of asset to deposit + * @param shares - the amount of shares to mint + */ + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal virtual override whenNotPaused { + // Copying from ERC4626 _deposit function for clarity + SafeERC20.safeTransferFrom( + IERC20(asset()), + caller, + address(this), + assets + ); + + // Vault specific logic: notify reward contract before mint so that + // _syncStaker reads the pre-deposit balanceOf for correct retroactive rewards. + IRewardContract rewardContract = getCurrentRewardContract(); + if (rewardContract != IRewardContract(address(0))) { + rewardContract.deposit(receiver, assets); + } + + _mint(receiver, shares); + + // Copying from ERC4626 _deposit function Event for clarity + emit Deposit(caller, receiver, assets, shares); + } + + /** + * _withdraw function + * + * Override ERC4626 _withdraw function to update staker info on vault share. burn + * + * @param caller - the caller of the withdraw + * @param receiver - the receiver of the vault share + * @param assets - the amount of asset to withdraw + * @param shares - the amount of shares to burn + */ + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual override whenNotPaused { + veLikeStorage storage $ = _getveLikeData(); + if (block.timestamp < $.lockTime) { + revert ErrWithdrawLocked(); + } + // Copying from ERC4626 _withdraw function for clarity + // Same as calling super._withdraw(caller, receiver, assets, shares); + if (caller != owner) { + _spendAllowance(owner, caller, shares); + } + + // Vault specific logic + IRewardContract rewardContract = getCurrentRewardContract(); + if (rewardContract != IRewardContract(address(0))) { + rewardContract.withdraw(owner, assets); + } + + // Copying from ERC4626 _withdraw function Event for clarity + _burn(owner, shares); + SafeERC20.safeTransfer(IERC20(asset()), receiver, assets); + emit Withdraw(caller, receiver, owner, assets, shares); + } + // End of ERC4626 Overrides + + // Start of Admin functions + + function pause() public onlyOwner { + _pause(); + } + + function unpause() public onlyOwner { + _unpause(); + } + // End of Admin functions +} diff --git a/likecoin3/ignition/modules/veLike.ts b/likecoin3/ignition/modules/veLike.ts index 3b5cdea0..5e3545ca 100644 --- a/likecoin3/ignition/modules/veLike.ts +++ b/likecoin3/ignition/modules/veLike.ts @@ -17,11 +17,11 @@ npx hardhat ignition wipe chain-901 \ const veLikeModule = buildModule("veLikeModule", (m) => { const { veLikeV0, veLikeProxy, likecoin } = m.useModule(veLikeV0Module); - const veLikeImpl = m.contract("veLike", [], { + const veLikeImpl = m.contract("contracts/veLike.sol:veLike", [], { id: "veLikeImpl", }); m.call(veLikeV0, "upgradeToAndCall", [veLikeImpl, "0x"]); - const veLike = m.contractAt("veLike", veLikeProxy); + const veLike = m.contractAt("contracts/veLike.sol:veLike", veLikeProxy); return { veLike, diff --git a/likecoin3/ignition/modules/veLikeReward.ts b/likecoin3/ignition/modules/veLikeReward.ts index 23f6eb0a..019004bc 100644 --- a/likecoin3/ignition/modules/veLikeReward.ts +++ b/likecoin3/ignition/modules/veLikeReward.ts @@ -21,9 +21,13 @@ npx hardhat ignition wipe chain-901 \ const veLikeRewardModule = buildModule("veLikeRewardModule", (m) => { const initOwner = m.getParameter("initOwner"); - const veLikeRewardImpl = m.contract("veLikeReward", [], { - id: "veLikeRewardImpl", - }); + const veLikeRewardImpl = m.contract( + "contracts/veLikeReward.sol:veLikeReward", + [], + { + id: "veLikeRewardImpl", + }, + ); const initData = m.encodeFunctionCall(veLikeRewardImpl, "initialize", [ initOwner, @@ -34,7 +38,10 @@ const veLikeRewardModule = buildModule("veLikeRewardModule", (m) => { initData, ]); - const veLikeReward = m.contractAt("veLikeReward", veLikeRewardProxy); + const veLikeReward = m.contractAt( + "contracts/veLikeReward.sol:veLikeReward", + veLikeRewardProxy, + ); return { veLikeReward, diff --git a/likecoin3/ignition/modules/veLikeRewardNoLock.ts b/likecoin3/ignition/modules/veLikeRewardNoLock.ts new file mode 100644 index 00000000..bb0c11c2 --- /dev/null +++ b/likecoin3/ignition/modules/veLikeRewardNoLock.ts @@ -0,0 +1,59 @@ +import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; +import veLikeModule from "./veLike"; + +/* +# Command to deploy the contract for testing +npx hardhat ignition deploy ignition/modules/veLikeRewardNoLock.ts \ + --strategy create2 \ + --parameters ignition/parameters.local.json \ + --network superism1 + +# Rerun only this for testing +npx hardhat ignition wipe chain-901 \ + veLikeRewardNoLockModule#veLikeRewardNoLock +npx hardhat ignition wipe chain-901 \ + veLikeRewardNoLockModule#ERC1967Proxy +npx hardhat ignition wipe chain-901 \ + "veLikeRewardNoLockModule#encodeFunctionCall(veLikeRewardNoLockModule#veLikeRewardNoLockImpl.initialize)" +npx hardhat ignition wipe chain-901 \ + veLikeRewardNoLockModule#veLikeRewardNoLockImpl +*/ +const veLikeRewardNoLockModule = buildModule( + "veLikeRewardNoLockModule", + (m) => { + const initOwner = m.getParameter("initOwner"); + const { veLike, likecoin } = m.useModule(veLikeModule); + + const veLikeRewardNoLockImpl = m.contract("veLikeRewardNoLock", [], { + id: "veLikeRewardNoLockImpl", + }); + + const initData = m.encodeFunctionCall( + veLikeRewardNoLockImpl, + "initialize", + [initOwner], + ); + + const veLikeRewardNoLockProxy = m.contract("ERC1967Proxy", [ + veLikeRewardNoLockImpl, + initData, + ]); + + const veLikeRewardNoLock = m.contractAt( + "veLikeRewardNoLock", + veLikeRewardNoLockProxy, + ); + + // Configure the reward contract to point at the vault and likecoin token. + m.call(veLikeRewardNoLock, "setVault", [veLike]); + m.call(veLikeRewardNoLock, "setLikecoin", [likecoin]); + + return { + veLikeRewardNoLock, + veLikeRewardNoLockProxy, + veLikeRewardNoLockImpl, + }; + }, +); + +export default veLikeRewardNoLockModule; diff --git a/likecoin3/ignition/modules/veLikeUpgradeV2.ts b/likecoin3/ignition/modules/veLikeUpgradeV2.ts new file mode 100644 index 00000000..7be4323a --- /dev/null +++ b/likecoin3/ignition/modules/veLikeUpgradeV2.ts @@ -0,0 +1,74 @@ +import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; + +/* +# Command to deploy the upgrade +DOTENV_CONFIG_PATH=.env \ + npx hardhat ignition deploy \ + ignition/modules/veLikeUpgradeV2.ts \ + --verify --strategy create2 \ + --parameters ignition/parameters.json \ + --network base + +# Rerun only this for testing +npx hardhat ignition wipe chain-901 \ + veLikeUpgradeV2Module#veLikeV2Impl +npx hardhat ignition wipe chain-901 \ + veLikeUpgradeV2Module#veLikeProxy.upgradeToAndCall +npx hardhat ignition wipe chain-901 \ + veLikeUpgradeV2Module#veLikeRewardV2Impl +npx hardhat ignition wipe chain-901 \ + veLikeUpgradeV2Module#veLikeRewardProxy.upgradeToAndCall +*/ +const veLikeUpgradeV2Module = buildModule("veLikeUpgradeV2Module", (m) => { + const veLikeAddress = m.getParameter("veLikeAddress"); + const veLikeRewardAddress = m.getParameter("veLikeRewardAddress"); + + const veLike = m.contractAt("contracts/veLike.sol:veLike", veLikeAddress); + const veLikeReward = m.contractAt( + "contracts/veLikeReward.sol:veLikeReward", + veLikeRewardAddress, + ); + + // Deploy new veLike implementation and upgrade the proxy. + // Adds: partial withdraw, legacy reward claiming, lock time management. + const veLikeV2Impl = m.contract("contracts/veLikeV2.sol:veLike", [], { + id: "veLikeV2Impl", + }); + m.call(veLike, "upgradeToAndCall", [veLikeV2Impl, "0x"]); + // For testing to force new ABI + const veLikeV2 = m.contractAt( + "contracts/veLikeV2.sol:veLike", + veLikeAddress, + { + id: "veLikeV2", + }, + ); + + // Deploy new veLikeReward implementation and upgrade the proxy. + // Updates withdraw(address) → withdraw(address, uint256) for partial + // withdraw support, matching the IRewardContract interface in veLike V2. + const veLikeRewardV2Impl = m.contract( + "contracts/veLikeRewardV2.sol:veLikeReward", + [], + { + id: "veLikeRewardV2Impl", + }, + ); + m.call(veLikeReward, "upgradeToAndCall", [veLikeRewardV2Impl, "0x"]); + const veLikeRewardV2 = m.contractAt( + "contracts/veLikeRewardV2.sol:veLikeReward", + veLikeRewardAddress, + { + id: "veLikeRewardV2", + }, + ); + + return { + veLike: veLikeV2, + veLikeReward: veLikeRewardV2, + veLikeV2Impl, + veLikeRewardV2Impl, + }; +}); + +export default veLikeUpgradeV2Module; diff --git a/likecoin3/ignition/parameters.json b/likecoin3/ignition/parameters.json index cfd8e189..560c8c56 100644 --- a/likecoin3/ignition/parameters.json +++ b/likecoin3/ignition/parameters.json @@ -16,5 +16,12 @@ }, "veLikeRewardModule": { "initOwner": "0x2dd2253cd5bef4ea6d74efdfad9718a73a7d7ec7" + }, + "veLikeUpgradeV2Module": { + "veLikeAddress": "0xE55C2b91E688BE70e5BbcEdE3792d723b4766e2B", + "veLikeRewardAddress": "0x465629cedF312B77C48602D5AfF1Ecb4FEb1Bf62" + }, + "veLikeRewardNoLockModule": { + "initOwner": "0x2dd2253cd5bef4ea6d74efdfad9718a73a7d7ec7" } } diff --git a/likecoin3/tasks/staking.ts b/likecoin3/tasks/staking.ts index 949dea73..a01d72a5 100644 --- a/likecoin3/tasks/staking.ts +++ b/likecoin3/tasks/staking.ts @@ -128,3 +128,25 @@ task("emitOnlyRewardAdded", "Emit only RewardAdded event for a bookNFT") const tx = await likecollective.emitOnlyRewardAdded(booknft, amount); await tx.wait(); }); + +task( + "claimLegacyReward", + "Claim legacy reward from a rotated-out veLikeReward contract", +) + .addParam("velike", "The address of the veLike contract") + .addParam("legacyreward", "The address of the legacy veLikeReward contract") + .addParam("account", "The account address to claim reward for") + .setAction(async ({ velike, legacyreward, account }, { ethers }) => { + const [operator] = await ethers.getSigners(); + console.log("Operator:", operator.address); + console.log("veLike address:", velike); + console.log("Legacy reward address:", legacyreward); + console.log("Account:", account); + + const veLike = await ethers.getContractAt("veLike", velike); + const veLikeConnected = veLike.connect(operator); + + const tx = await veLikeConnected.claimLegacyReward(legacyreward, account); + const receipt = await tx.wait(); + console.log("Legacy reward claimed. Tx hash:", receipt?.hash); + }); diff --git a/likecoin3/test/factory.ts b/likecoin3/test/factory.ts index 16caee18..e6537ae5 100644 --- a/likecoin3/test/factory.ts +++ b/likecoin3/test/factory.ts @@ -1,5 +1,5 @@ import { viem, ignition } from "hardhat"; -import { encodeAbiParameters, keccak256 } from "viem"; +import { encodeAbiParameters, encodeFunctionData, keccak256 } from "viem"; import { loadFixture } from "@nomicfoundation/hardhat-toolbox-viem/network-helpers"; import LikeProtocolV1Module from "../ignition/modules/LikeProtocolV1"; @@ -7,6 +7,7 @@ import LikeCollectiveModule from "../ignition/modules/LikeCollective"; import LikeStakePositionModule from "../ignition/modules/LikeStakePosition"; import veLikeModule from "../ignition/modules/veLike"; import veLikeRewardModule from "../ignition/modules/veLikeReward"; +import veLikeUpgradeV2Module from "../ignition/modules/veLikeUpgradeV2"; export const ROYALTY_DEFAULT = 500n; @@ -168,8 +169,6 @@ export async function deployVeLike() { export async function deployVeLikeReward() { const { veLike, - veLikeImpl, - veLikeProxy, likecoin, deployer, rick, @@ -178,15 +177,14 @@ export async function deployVeLikeReward() { publicClient, testClient, } = await loadFixture(deployVeLike); - const { veLikeReward, veLikeRewardImpl, veLikeRewardProxy } = - await ignition.deploy(veLikeRewardModule, { - parameters: { - veLikeRewardModule: { - initOwner: deployer.account.address, - }, + const { veLikeReward } = await ignition.deploy(veLikeRewardModule, { + parameters: { + veLikeRewardModule: { + initOwner: deployer.account.address, }, - defaultSender: deployer.account.address, - }); + }, + defaultSender: deployer.account.address, + }); await veLikeReward.write.setVault([veLike.address], { account: deployer.account.address, }); @@ -196,13 +194,18 @@ export async function deployVeLikeReward() { await veLike.write.setRewardContract([veLikeReward.address], { account: deployer.account.address, }); + const v2Deploy = await ignition.deploy(veLikeUpgradeV2Module, { + parameters: { + veLikeUpgradeV2Module: { + veLikeAddress: veLike.address, + veLikeRewardAddress: veLikeReward.address, + }, + }, + defaultSender: deployer.account.address, + }); return { - veLikeReward, - veLikeRewardImpl, - veLikeRewardProxy, - veLike, - veLikeImpl, - veLikeProxy, + veLikeReward: v2Deploy.veLikeReward, + veLike: v2Deploy.veLike, likecoin, deployer, rick, @@ -268,10 +271,7 @@ export async function initialCondition() { const block = await publicClient.getBlock(); const startTime = block.timestamp + 100n; const endTime = startTime + 1000n; - // Assume set manually by admin. - await veLike.write.setLockTime([endTime], { - account: deployer.account.address, - }); + // No lockTime set — users can withdraw anytime (no-lock model). await veLikeReward.write.addReward( [deployer.account.address, 10000n * 10n ** 6n, startTime, endTime], { @@ -309,3 +309,150 @@ export async function initialCondition() { testClient, }; } + +// --- NoLock fixtures (deploy veLikeRewardNoLock instead of veLikeReward) --- + +async function deployVeLikeRewardNoLockContract(ownerAddress: `0x${string}`) { + const impl = await viem.deployContract("veLikeRewardNoLock"); + const initData = encodeFunctionData({ + abi: impl.abi, + functionName: "initialize", + args: [ownerAddress], + }); + const proxy = await viem.deployContract("ERC1967Proxy", [ + impl.address, + initData, + ]); + return await viem.getContractAt("veLikeRewardNoLock", proxy.address); +} + +export async function deployVeLikeRewardNoLock() { + const { + veLike, + likecoin, + deployer, + rick, + kin, + bob, + publicClient, + testClient, + } = await loadFixture(deployVeLikeReward); + const veLikeReward = await deployVeLikeRewardNoLockContract( + deployer.account.address, + ); + await veLikeReward.write.setVault([veLike.address], { + account: deployer.account.address, + }); + await veLikeReward.write.setLikecoin([likecoin.address], { + account: deployer.account.address, + }); + await veLike.write.setRewardContract([veLikeReward.address], { + account: deployer.account.address, + }); + return { + veLikeReward, + veLike, + likecoin, + deployer, + rick, + kin, + bob, + publicClient, + testClient, + }; +} + +export async function initialMintNoLock() { + const { + veLikeReward, + veLike, + likecoin, + deployer, + rick, + kin, + bob, + publicClient, + testClient, + } = await loadFixture(deployVeLikeRewardNoLock); + await likecoin.write.mint([deployer.account.address, 50000n * 10n ** 6n], { + account: deployer.account.address, + }); + await likecoin.write.mint([rick.account.address, 10000n * 10n ** 6n], { + account: deployer.account.address, + }); + await likecoin.write.mint([kin.account.address, 10000n * 10n ** 6n], { + account: deployer.account.address, + }); + await likecoin.write.mint([bob.account.address, 10000n * 10n ** 6n], { + account: deployer.account.address, + }); + return { + veLikeReward, + veLike, + likecoin, + deployer, + rick, + kin, + bob, + publicClient, + testClient, + }; +} + +export async function initialConditionNoLock() { + const { + veLikeReward, + veLike, + likecoin, + deployer, + publicClient, + rick, + kin, + bob, + testClient, + } = await loadFixture(initialMintNoLock); + await likecoin.write.approve([veLikeReward.address, 10000n * 10n ** 6n], { + account: deployer.account.address, + }); + const block = await publicClient.getBlock(); + const startTime = block.timestamp + 100n; + const endTime = startTime + 1000n; + + // Bob deposits before reward period starts. + await likecoin.write.approve([veLike.address, 100n * 10n ** 6n], { + account: bob.account.address, + }); + await veLike.write.deposit([100n * 10n ** 6n, bob.account.address], { + account: bob.account.address, + }); + + // No lockTime set — users can withdraw anytime (no-lock model). + await veLikeReward.write.addReward( + [deployer.account.address, 10000n * 10n ** 6n, startTime, endTime], + { + account: deployer.account.address, + }, + ); + + // Test case assume start of block is the startTime + await testClient.setNextBlockTimestamp({ + timestamp: startTime, + }); + await testClient.mine({ + blocks: 1, + }); + + return { + veLike, + veLikeReward, + likecoin, + deployer, + publicClient, + rick, + kin, + bob, + startTime, + endTime, + testClient, + }; +} diff --git a/likecoin3/test/veLike.ts b/likecoin3/test/veLike.ts index 96d1bfb0..dcd0d3a9 100644 --- a/likecoin3/test/veLike.ts +++ b/likecoin3/test/veLike.ts @@ -4,9 +4,17 @@ import { } from "@nomicfoundation/hardhat-toolbox-viem/network-helpers"; import { expect } from "chai"; import { viem, ignition } from "hardhat"; +import { encodeFunctionData } from "viem"; import "./setup"; -import { deployVeLike, initialMint, initialCondition } from "./factory"; +import { + deployVeLike, + deployVeLikeReward, + initialMint, + initialCondition, + initialMintNoLock, + initialConditionNoLock, +} from "./factory"; describe("veLike ", async function () { describe("as ERC1967 Proxy", async function () { @@ -200,10 +208,171 @@ describe("veLike ", async function () { }); }); + describe("as a no-lock vault", async function () { + it("should have lock time as zero (no lock)", async function () { + const { veLike } = await loadFixture(initialConditionNoLock); + expect(await veLike.read.getLockTime()).to.equal(0n); + }); + + it("should allow withdraw during active reward period", async function () { + const { veLike, veLikeReward, likecoin, rick, testClient, startTime } = + await loadFixture(initialConditionNoLock); + await likecoin.write.approve([veLike.address, 100n * 10n ** 6n], { + account: rick.account.address, + }); + await veLike.write.deposit([100n * 10n ** 6n, rick.account.address], { + account: rick.account.address, + }); + // Withdraw during active period should succeed + await veLike.write.withdraw( + [100n * 10n ** 6n, rick.account.address, rick.account.address], + { + account: rick.account.address, + }, + ); + expect(await veLike.read.balanceOf([rick.account.address])).to.equal(0n); + // Rick gets back 10000 LIKE + any auto-claimed reward from the time between deposit and withdraw + const rickBalance = await likecoin.read.balanceOf([rick.account.address]); + expect(rickBalance >= 10000n * 10n ** 6n).to.be.true; + }); + + it("should support partial withdraw and keep remaining stake", async function () { + const { veLike, veLikeReward, likecoin, rick, testClient, startTime } = + await loadFixture(initialConditionNoLock); + await likecoin.write.approve([veLike.address, 200n * 10n ** 6n], { + account: rick.account.address, + }); + await veLike.write.deposit([200n * 10n ** 6n, rick.account.address], { + account: rick.account.address, + }); + expect(await veLike.read.balanceOf([rick.account.address])).to.equal( + 200n * 10n ** 6n, + ); + + // Partial withdraw: take out 50 LIKE + await veLike.write.withdraw( + [50n * 10n ** 6n, rick.account.address, rick.account.address], + { + account: rick.account.address, + }, + ); + expect(await veLike.read.balanceOf([rick.account.address])).to.equal( + 150n * 10n ** 6n, + ); + // Rick started with 10000, deposited 200, withdrew 50 => 9850 LIKE + any claimed reward + // (reward claimed auto on withdraw is 0 since just deposited same block) + const rickBalance = await likecoin.read.balanceOf([rick.account.address]); + expect(rickBalance >= 9850n * 10n ** 6n).to.be.true; + }); + + it("should correctly track reward after partial withdraw", async function () { + const { + veLike, + veLikeReward, + likecoin, + bob, + rick, + testClient, + startTime, + endTime, + } = await loadFixture(initialConditionNoLock); + // Bob already has 100 LIKE deposited from fixture. + // Advance 500 seconds (half the period). + await testClient.setNextBlockTimestamp({ + timestamp: startTime + 500n, + }); + await testClient.mine({ blocks: 1 }); + + // Bob's pending reward at half-period: ~5000 LIKE (sole staker, 10000 total over 1000s) + const pendingBefore = await veLike.read.getPendingReward([ + bob.account.address, + ]); + expect(pendingBefore).to.equal(5000n * 10n ** 6n); + + // Bob partial withdraws 50 LIKE (keeps 50 staked) at the exact half-point + await testClient.setNextBlockTimestamp({ + timestamp: startTime + 500n + 1n, + }); + await veLike.write.withdraw( + [50n * 10n ** 6n, bob.account.address, bob.account.address], + { + account: bob.account.address, + }, + ); + + // After withdraw, bob should have 50 veLIKE remaining + expect(await veLike.read.balanceOf([bob.account.address])).to.equal( + 50n * 10n ** 6n, + ); + + // Advance to end of period + await testClient.setNextBlockTimestamp({ + timestamp: endTime, + }); + await testClient.mine({ blocks: 1 }); + + // Bob should have earned additional reward on remaining 50 LIKE for the rest of the period + // Bob is still the sole staker, so gets all remaining reward + const pendingAfter = await veLike.read.getPendingReward([ + bob.account.address, + ]); + // Remaining period: endTime - (startTime+501) = 499 seconds + // Reward rate: 10000 LIKE / 1000s = 10 LIKE/s + // Expected: 499 * 10 = 4990 LIKE + expect(pendingAfter).to.equal(4990n * 10n ** 6n); + }); + + it("should be able to withdraw after the reward period ends", async function () { + const { veLike, veLikeReward, likecoin, bob, testClient, endTime } = + await loadFixture(initialConditionNoLock); + await testClient.setNextBlockTimestamp({ + timestamp: endTime + 100n, + }); + // Must mine for follow read command to work. + await testClient.mine({ + blocks: 1, + }); + expect(await likecoin.read.balanceOf([bob.account.address])).to.equal( + 9900n * 10n ** 6n, + ); + expect(await veLike.read.balanceOf([bob.account.address])).to.equal( + 100n * 10n ** 6n, + ); + const pendingReward = await veLike.read.getPendingReward([ + bob.account.address, + ]); + expect(pendingReward).to.equal(10000n * 10n ** 6n); + await veLike.write.claimReward([bob.account.address], { + account: bob.account.address, + }); + expect(await likecoin.read.balanceOf([bob.account.address])).to.equal( + 10000n * 10n ** 6n + 10000n * 10n ** 6n - 100n * 10n ** 6n, + ); + await veLike.write.withdraw( + [100n * 10n ** 6n, bob.account.address, bob.account.address], + { + account: bob.account.address, + }, + ); + expect(await veLike.read.balanceOf([bob.account.address])).to.equal(0n); + // Bob receive all the reward from the reward contract. + expect(await likecoin.read.balanceOf([bob.account.address])).to.equal( + 10000n * 10n ** 6n + 10000n * 10n ** 6n, + ); + }); + }); + describe("as a lockable vault for whole period", async function () { + async function lockedCondition() { + const result = await loadFixture(initialConditionNoLock); + await result.veLike.write.setLockTime([result.endTime], { + account: result.deployer.account.address, + }); + return result; + } + it("should set the lock time same as the condition", async function () { - const { veLike, likecoin, deployer, rick, endTime } = - await loadFixture(initialCondition); + const { veLike, likecoin, rick, endTime } = await lockedCondition(); await likecoin.write.approve([veLike.address, 100n * 10n ** 6n], { account: rick.account.address, }); @@ -214,8 +383,7 @@ describe("veLike ", async function () { }); it("should be revert on withdraw when the condition is active", async function () { - const { veLike, likecoin, deployer, rick } = - await loadFixture(initialCondition); + const { veLike, likecoin, rick } = await lockedCondition(); await likecoin.write.approve([veLike.address, 100n * 10n ** 6n], { account: rick.account.address, }); @@ -233,12 +401,11 @@ describe("veLike ", async function () { }); it("should be able to withdraw when the block timestamp is after the lock time", async function () { - const { veLike, veLikeReward, likecoin, bob, testClient, endTime } = - await loadFixture(initialCondition); + const { veLike, likecoin, bob, testClient, endTime } = + await lockedCondition(); await testClient.setNextBlockTimestamp({ timestamp: endTime + 100n, }); - // Must mine for follow read command to work. await testClient.mine({ blocks: 1, }); @@ -265,7 +432,6 @@ describe("veLike ", async function () { }, ); expect(await veLike.read.balanceOf([bob.account.address])).to.equal(0n); - // Bob receive all the reward from the reward contract. expect(await likecoin.read.balanceOf([bob.account.address])).to.equal( 10000n * 10n ** 6n + 10000n * 10n ** 6n, ); @@ -298,4 +464,1087 @@ describe("veLike ", async function () { ).to.be.rejectedWith("ErrNoRewardToClaim"); }); }); + + describe("legacy reward claims", async function () { + it("should allow owner to set legacy reward contract", async function () { + const { veLike, veLikeReward, deployer } = await loadFixture( + initialConditionNoLock, + ); + // setLegacyRewardContract should succeed for owner + await veLike.write.setLegacyRewardContract([veLikeReward.address, true], { + account: deployer.account.address, + }); + }); + + it("should not allow non-owner to set legacy reward contract", async function () { + const { veLike, veLikeReward, rick } = await loadFixture( + initialConditionNoLock, + ); + await expect( + veLike.write.setLegacyRewardContract([veLikeReward.address, true], { + account: rick.account.address, + }), + ).to.be.rejectedWith("OwnableUnauthorizedAccount"); + }); + + it("should revert claimLegacyReward on non-allowlisted contract", async function () { + const { veLike, veLikeReward, bob } = await loadFixture( + initialConditionNoLock, + ); + await expect( + veLike.write.claimLegacyReward( + [veLikeReward.address, bob.account.address], + { + account: bob.account.address, + }, + ), + ).to.be.rejectedWith("ErrNotLegacyRewardContract"); + }); + + it("should allow user to claim legacy reward from allowlisted contract", async function () { + const { + veLike, + veLikeReward, + likecoin, + deployer, + bob, + testClient, + endTime, + } = await loadFixture(initialConditionNoLock); + + // Advance past the reward period end + await testClient.setNextBlockTimestamp({ timestamp: endTime + 100n }); + await testClient.mine({ blocks: 1 }); + + // Bob has 100 LIKE staked and earned all 10000 LIKE reward + const pendingReward = await veLike.read.getPendingReward([ + bob.account.address, + ]); + expect(pendingReward).to.equal(10000n * 10n ** 6n); + + // Now simulate rotation: allowlist the old reward contract + await veLike.write.setLegacyRewardContract([veLikeReward.address, true], { + account: deployer.account.address, + }); + + // Set reward contract to address(0) to simulate rotation + await veLike.write.setRewardContract( + ["0x0000000000000000000000000000000000000000"], + { account: deployer.account.address }, + ); + + // Bob claims legacy reward + const balanceBefore = await likecoin.read.balanceOf([ + bob.account.address, + ]); + await veLike.write.claimLegacyReward( + [veLikeReward.address, bob.account.address], + { + account: bob.account.address, + }, + ); + const balanceAfter = await likecoin.read.balanceOf([bob.account.address]); + + // Bob should have received the full reward + expect(balanceAfter - balanceBefore).to.equal(10000n * 10n ** 6n); + }); + + it("should revert claimLegacyReward on second call for same user", async function () { + const { + veLike, + veLikeReward, + likecoin, + deployer, + bob, + testClient, + endTime, + } = await loadFixture(initialConditionNoLock); + + // Advance past the reward period end + await testClient.setNextBlockTimestamp({ timestamp: endTime + 100n }); + await testClient.mine({ blocks: 1 }); + + // Allowlist the old reward contract and rotate away from it + await veLike.write.setLegacyRewardContract([veLikeReward.address, true], { + account: deployer.account.address, + }); + await veLike.write.setRewardContract( + ["0x0000000000000000000000000000000000000000"], + { account: deployer.account.address }, + ); + + // First call: Bob claims successfully + const balanceBefore = await likecoin.read.balanceOf([ + bob.account.address, + ]); + await veLike.write.claimLegacyReward( + [veLikeReward.address, bob.account.address], + { account: bob.account.address }, + ); + const balanceAfter = await likecoin.read.balanceOf([bob.account.address]); + expect(balanceAfter - balanceBefore).to.equal(10000n * 10n ** 6n); + + // Second call: Bob has no reward left, should revert + await expect( + veLike.write.claimLegacyReward( + [veLikeReward.address, bob.account.address], + { account: bob.account.address }, + ), + ).to.be.rejectedWith("ErrNoRewardToClaim"); + }); + + it("should revert claimLegacyReward after removing from allowlist", async function () { + const { veLike, veLikeReward, deployer, bob, testClient, endTime } = + await loadFixture(initialConditionNoLock); + + // Advance past the reward period end + await testClient.setNextBlockTimestamp({ timestamp: endTime + 100n }); + await testClient.mine({ blocks: 1 }); + + // Allowlist, then remove + await veLike.write.setLegacyRewardContract([veLikeReward.address, true], { + account: deployer.account.address, + }); + await veLike.write.setLegacyRewardContract( + [veLikeReward.address, false], + { account: deployer.account.address }, + ); + + // Should revert since no longer allowlisted + await expect( + veLike.write.claimLegacyReward( + [veLikeReward.address, bob.account.address], + { + account: bob.account.address, + }, + ), + ).to.be.rejectedWith("ErrNotLegacyRewardContract"); + }); + }); + + describe("legacy reward with lock", async function () { + /** + * Simulates the real production path: + * - Period 1 uses veLikeReward (original) with lock enabled + * - Period 1 ends, lock expires + * - Rotate to veLikeRewardNoLock for period 2 + * - Bob claims legacy reward from the locked veLikeReward + * - Auto-enrollment works for period 2 + */ + it("should allow claiming legacy reward from a locked veLikeReward after rotation", async function () { + const { + veLike, + veLikeReward: reward1, + likecoin, + deployer, + bob, + rick, + publicClient, + testClient, + } = await loadFixture(initialMint); + + // --- Period 1 setup with veLikeReward (original) + lock --- + // Bob deposits 100 LIKE before reward period + await likecoin.write.approve([veLike.address, 100n * 10n ** 6n], { + account: bob.account.address, + }); + await veLike.write.deposit([100n * 10n ** 6n, bob.account.address], { + account: bob.account.address, + }); + + // Fund period 1: 10000 LIKE over 1000 seconds + await likecoin.write.approve([reward1.address, 10000n * 10n ** 6n], { + account: deployer.account.address, + }); + const block1 = await publicClient.getBlock(); + const start1 = block1.timestamp + 100n; + const end1 = start1 + 1000n; + await reward1.write.addReward( + [deployer.account.address, 10000n * 10n ** 6n, start1, end1], + { account: deployer.account.address }, + ); + + // Set lock time to end of period 1 (users can't withdraw until period ends) + await veLike.write.setLockTime([end1], { + account: deployer.account.address, + }); + + // Advance to start of period 1 + await testClient.setNextBlockTimestamp({ timestamp: start1 }); + await testClient.mine({ blocks: 1 }); + + // Verify Bob cannot withdraw during locked period + await expect( + veLike.write.withdraw( + [100n * 10n ** 6n, bob.account.address, bob.account.address], + { account: bob.account.address }, + ), + ).to.be.rejectedWith("ErrWithdrawLocked"); + + // Advance past period 1 (lock also expires since lockTime == end1) + await testClient.setNextBlockTimestamp({ timestamp: end1 + 1n }); + await testClient.mine({ blocks: 1 }); + + // Bob earned all 10000 LIKE from period 1 + const pendingP1 = await veLike.read.getPendingReward([ + bob.account.address, + ]); + expect(pendingP1).to.equal(10000n * 10n ** 6n); + + // --- Rotation: deploy veLikeRewardNoLock, replace reward1 --- + const reward2Impl = await viem.deployContract("veLikeRewardNoLock"); + const reward2InitData = encodeFunctionData({ + abi: reward2Impl.abi, + functionName: "initialize", + args: [deployer.account.address], + }); + const reward2Proxy = await viem.deployContract("ERC1967Proxy", [ + reward2Impl.address, + reward2InitData, + ]); + const reward2 = await viem.getContractAt( + "veLikeRewardNoLock", + reward2Proxy.address, + ); + await reward2.write.setVault([veLike.address], { + account: deployer.account.address, + }); + await reward2.write.setLikecoin([likecoin.address], { + account: deployer.account.address, + }); + + // Remove lock for period 2 (no-lock model going forward) + await veLike.write.setLockTime([0n], { + account: deployer.account.address, + }); + + // Pre-initialize totalStaked on reward2 (captures Bob's 100 LIKE) + await reward2.write.initTotalStaked({ + account: deployer.account.address, + }); + + // Switch active reward to reward2, mark reward1 as legacy + await veLike.write.setRewardContract([reward2.address], { + account: deployer.account.address, + }); + await veLike.write.setLegacyRewardContract([reward1.address, true], { + account: deployer.account.address, + }); + + // --- Period 2 setup: 5000 LIKE over 500 seconds --- + await likecoin.write.approve([reward2.address, 5000n * 10n ** 6n], { + account: deployer.account.address, + }); + const block2 = await publicClient.getBlock(); + const start2 = block2.timestamp + 100n; + const end2 = start2 + 500n; + await reward2.write.addReward( + [deployer.account.address, 5000n * 10n ** 6n, start2, end2], + { account: deployer.account.address }, + ); + + // Approve Rick's deposit before advancing time + await likecoin.write.approve([veLike.address, 100n * 10n ** 6n], { + account: rick.account.address, + }); + + // Advance to period 2 start + await testClient.setNextBlockTimestamp({ timestamp: start2 }); + await testClient.mine({ blocks: 1 }); + + // Rick deposits 100 LIKE at start2 + 1 + await testClient.setNextBlockTimestamp({ timestamp: start2 + 1n }); + await veLike.write.deposit([100n * 10n ** 6n, rick.account.address], { + account: rick.account.address, + }); + + // Advance past period 2 + await testClient.setNextBlockTimestamp({ timestamp: end2 + 1n }); + await testClient.mine({ blocks: 1 }); + + // --- Verify: Bob auto-enrolled in period 2, gets retroactive rewards --- + // Same math as rotation integration test: + // 5000 LIKE over 500s = 10 LIKE/s + // 1s with Bob only (totalStaked=100): Bob gets 10 LIKE + // 499s with Bob+Rick (totalStaked=200): Bob 2495, Rick 2495 + // Bob total: 2505, Rick total: 2495 + const bobP2 = await veLike.read.getPendingReward([bob.account.address]); + const rickP2 = await veLike.read.getPendingReward([rick.account.address]); + expect(bobP2).to.equal(2505n * 10n ** 6n); + expect(rickP2).to.equal(2495n * 10n ** 6n); + + // --- Bob claims legacy reward from period 1 (veLikeReward with lock) --- + const bobBalanceBefore = await likecoin.read.balanceOf([ + bob.account.address, + ]); + await veLike.write.claimLegacyReward( + [reward1.address, bob.account.address], + { account: bob.account.address }, + ); + const bobBalanceAfter = await likecoin.read.balanceOf([ + bob.account.address, + ]); + expect(bobBalanceAfter - bobBalanceBefore).to.equal(10000n * 10n ** 6n); + + // --- Bob claims current reward from period 2 --- + const bobBalanceBefore2 = await likecoin.read.balanceOf([ + bob.account.address, + ]); + await veLike.write.claimReward([bob.account.address], { + account: bob.account.address, + }); + const bobBalanceAfter2 = await likecoin.read.balanceOf([ + bob.account.address, + ]); + expect(bobBalanceAfter2 - bobBalanceBefore2).to.equal(2505n * 10n ** 6n); + + // --- Rick claims current reward from period 2 --- + const rickBalanceBefore = await likecoin.read.balanceOf([ + rick.account.address, + ]); + await veLike.write.claimReward([rick.account.address], { + account: rick.account.address, + }); + const rickBalanceAfter = await likecoin.read.balanceOf([ + rick.account.address, + ]); + expect(rickBalanceAfter - rickBalanceBefore).to.equal(2495n * 10n ** 6n); + + // --- Rick has no legacy reward (wasn't in period 1) --- + await expect( + veLike.write.claimLegacyReward( + [reward1.address, rick.account.address], + { account: rick.account.address }, + ), + ).to.be.rejectedWith("ErrNoRewardToClaim"); + }); + }); + + describe("reward rotation integration", async function () { + async function deployNewVeLikeRewardNoLock(ownerAddress: `0x${string}`) { + const impl = await viem.deployContract("veLikeRewardNoLock"); + const initData = encodeFunctionData({ + abi: impl.abi, + functionName: "initialize", + args: [ownerAddress], + }); + const proxy = await viem.deployContract("ERC1967Proxy", [ + impl.address, + initData, + ]); + return await viem.getContractAt("veLikeRewardNoLock", proxy.address); + } + + /** + * Flow: deploy reward1 → Bob stakes → period 1 ends → + * rotate to reward2 (reward1 becomes legacy) → initTotalStaked → + * Rick stakes → period 2 ends → + * Bob auto-earns period 2 rewards (lazy sync), Rick also earns. + * Bob claims legacy for period 1. + */ + it("should support full rotation with auto-enrollment of existing stakers", async function () { + const { + veLike, + likecoin, + deployer, + bob, + rick, + publicClient, + testClient, + } = await loadFixture(initialMintNoLock); + + // --- Period 1 setup --- + const reward1 = await deployNewVeLikeRewardNoLock( + deployer.account.address, + ); + await reward1.write.setVault([veLike.address], { + account: deployer.account.address, + }); + await reward1.write.setLikecoin([likecoin.address], { + account: deployer.account.address, + }); + await veLike.write.setRewardContract([reward1.address], { + account: deployer.account.address, + }); + + // Bob deposits 100 LIKE into the vault + await likecoin.write.approve([veLike.address, 100n * 10n ** 6n], { + account: bob.account.address, + }); + await veLike.write.deposit([100n * 10n ** 6n, bob.account.address], { + account: bob.account.address, + }); + + // Fund and start period 1: 10000 LIKE over 1000 seconds + await likecoin.write.approve([reward1.address, 10000n * 10n ** 6n], { + account: deployer.account.address, + }); + const block1 = await publicClient.getBlock(); + const start1 = block1.timestamp + 100n; + const end1 = start1 + 1000n; + await reward1.write.addReward( + [deployer.account.address, 10000n * 10n ** 6n, start1, end1], + { account: deployer.account.address }, + ); + + // Advance past period 1 + await testClient.setNextBlockTimestamp({ timestamp: end1 + 1n }); + await testClient.mine({ blocks: 1 }); + + const pendingP1 = await veLike.read.getPendingReward([ + bob.account.address, + ]); + expect(pendingP1).to.equal(10000n * 10n ** 6n); + + // --- Rotate: reward2 replaces reward1, reward1 becomes legacy --- + const reward2 = await deployNewVeLikeRewardNoLock( + deployer.account.address, + ); + await reward2.write.setVault([veLike.address], { + account: deployer.account.address, + }); + await reward2.write.setLikecoin([likecoin.address], { + account: deployer.account.address, + }); + + // Pre-initialize totalStaked on reward2 BEFORE setting as active reward. + // This captures all existing vault holders (Bob's 100 LIKE). + await reward2.write.initTotalStaked({ + account: deployer.account.address, + }); + + await veLike.write.setRewardContract([reward2.address], { + account: deployer.account.address, + }); + await veLike.write.setLegacyRewardContract([reward1.address, true], { + account: deployer.account.address, + }); + + // getPendingReward now queries reward2, which knows nothing about Bob yet + // but _effectiveStakedAmount returns vault balance for un-synced users + expect( + await veLike.read.getPendingReward([bob.account.address]), + ).to.equal(0n); + + // --- Period 2 setup: 5000 LIKE over 500 seconds --- + await likecoin.write.approve([reward2.address, 5000n * 10n ** 6n], { + account: deployer.account.address, + }); + const block2 = await publicClient.getBlock(); + const start2 = block2.timestamp + 100n; + const end2 = start2 + 500n; + await reward2.write.addReward( + [deployer.account.address, 5000n * 10n ** 6n, start2, end2], + { account: deployer.account.address }, + ); + + // Approve Rick's deposit before advancing time + await likecoin.write.approve([veLike.address, 100n * 10n ** 6n], { + account: rick.account.address, + }); + + // Advance to period start + await testClient.setNextBlockTimestamp({ timestamp: start2 }); + await testClient.mine({ blocks: 1 }); + + // Rick deposits 100 LIKE (same amount as Bob) at start2 + 1 + await testClient.setNextBlockTimestamp({ timestamp: start2 + 1n }); + await veLike.write.deposit([100n * 10n ** 6n, rick.account.address], { + account: rick.account.address, + }); + + // Advance past period 2 + await testClient.setNextBlockTimestamp({ timestamp: end2 + 1n }); + await testClient.mine({ blocks: 1 }); + + // --- Verify auto-enrollment: Bob earns period 2 rewards without re-depositing --- + // Bob (100 LIKE) and Rick (100 LIKE) share the 5000 LIKE reward. + // Bob's share is retroactive from period start (rewardIndex stays 0). + // + // Total reward = 5000 LIKE over 500 seconds = 10 LIKE/s + // For 1 second before Rick deposits: totalStaked = 100 (Bob only) + // Bob gets 100% of 10 LIKE/s = 10 LIKE + // + // For remaining 499 seconds: totalStaked = 200 (Bob + Rick) + // Bob gets 50% of 10 LIKE/s * 499 = 2495 LIKE + // Rick gets 50% of 10 LIKE/s * 499 = 2495 LIKE + // + // Bob total: 10 + 2495 = 2505 LIKE + // Rick total: 2495 LIKE + // Grand total: 2505 + 2495 = 5000 LIKE ✓ + const bobP2 = await veLike.read.getPendingReward([bob.account.address]); + const rickP2 = await veLike.read.getPendingReward([rick.account.address]); + expect(bobP2).to.equal(2505n * 10n ** 6n); + expect(rickP2).to.equal(2495n * 10n ** 6n); + + // --- Bob claims legacy reward from period 1 --- + const bobBalanceBefore = await likecoin.read.balanceOf([ + bob.account.address, + ]); + await veLike.write.claimLegacyReward( + [reward1.address, bob.account.address], + { + account: bob.account.address, + }, + ); + const bobBalanceAfter = await likecoin.read.balanceOf([ + bob.account.address, + ]); + expect(bobBalanceAfter - bobBalanceBefore).to.equal(10000n * 10n ** 6n); + + // --- Rick claims current reward from period 2 --- + const rickBalanceBefore = await likecoin.read.balanceOf([ + rick.account.address, + ]); + await veLike.write.claimReward([rick.account.address], { + account: rick.account.address, + }); + const rickBalanceAfter = await likecoin.read.balanceOf([ + rick.account.address, + ]); + expect(rickBalanceAfter - rickBalanceBefore).to.equal(2495n * 10n ** 6n); + + // --- Bob claims current reward from period 2 (auto-enrolled) --- + const bobBalanceBefore2 = await likecoin.read.balanceOf([ + bob.account.address, + ]); + await veLike.write.claimReward([bob.account.address], { + account: bob.account.address, + }); + const bobBalanceAfter2 = await likecoin.read.balanceOf([ + bob.account.address, + ]); + expect(bobBalanceAfter2 - bobBalanceBefore2).to.equal(2505n * 10n ** 6n); + + // Rick has no legacy reward (wasn't in period 1) + await expect( + veLike.write.claimLegacyReward( + [reward1.address, rick.account.address], + { + account: rick.account.address, + }, + ), + ).to.be.rejectedWith("ErrNoRewardToClaim"); + }); + }); + + describe("syncStakers and lazy sync", async function () { + async function deployNewVeLikeRewardNoLock(ownerAddress: `0x${string}`) { + const impl = await viem.deployContract("veLikeRewardNoLock"); + const initData = encodeFunctionData({ + abi: impl.abi, + functionName: "initialize", + args: [ownerAddress], + }); + const proxy = await viem.deployContract("ERC1967Proxy", [ + impl.address, + initData, + ]); + return await viem.getContractAt("veLikeRewardNoLock", proxy.address); + } + + /** + * Shared fixture for syncStakers tests: + * - Bob deposits 100 LIKE via bootstrap reward contract + * - reward1 deployed with initTotalStaked (captures Bob's 100) + * - reward1 set as active, period 1 funded (10000 LIKE / 1000s) + * - Bob does NO operation during period 1 (never synced into reward1) + */ + async function syncStakersFixture() { + const { + veLike, + likecoin, + deployer, + bob, + rick, + publicClient, + testClient, + } = await loadFixture(initialMintNoLock); + + // Bob deposits 100 LIKE via bootstrap reward contract + await likecoin.write.approve([veLike.address, 100n * 10n ** 6n], { + account: bob.account.address, + }); + await veLike.write.deposit([100n * 10n ** 6n, bob.account.address], { + account: bob.account.address, + }); + + // Deploy reward1 with initTotalStaked + const reward1 = await deployNewVeLikeRewardNoLock( + deployer.account.address, + ); + await reward1.write.setVault([veLike.address], { + account: deployer.account.address, + }); + await reward1.write.setLikecoin([likecoin.address], { + account: deployer.account.address, + }); + await reward1.write.initTotalStaked({ + account: deployer.account.address, + }); + await veLike.write.setRewardContract([reward1.address], { + account: deployer.account.address, + }); + + // Fund period 1: 10000 LIKE over 1000 seconds + await likecoin.write.approve([reward1.address, 10000n * 10n ** 6n], { + account: deployer.account.address, + }); + const block1 = await publicClient.getBlock(); + const start1 = block1.timestamp + 100n; + const end1 = start1 + 1000n; + await reward1.write.addReward( + [deployer.account.address, 10000n * 10n ** 6n, start1, end1], + { account: deployer.account.address }, + ); + + return { + veLike, + likecoin, + deployer, + bob, + rick, + publicClient, + testClient, + reward1, + start1, + end1, + }; + } + + it("should revert with ErrNotActive before period starts", async function () { + const { reward1, deployer, bob } = await loadFixture(syncStakersFixture); + + // Period hasn't started yet — block.timestamp < start1 + await expect( + reward1.write.syncStakers([[bob.account.address]], { + account: deployer.account.address, + }), + ).to.be.rejectedWith("ErrNotActive"); + }); + + it("should revert with ErrNotActive after period ends", async function () { + const { reward1, deployer, bob, testClient, end1 } = + await loadFixture(syncStakersFixture); + + // Advance past end of period + await testClient.setNextBlockTimestamp({ timestamp: end1 + 1n }); + await testClient.mine({ blocks: 1 }); + + await expect( + reward1.write.syncStakers([[bob.account.address]], { + account: deployer.account.address, + }), + ).to.be.rejectedWith("ErrNotActive"); + }); + + it("should sync un-synced account during active period", async function () { + const { reward1, deployer, bob, testClient, start1 } = + await loadFixture(syncStakersFixture); + + // Advance to active period + await testClient.setNextBlockTimestamp({ timestamp: start1 + 1n }); + await testClient.mine({ blocks: 1 }); + + // Bob is un-synced (stakedAmount == 0 in reward1) + await reward1.write.syncStakers([[bob.account.address]], { + account: deployer.account.address, + }); + + // Verify Bob's pending reward is non-zero (he is now synced and earning) + const pending = await reward1.read.getPendingReward([ + bob.account.address, + ]); + expect(pending > 0n).to.be.true; + }); + + it("should revert with ErrAlreadySynced when account is synced with matching balance", async function () { + const { reward1, deployer, bob, testClient, start1 } = + await loadFixture(syncStakersFixture); + + // Advance to active period and sync Bob + await testClient.setNextBlockTimestamp({ timestamp: start1 + 1n }); + await testClient.mine({ blocks: 1 }); + + await reward1.write.syncStakers([[bob.account.address]], { + account: deployer.account.address, + }); + + // Try to sync again — vault balance still matches stakedAmount + await expect( + reward1.write.syncStakers([[bob.account.address]], { + account: deployer.account.address, + }), + ).to.be.rejectedWith("ErrAlreadySynced"); + }); + + it("should revert with ErrMismatchSync when account is synced but balance changed", async function () { + const { veLike, reward1, likecoin, deployer, bob, testClient, start1 } = + await loadFixture(syncStakersFixture); + + // Advance to active period and sync Bob (stakedAmount = 100) + await testClient.setNextBlockTimestamp({ timestamp: start1 + 1n }); + await testClient.mine({ blocks: 1 }); + + await reward1.write.syncStakers([[bob.account.address]], { + account: deployer.account.address, + }); + + // Switch active reward contract away from reward1 so that + // veLike.deposit goes to a different contract, leaving reward1's + // stakedAmount unchanged while vault balance grows. + const reward2 = await deployNewVeLikeRewardNoLock( + deployer.account.address, + ); + await reward2.write.setVault([veLike.address], { + account: deployer.account.address, + }); + await reward2.write.setLikecoin([likecoin.address], { + account: deployer.account.address, + }); + await reward2.write.initTotalStaked({ + account: deployer.account.address, + }); + await veLike.write.setRewardContract([reward2.address], { + account: deployer.account.address, + }); + + // Bob deposits 50 more LIKE → vault balance = 150 + // This goes through reward2.deposit(), so reward1's stakedAmount stays 100 + await likecoin.write.approve([veLike.address, 50n * 10n ** 6n], { + account: bob.account.address, + }); + await veLike.write.deposit([50n * 10n ** 6n, bob.account.address], { + account: bob.account.address, + }); + + // Try to sync on reward1 — stakedAmount (100) != vaultBalance (150) + await expect( + reward1.write.syncStakers([[bob.account.address]], { + account: deployer.account.address, + }), + ).to.be.rejectedWith("ErrMismatchSync"); + }); + + it("should revert whole tx on first bad account in batch", async function () { + const { reward1, deployer, bob, rick, testClient, start1 } = + await loadFixture(syncStakersFixture); + + // Advance to active period and sync Bob first + await testClient.setNextBlockTimestamp({ timestamp: start1 + 1n }); + await testClient.mine({ blocks: 1 }); + + await reward1.write.syncStakers([[bob.account.address]], { + account: deployer.account.address, + }); + + // Batch with [bob (already synced), rick (un-synced)] — reverts on bob + await expect( + reward1.write.syncStakers( + [[bob.account.address, rick.account.address]], + { account: deployer.account.address }, + ), + ).to.be.rejectedWith("ErrAlreadySynced"); + }); + + it("should revert when called by non-owner", async function () { + const { reward1, bob, testClient, start1 } = + await loadFixture(syncStakersFixture); + + // Advance to active period + await testClient.setNextBlockTimestamp({ timestamp: start1 + 1n }); + await testClient.mine({ blocks: 1 }); + + await expect( + reward1.write.syncStakers([[bob.account.address]], { + account: bob.account.address, + }), + ).to.be.rejectedWith("OwnableUnauthorizedAccount"); + }); + + /** + * Full rotation flow with syncStakers fixing the stale-balance problem: + * + * Period 0 (bootstrap): Bob deposits 100 LIKE via initial reward contract. + * + * Period 1 (veLikeRewardNoLock #1): + * - Deployed with initTotalStaked() → totalStaked = 100 + * - Bob does NO operation on veLike during period 1 + * - Owner calls syncStakers([bob]) before rotation → freezes Bob at 100 + * - 10000 LIKE reward accrues over 1000 seconds + * + * Rotation to period 2 (veLikeRewardNoLock #2): + * - Bob deposits 50 more LIKE → vault balance goes from 100 → 150 + * - 5000 LIKE reward accrues over 500 seconds + * + * Bob claims: + * (a) legacy reward from reward1 — based on frozen 100 LIKE → 10000 LIKE + * (b) current reward from reward2 — based on 150 LIKE → 5000 LIKE + */ + it("should claim legacy and current rewards based on respective period balances", async function () { + const { + veLike, + likecoin, + deployer, + bob, + publicClient, + testClient, + reward1, + start1, + end1, + } = await loadFixture(syncStakersFixture); + + // Advance to active period, then sync Bob's stake into reward1 + await testClient.setNextBlockTimestamp({ timestamp: start1 + 1n }); + await testClient.mine({ blocks: 1 }); + + // Owner syncs Bob before rotation — freezes stakedAmount at 100 + await reward1.write.syncStakers([[bob.account.address]], { + account: deployer.account.address, + }); + + // Advance past period 1 + await testClient.setNextBlockTimestamp({ timestamp: end1 + 1n }); + await testClient.mine({ blocks: 1 }); + + // --- Rotate: reward2 replaces reward1, reward1 becomes legacy --- + const reward2 = await deployNewVeLikeRewardNoLock( + deployer.account.address, + ); + await reward2.write.setVault([veLike.address], { + account: deployer.account.address, + }); + await reward2.write.setLikecoin([likecoin.address], { + account: deployer.account.address, + }); + await reward2.write.initTotalStaked({ + account: deployer.account.address, + }); + await veLike.write.setRewardContract([reward2.address], { + account: deployer.account.address, + }); + await veLike.write.setLegacyRewardContract([reward1.address, true], { + account: deployer.account.address, + }); + + // --- Bob deposits 50 more LIKE → vault balance: 100 → 150 --- + // reward1 has stakedAmount = 100 (frozen by syncStakers), unaffected. + await likecoin.write.approve([veLike.address, 50n * 10n ** 6n], { + account: bob.account.address, + }); + await veLike.write.deposit([50n * 10n ** 6n, bob.account.address], { + account: bob.account.address, + }); + + // Fund period 2: 5000 LIKE over 500 seconds + await likecoin.write.approve([reward2.address, 5000n * 10n ** 6n], { + account: deployer.account.address, + }); + const block2 = await publicClient.getBlock(); + const start2 = block2.timestamp + 100n; + const end2 = start2 + 500n; + await reward2.write.addReward( + [deployer.account.address, 5000n * 10n ** 6n, start2, end2], + { account: deployer.account.address }, + ); + + // Advance past period 2 + await testClient.setNextBlockTimestamp({ timestamp: end2 + 1n }); + await testClient.mine({ blocks: 1 }); + + // --- Claim legacy reward from period 1 --- + // Bob's frozen stakedAmount = 100, totalStaked = 100 → reward = 10000 LIKE + const bobBefore = await likecoin.read.balanceOf([bob.account.address]); + await veLike.write.claimLegacyReward( + [reward1.address, bob.account.address], + { account: bob.account.address }, + ); + const bobAfter = await likecoin.read.balanceOf([bob.account.address]); + const legacyReward = bobAfter - bobBefore; + expect(legacyReward).to.equal(10000n * 10n ** 6n); + + // --- Claim current reward from period 2 --- + // Bob has 150 LIKE staked (sole staker) → gets all 5000 LIKE + const bobBefore2 = await likecoin.read.balanceOf([bob.account.address]); + await veLike.write.claimReward([bob.account.address], { + account: bob.account.address, + }); + const bobAfter2 = await likecoin.read.balanceOf([bob.account.address]); + const currentReward = bobAfter2 - bobBefore2; + // Allow 1-unit rounding tolerance from accumulator integer division + const expected2 = 5000n * 10n ** 6n; + expect(currentReward >= expected2 - 1n && currentReward <= expected2).to + .be.true; + }); + + it("should correctly sync, update vault and claim reward when deposit is the first interaction after auto-enrollment with a new depositor", async function () { + const { + veLike, + likecoin, + deployer, + bob, + rick, + publicClient, + testClient, + reward1, + end1, + } = await loadFixture(syncStakersFixture); + + // Advance past period 1 (Bob never interacted with reward1) + await testClient.setNextBlockTimestamp({ timestamp: end1 + 1n }); + await testClient.mine({ blocks: 1 }); + + // --- Rotate: reward2 replaces reward1, reward1 becomes legacy --- + const reward2 = await deployNewVeLikeRewardNoLock( + deployer.account.address, + ); + await reward2.write.setVault([veLike.address], { + account: deployer.account.address, + }); + await reward2.write.setLikecoin([likecoin.address], { + account: deployer.account.address, + }); + // Auto-enroll: captures Bob's 100 LIKE vault balance into reward2.totalStaked. + // Rick has no vault balance yet, so his stakedAmount and rewardIndex start at 0. + await reward2.write.initTotalStaked({ + account: deployer.account.address, + }); + await veLike.write.setRewardContract([reward2.address], { + account: deployer.account.address, + }); + await veLike.write.setLegacyRewardContract([reward1.address, true], { + account: deployer.account.address, + }); + + // --- Fund and start period 2: 5000 LIKE over 500 seconds --- + // Bob is the sole auto-enrolled staker (100 LIKE), rate = 10 LIKE/s + await likecoin.write.approve([reward2.address, 5000n * 10n ** 6n], { + account: deployer.account.address, + }); + const block2 = await publicClient.getBlock(); + const start2 = block2.timestamp + 100n; + const end2 = start2 + 500n; + await reward2.write.addReward( + [deployer.account.address, 5000n * 10n ** 6n, start2, end2], + { account: deployer.account.address }, + ); + + // Approve both deposits before fixing timestamps so the approve txs do + // not consume the setNextBlockTimestamp slots. + await likecoin.write.approve([veLike.address, 50n * 10n ** 6n], { + account: bob.account.address, + }); + await likecoin.write.approve([veLike.address, 100n * 10n ** 6n], { + account: rick.account.address, + }); + + // --- Bob's FIRST interaction with reward2 is a deposit at exactly start2+250 --- + // At this point Bob (sole staker, 100 LIKE) has accrued exactly 250 seconds = 2500 LIKE. + // Inside reward2.deposit() this triggers in sequence: + // 1. _syncStaker(bob) → sets stakerInfo.stakedAmount = vaultBalance = 100 LIKE + // 2. _updateVault() → accumulates rewardIndex for exactly 250 seconds elapsed + // 3. _claimReward(bob) → pays out 2500 LIKE accrued for the first 250 seconds + // 4. stakedAmount += 50 LIKE (new deposit) + // After: Bob stakedAmount = 150, totalStaked = 150 + await testClient.setNextBlockTimestamp({ timestamp: start2 + 250n }); + const bobBalanceBefore = await likecoin.read.balanceOf([ + bob.account.address, + ]); + await veLike.write.deposit([50n * 10n ** 6n, bob.account.address], { + account: bob.account.address, + }); + const bobBalanceAfter = await likecoin.read.balanceOf([ + bob.account.address, + ]); + + // _claimReward within deposit should have paid exactly 2500 LIKE. + // Bob sent 50 LIKE to vault and received 2500 LIKE reward in the same tx. + // Net balance change = reward - deposit = 2500 - 50 LIKE + const expected2500 = 2500n * 10n ** 6n; + const rewardWithinDeposit = + bobBalanceAfter - bobBalanceBefore + 50n * 10n ** 6n; + expect( + rewardWithinDeposit >= expected2500 - 1n && + rewardWithinDeposit <= expected2500, + ).to.be.true; + + // --- Rick's FIRST interaction with reward2 is a deposit at start2+251 --- + // Rick was NOT auto-enrolled: his stakedAmount = 0 and rewardIndex = 0 in reward2. + // Inside reward2.deposit() this triggers in sequence: + // 1. _syncStaker(rick) → vault balance = 0 (no prior vault shares), nothing changes + // 2. _updateVault() → accumulates 1 second of rewardIndex (totalStaked=150) + // 3. _claimReward(rick) → stakedAmount = 0, pending = 0, rewardIndex updated to current + // 4. stakedAmount += 100 LIKE (Rick's first deposit) + // After: Rick stakedAmount = 100, totalStaked = 250 + await testClient.setNextBlockTimestamp({ timestamp: start2 + 251n }); + const rickBalanceBefore = await likecoin.read.balanceOf([ + rick.account.address, + ]); + await veLike.write.deposit([100n * 10n ** 6n, rick.account.address], { + account: rick.account.address, + }); + const rickBalanceAfter = await likecoin.read.balanceOf([ + rick.account.address, + ]); + + // Rick had stakedAmount = 0 so no reward was claimed within his deposit tx. + // His balance decreased by exactly 100 LIKE (only the deposit, no reward received). + expect(rickBalanceBefore - rickBalanceAfter).to.equal(100n * 10n ** 6n); + + // Advance to end of period 2 + await testClient.setNextBlockTimestamp({ timestamp: end2 + 1n }); + await testClient.mine({ blocks: 1 }); + + // Remaining rewards after both deposits: + // + // start2+250 → start2+251 (1s): totalStaked=150 (Bob only) + // Bob earns: 1 × 10 LIKE/s = 10 LIKE + // + // start2+251 → end2 (249s): totalStaked=250 (Bob 150 + Rick 100) + // Bob earns: 249 × 10 × 150/250 = 1494 LIKE + // Rick earns: 249 × 10 × 100/250 = 996 LIKE + // + // Bob total remaining: 10 + 1494 = 1504 LIKE + // Rick total remaining: 996 LIKE + // Sum: 2500 LIKE ✓ + const expectedBobRemaining = 1504n * 10n ** 6n; + const expectedRickRemaining = 996n * 10n ** 6n; + + const bobPending = await veLike.read.getPendingReward([ + bob.account.address, + ]); + const rickPending = await veLike.read.getPendingReward([ + rick.account.address, + ]); + expect( + bobPending >= expectedBobRemaining - 2n && + bobPending <= expectedBobRemaining, + ).to.be.true; + expect( + rickPending >= expectedRickRemaining - 1n && + rickPending <= expectedRickRemaining, + ).to.be.true; + + // Claim and verify final balances for both + const bobBefore2 = await likecoin.read.balanceOf([bob.account.address]); + await veLike.write.claimReward([bob.account.address], { + account: bob.account.address, + }); + const bobAfter2 = await likecoin.read.balanceOf([bob.account.address]); + expect( + bobAfter2 - bobBefore2 >= expectedBobRemaining - 2n && + bobAfter2 - bobBefore2 <= expectedBobRemaining, + ).to.be.true; + + const rickBefore2 = await likecoin.read.balanceOf([rick.account.address]); + await veLike.write.claimReward([rick.account.address], { + account: rick.account.address, + }); + const rickAfter2 = await likecoin.read.balanceOf([rick.account.address]); + expect( + rickAfter2 - rickBefore2 >= expectedRickRemaining - 1n && + rickAfter2 - rickBefore2 <= expectedRickRemaining, + ).to.be.true; + }); + }); }); diff --git a/likecoin3/velike.md b/likecoin3/velike.md new file mode 100644 index 00000000..31063c07 --- /dev/null +++ b/likecoin3/velike.md @@ -0,0 +1,288 @@ +# veLike deployment notes + +Since the deployment of veLike is a bit complicated. + +## Addresses (Base Mainnet) + +| Contract | Address | +| ---------------------------------------------------- | -------------------------------------------- | +| LIKE token | `0x1EE5DD1794C28F559f94d2cc642BaE62dC3be5cf` | +| veLike vault (proxy) | `0xE55C2b91E688BE70e5BbcEdE3792d723b4766e2B` | +| veLikeReward (1st period, **legacy** after rotation) | `0x465629cedF312B77C48602D5AfF1Ecb4FEb1Bf62` | + +Set env vars before running cast commands: + +```bash +export RPC=https://mainnet.base.org # or your Alchemy/Infura URL +export VELIKE=0xE55C2b91E688BE70e5BbcEdE3792d723b4766e2B +export LIKE=0x1EE5DD1794C28F559f94d2cc642BaE62dC3be5cf +``` + +--- + +## Initial Deployment Reference (commit 80a60ec) + +Period 1: **Nov 3 2025 → Feb 1 2026** (timestamps `1762164000` → `1769940000`) + +``` +DOTENV_CONFIG_PATH=.env \ + npx hardhat ignition deploy \ + ignition/modules/veLike.ts \ + --verify --strategy create2 \ + --parameters ignition/parameters.json --network baseSepolia +DOTENV_CONFIG_PATH=.env \ + npx hardhat ignition deploy \ + ignition/modules/veLikeReward.ts \ + --verify --strategy create2 \ + --parameters ignition/parameters.json --network baseSepolia +``` + +Post-deploy setup calls that were made (for reference): + +```bash +# Wire reward to vault and LIKE token +cast send 0x465629cedF312B77C48602D5AfF1Ecb4FEb1Bf62 \ + "setVault(address)" 0xE55C2b91E688BE70e5BbcEdE3792d723b4766e2B \ + --account likecoin-deployer.eth --rpc-url $RPC + +cast send 0x465629cedF312B77C48602D5AfF1Ecb4FEb1Bf62 \ + "setLikecoin(address)" 0x1EE5DD1794C28F559f94d2cc642BaE62dC3be5cf \ + --account likecoin-deployer.eth --rpc-url $RPC + +# Fund the reward period (drawer: 0x1f135ca20cE4d5Abb53dB27c7F981b43a5734419) +cast send 0x465629cedF312B77C48602D5AfF1Ecb4FEb1Bf62 \ + "addReward(address,uint256,uint256,uint256)" \ + 0x1f135ca20cE4d5Abb53dB27c7F981b43a5734419 \ + 10000000000000 1762164000 1769940000 \ + --account likecoin-deployer.eth --rpc-url $RPC + +# Set vault's active reward and lock (users can't withdraw before lock time) +cast send $VELIKE "setRewardContract(address)" 0x465629cedF312B77C48602D5AfF1Ecb4FEb1Bf62 \ + --account likecoin-deployer.eth --rpc-url $RPC + +cast send $VELIKE "setLockTime(uint256)" 1769940000 \ + --account likecoin-deployer.eth --rpc-url $RPC +``` + +--- + +### Prerequisites + +- The current reward period has ended (or will end before any user withdraws after the + upgrade). +- You have the deployer account that owns both proxies. + +## Step 1: Upgrading veLike & veLikeReward (→ V2) + +The initial deployment (commit `80a60ec`) used `veLikeV0` and the original `veLikeReward`. +Before rotating to `veLikeRewardNoLock`, both contracts must be upgraded in-place: + +| Contract | Change | +| ---------------- | -------------------------------------------------------------------------------------------------------------------- | +| **veLike** | Adds partial withdraw support, `setLockTime`, `setLegacyRewardContract`, `claimLegacyReward`, non-transferable token | +| **veLikeReward** | Updates `withdraw(address)` → `withdraw(address, uint256)` to match the `IRewardContract` interface in veLike V1 | + +Both upgrades are handled by a single ignition module so the proxy implementations stay +in sync. + +```bash +DOTENV_CONFIG_PATH=.env \ + npx hardhat ignition deploy \ + ignition/modules/veLikeUpgradeV2.ts \ + --verify --strategy create2 \ + --parameters ignition/parameters.json --network baseSepolia +``` + +### Step 2: Verify + +Check the new implementation addresses in +`ignition/deployments/chain-8453/deployed_addresses.json`: + +- `"veLikeUpgradeV2Module#veLikeV2Impl"` — new veLike implementation +- `"veLikeUpgradeV2Module#veLikeRewardV2Impl"` — new veLikeReward implementation + +Confirm on-chain: + +```bash +# veLike proxy should point to the new impl +cast storage $VELIKE 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --rpc-url $RPC + +# veLikeReward proxy should point to the new impl +export REWARD=0x465629cedF312B77C48602D5AfF1Ecb4FEb1Bf62 +cast storage $REWARD 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --rpc-url $RPC +``` + +### Next steps + +After the upgrade, proceed to **"Rotating to a New veLikeRewardNoLock"** below to deploy +the no-lock reward contract for the next period. + +--- + +## Rotating to a New veLikeRewardNoLock (2nd period onwards) + +Use this when the current reward period has ended and you want to start a new one with a +fresh contract. The `veLikeRewardNoLock` contract supports auto-enrollment of existing +stakers without requiring them to re-deposit. + +### Before you start + +- Confirm the current period has ended (`endTime` has passed). +- Decide the new period parameters: `startTime`, `endTime`, `rewardAmount`, `drawer`. +- The `drawer` account must hold enough LIKE and pre-approve the new reward contract. + +### Step 1: (Optional but recommended) Sync stakers on the old contract + +Call `syncStakers` on the **current** (old) reward contract to freeze staker balances before +anyone withdraws between periods. Must be called while the old period is still active. + +```bash +cast send $OLD_REWARD \ + "syncStakers(address[])" \ + "[0xAddr1,0xAddr2,...]" \ + --account likecoin-deployer.eth --rpc-url $RPC +``` + +Skip this if all stakers have already interacted with the old contract (they are already +synced) or if the period has already ended. + +### Step 2: Add parameters for the new ignition module + +Add to `ignition/parameters.json`: + +```json +"veLikeRewardNoLockModule": { + "initOwner": "0x2dd2253cd5bef4ea6d74efdfad9718a73a7d7ec7" +} +``` + +For a 3rd/4th reward, duplicate `ignition/modules/veLikeRewardNoLock.ts` as +`veLikeRewardNoLockV2.ts` (change the module ID string to `"veLikeRewardNoLockV2Module"`) +and add a matching `"veLikeRewardNoLockV2Module"` entry in `parameters.json`. + +### Step 3: Deploy the new reward contract + +The ignition module calls `setVault` and `setLikecoin` during deployment. + +```bash +DOTENV_CONFIG_PATH=.env \ + npx hardhat ignition deploy \ + ignition/modules/veLikeRewardNoLock.ts \ + --verify --strategy create2 \ + --parameters ignition/parameters.json \ + --network baseSepolia +``` + +Note the new contract address from +`ignition/deployments/chain-8453/deployed_addresses.json` — it will appear as +`"veLikeRewardNoLockModule#veLikeRewardNoLock"`. + +### Step 4: Fund and start the new reward period + +The drawer must first approve the reward contract, then the owner calls `addReward`. + +`startTime` must be strictly greater than the old contract's `lastRewardTime` (which equals +the old period's `startTime`). Use `date -d "..." +%s` to convert a human date to Unix +timestamp. + +```bash +export NEW_REWARD=
+ +# Drawer approves (run as the drawer account, not deployer) +# it should be on a multisig account. +cast send $LIKE \ + "approve(address,uint256)" $NEW_REWARD $REWARD_AMOUNT \ + --account