diff --git a/contracts/interfaces/IAddressHolder.sol b/contracts/interfaces/IAddressHolder.sol index 5b08cabc..153fef78 100644 --- a/contracts/interfaces/IAddressHolder.sol +++ b/contracts/interfaces/IAddressHolder.sol @@ -38,5 +38,7 @@ interface IAddressHolder { function getUnstakingAddresses() external view returns (address, address); + function getStFlip() external view returns (address); + function getGovernor() external view returns (address); } diff --git a/contracts/interfaces/ITokenVestingStaking.sol b/contracts/interfaces/ITokenVestingStaking.sol index b6036f17..0dbb3432 100644 --- a/contracts/interfaces/ITokenVestingStaking.sol +++ b/contracts/interfaces/ITokenVestingStaking.sol @@ -23,6 +23,8 @@ interface ITokenVestingStaking { function unstakeFromStProvider(uint256 amount) external returns (uint256); + function claimStProviderRewards(uint256 amount_) external; + function release(IERC20 token) external; function revoke(IERC20 token) external; diff --git a/contracts/mocks/MockStProvider.sol b/contracts/mocks/MockStProvider.sol index 45ebe7a2..7303108b 100644 --- a/contracts/mocks/MockStProvider.sol +++ b/contracts/mocks/MockStProvider.sol @@ -96,4 +96,8 @@ contract stFLIP is ERC20 { emit Burn(msg.sender, value, refundee); _burn(msg.sender, value); } + + function mockSlash(address account, uint256 amount) public { + _burn(account, amount); + } } diff --git a/contracts/utils/AddressHolder.sol b/contracts/utils/AddressHolder.sol index d80fe866..5dd4cbce 100644 --- a/contracts/utils/AddressHolder.sol +++ b/contracts/utils/AddressHolder.sol @@ -88,6 +88,10 @@ contract AddressHolder is IAddressHolder, Shared { return (stBurner, stFLIP); } + function getStFlip() external view override returns (address) { + return stFLIP; + } + /// @dev Getter function for the governor address function getGovernor() external view override returns (address) { return governor; diff --git a/contracts/utils/TokenVestingStaking.sol b/contracts/utils/TokenVestingStaking.sol index 75fdd125..01867d36 100644 --- a/contracts/utils/TokenVestingStaking.sol +++ b/contracts/utils/TokenVestingStaking.sol @@ -46,6 +46,12 @@ contract TokenVestingStaking is ITokenVestingStaking, Shared { bool public revoked; + // Cumulative counter for amount staked to the st provider + uint256 public stTokenStaked; + + // Cumulative counter for amount unstaked from the st provider + uint256 public stTokenUnstaked; + /** * @param beneficiary_ address of the beneficiary to whom vested tokens are transferred * @param revoker_ the person with the power to revoke the vesting. Address(0) means it is not revocable. @@ -100,6 +106,9 @@ contract TokenVestingStaking is ITokenVestingStaking, Shared { address stMinter = addressHolder.getStakingAddress(); FLIP.approve(stMinter, amount); + + stTokenStaked += amount; + require(IMinter(stMinter).mint(address(this), amount)); } @@ -111,9 +120,27 @@ contract TokenVestingStaking is ITokenVestingStaking, Shared { (address stBurner, address stFlip) = addressHolder.getUnstakingAddresses(); IERC20(stFlip).approve(stBurner, amount); + + stTokenUnstaked += amount; + return IBurner(stBurner).burn(address(this), amount); } + /** + * @notice Claims the liquid staking provider rewards. + * @param amount_ the amount of rewards to claim. If greater than `totalRewards`, then all rewards are claimed. + * @dev `stTokenCounter` updates after staking/unstaking operation to keep track of the st token principle. Any + * amount above the principle is considered rewards and thus can be claimed by the beneficiary. + */ + function claimStProviderRewards(uint256 amount_) external override onlyBeneficiary notRevoked { + address stFlip = addressHolder.getStFlip(); + uint256 totalRewards = stFLIP(stFlip).balanceOf(address(this)) + stTokenUnstaked - stTokenStaked; + + uint256 amount = amount_ > totalRewards ? totalRewards : amount_; + + stFLIP(stFlip).transfer(beneficiary, amount); + } + /** * @notice Transfers vested tokens to beneficiary. * @param token ERC20 token which is being vested. @@ -128,9 +155,9 @@ contract TokenVestingStaking is ITokenVestingStaking, Shared { } /** - * @notice Allows the revoker to revoke the vesting and stop the beneficiary from releasing - * any tokens if the vesting period has not bene completed. Any staked tokens at the time of - * revoking can be retrieved by the revoker upon unstaking via `retrieveRevokedFunds`. + * @notice Allows the revoker to revoke the vesting and stop the beneficiary from releasing any + * tokens if the vesting period has not bene completed. Any staked tokens at the time of + * revoking can be retrieved by the revoker upon unstaking via `retrieveRevokedFunds`. * @param token ERC20 token which is being vested. */ function revoke(IERC20 token) external override onlyRevoker notRevoked { diff --git a/tests/token_vesting/stakeStProvider/test_stProvider.py b/tests/token_vesting/stakeStProvider/test_stProvider.py index 400e4d47..9941c008 100644 --- a/tests/token_vesting/stakeStProvider/test_stProvider.py +++ b/tests/token_vesting/stakeStProvider/test_stProvider.py @@ -18,15 +18,17 @@ def test_stakeToStProvider(addrs, tokenVestingStaking, cf, mockStProvider): assert stFLIP.balanceOf(staking_address) == 0 -def test_stake_unstake_rev_sender(addrs, tokenVestingStaking): +def test_rev_sender(addrs, tokenVestingStaking): tv, _, total = tokenVestingStaking with reverts(REV_MSG_NOT_BENEFICIARY): tv.stakeToStProvider(total, {"from": addrs.DEPLOYER}) with reverts(REV_MSG_NOT_BENEFICIARY): tv.unstakeFromStProvider(0, {"from": addrs.DEPLOYER}) + with reverts(REV_MSG_NOT_BENEFICIARY): + tv.claimStProviderRewards(0, {"from": addrs.DEPLOYER}) -def test_stake_unstake_rev_amount(addrs, tokenVestingStaking): +def test_rev_amount(addrs, tokenVestingStaking): tv, _, total = tokenVestingStaking with reverts(REV_MSG_ERC20_EXCEED_BAL): tv.stakeToStProvider(total + 1, {"from": addrs.BENEFICIARY}) @@ -53,6 +55,16 @@ def test_unstake_rev_revoked(addrs, tokenVestingStaking, mockStProvider): tv.unstakeFromStProvider(1, {"from": addrs.BENEFICIARY}) +def test_claim_rev_revoked(addrs, tokenVestingStaking, mockStProvider): + tv, _, _ = tokenVestingStaking + stFLIP, _, _, _ = mockStProvider + + tv.revoke(stFLIP, {"from": addrs.REVOKER}) + + with reverts(REV_MSG_TOKEN_REVOKED): + tv.claimStProviderRewards(1, {"from": addrs.BENEFICIARY}) + + def test_unstakeFromStProvider(addrs, tokenVestingStaking, cf, mockStProvider): tv, _, total = tokenVestingStaking stFLIP, _, burner, staking_address = mockStProvider @@ -73,3 +85,138 @@ def test_unstakeFromStProvider(addrs, tokenVestingStaking, cf, mockStProvider): assert stFLIP.balanceOf(tv) == 0 assert cf.flip.balanceOf(staking_address) == 0 assert stFLIP.balanceOf(staking_address) == 0 + + +## Claim rewards flow possibilities +## 1. increment stake (staked 100, unstaked 0, balance 100) +## 2. earn rewards (staked 100, unstaked 0, balance 103) +## 3. claim rewards (staked 100, unstaked 0, balance 100) 103 + 0 - 100 = 3 +## 4. receive 3 stflip +## +## 1. stake (staked 100, unstaked 0, balance 100) +## 2. earn rewards (staked 100, unstaked 0, balance 103) +## 3. unstake all (staked 100, unstaked 103, balance 0) +## 4. claim underflows (staked 100, unstaked 103, balance 0) 0 + 103 - 100 = 3 +## 5. Need to have stflip to claim +## 1. stake (staked 100, unstaked 0, balance 100) +## 2. get slashed (staked 100, unstaked 0, balance 95) +## 3. unstake all (staked 100, unstaked 0, balance 95) +## 4. claim underflows (staked 100, unstaked 0, balance 95) 95 + 0 - 100 = -5 +## 5. must earn 5 stflip first before earning claimable rewards +## +## 1. stake (staked 100, unstaked 0, balance 100) +## 2. earn rewards (staked 100, unstaked 0, balance 103) +## 3. unstake half (staked 50, unstaked 53, balance 50) +## 4. claim rewards (staked 50, unstaked 53, balance 50) 50 + 53 - 50 = 3 +## 5. Receive 3 stflip + + +def test_stProviderClaimRewards(addrs, tokenVestingStaking, cf, mockStProvider): + tv, _, total = tokenVestingStaking + stFLIP, minter, _, _ = mockStProvider + reward_amount = 100 * 10**18 + + cf.flip.approve(minter, 2**256 - 1, {"from": addrs.DEPLOYER}) + minter.mint(addrs.DEPLOYER, reward_amount, {"from": addrs.DEPLOYER}) + + assert cf.flip.balanceOf(tv) == total + assert stFLIP.balanceOf(tv) == 0 + assert tv.stTokenStaked() == 0 + assert tv.stTokenUnstaked() == 0 + + tv.stakeToStProvider(total, {"from": addrs.BENEFICIARY}) + + assert cf.flip.balanceOf(tv) == 0 + assert stFLIP.balanceOf(tv) == total + assert tv.stTokenStaked() == total + assert tv.stTokenUnstaked() == 0 + + stFLIP.transfer(tv, reward_amount, {"from": addrs.DEPLOYER}) # earn rewards + + assert cf.flip.balanceOf(tv) == 0 + assert stFLIP.balanceOf(tv) == total + reward_amount + assert tv.stTokenStaked() == total + assert tv.stTokenUnstaked() == 0 + + tv.claimStProviderRewards(reward_amount, {"from": addrs.BENEFICIARY}) + + assert cf.flip.balanceOf(tv) == 0 + assert stFLIP.balanceOf(tv) == total + assert tv.stTokenStaked() == total + assert tv.stTokenUnstaked() == 0 + assert stFLIP.balanceOf(addrs.BENEFICIARY) == reward_amount + + +def test_stProviderClaimRewardsInsufficientStflip( + addrs, tokenVestingStaking, cf, mockStProvider +): + tv, _, total = tokenVestingStaking + stFLIP, minter, _, _ = mockStProvider + reward_amount = 100 * 10**18 + + cf.flip.approve(minter, 2**256 - 1, {"from": addrs.DEPLOYER}) + minter.mint(addrs.DEPLOYER, reward_amount, {"from": addrs.DEPLOYER}) + + assert cf.flip.balanceOf(tv) == total + assert stFLIP.balanceOf(tv) == 0 + assert tv.stTokenStaked() == 0 + assert tv.stTokenUnstaked() == 0 + + tv.stakeToStProvider(total, {"from": addrs.BENEFICIARY}) + + assert cf.flip.balanceOf(tv) == 0 + assert stFLIP.balanceOf(tv) == total + assert tv.stTokenStaked() == total + assert tv.stTokenUnstaked() == 0 + + stFLIP.transfer(tv, reward_amount, {"from": addrs.DEPLOYER}) # earn rewards + + assert cf.flip.balanceOf(tv) == 0 + assert stFLIP.balanceOf(tv) == total + reward_amount + assert tv.stTokenStaked() == total + assert tv.stTokenUnstaked() == 0 + + tv.unstakeFromStProvider(total + reward_amount, {"from": addrs.BENEFICIARY}) + + assert cf.flip.balanceOf(tv) == 0 + assert stFLIP.balanceOf(tv) == 0 + assert tv.stTokenStaked() == total + assert tv.stTokenUnstaked() == total + reward_amount + + with reverts(REV_MSG_ERC20_EXCEED_BAL): + tv.claimStProviderRewards(reward_amount, {"from": addrs.BENEFICIARY}) + + +def test_stProviderClaimRewardsSlash(addrs, tokenVestingStaking, cf, mockStProvider): + tv, _, total = tokenVestingStaking + stFLIP, _, _, _ = mockStProvider + slash_amount = 100 * 10**18 + + assert cf.flip.balanceOf(tv) == total + assert stFLIP.balanceOf(tv) == 0 + assert tv.stTokenStaked() == 0 + assert tv.stTokenUnstaked() == 0 + + tv.stakeToStProvider(total, {"from": addrs.BENEFICIARY}) + + assert cf.flip.balanceOf(tv) == 0 + assert stFLIP.balanceOf(tv) == total + assert tv.stTokenStaked() == total + assert tv.stTokenUnstaked() == 0 + + stFLIP.mockSlash(tv, slash_amount, {"from": addrs.DEPLOYER}) + + assert cf.flip.balanceOf(tv) == 0 + assert stFLIP.balanceOf(tv) == total - slash_amount + assert tv.stTokenStaked() == total + assert tv.stTokenUnstaked() == 0 + + tv.unstakeFromStProvider(total - slash_amount, {"from": addrs.BENEFICIARY}) + + assert cf.flip.balanceOf(tv) == 0 + assert stFLIP.balanceOf(tv) == 0 + assert tv.stTokenStaked() == total + assert tv.stTokenUnstaked() == total - slash_amount + + with reverts(REV_MSG_INTEGER_OVERFLOW): + tv.claimStProviderRewards(2**256 - 1, {"from": addrs.BENEFICIARY})