diff --git a/docs/src/src/libs/LibAdmin.sol/library.LibAdmin.md b/docs/src/src/libs/LibAdmin.sol/library.LibAdmin.md index 116f54d0..67ec5ed0 100644 --- a/docs/src/src/libs/LibAdmin.sol/library.LibAdmin.md +++ b/docs/src/src/libs/LibAdmin.sol/library.LibAdmin.md @@ -10,13 +10,6 @@ function _getSystemId() internal pure returns (bytes32); ``` -### _getEmptyId - - -```solidity -function _getEmptyId() internal pure returns (bytes32); -``` - ### _updateMaxDividendDenominations diff --git a/docs/src/src/libs/LibHelpers.sol/library.LibHelpers.md b/docs/src/src/libs/LibHelpers.sol/library.LibHelpers.md index d1251d82..7f3770aa 100644 --- a/docs/src/src/libs/LibHelpers.sol/library.LibHelpers.md +++ b/docs/src/src/libs/LibHelpers.sol/library.LibHelpers.md @@ -5,13 +5,6 @@ Pure functions ## Functions -### _getIdForObjectAtIndex - - -```solidity -function _getIdForObjectAtIndex(uint256 _index) internal pure returns (bytes32); -``` - ### _getIdForAddress @@ -33,13 +26,6 @@ function _getSenderId() internal view returns (bytes32); function _checkBottom12BytesAreEmpty(bytes32 value) internal pure returns (bool); ``` -### _checkUpper12BytesAreEmpty - - -```solidity -function _checkUpper12BytesAreEmpty(bytes32 value) internal pure returns (bool); -``` - ### _getAddressFromId diff --git a/docs/src/src/shared/FreeStructs.sol/struct.StakingCheckpoint.md b/docs/src/src/shared/FreeStructs.sol/struct.StakingCheckpoint.md deleted file mode 100644 index a4ff1b1a..00000000 --- a/docs/src/src/shared/FreeStructs.sol/struct.StakingCheckpoint.md +++ /dev/null @@ -1,13 +0,0 @@ -# StakingCheckpoint -[Git Source](https://github.com/nayms/contracts-v3/blob/0aa70a4d39a9875c02cd43cc38c09012f52d800e/src/shared/FreeStructs.sol) - - -```solidity -struct StakingCheckpoint { - int128 bias; - int128 slope; - uint256 ts; - uint256 blk; -} -``` - diff --git a/src/facets/StakingFacet.sol b/src/facets/StakingFacet.sol index 1f5a815c..3e0d4d0d 100644 --- a/src/facets/StakingFacet.sol +++ b/src/facets/StakingFacet.sol @@ -70,6 +70,17 @@ contract StakingFacet is Modifiers { LibTokenizedVaultStaking._collectRewards(parentId, _entityId, lastPaid); } + /** + * @notice Collect rewards for a staker + * @param _entityId staking entity ID + * @param _interval interval to collect rewards up to + */ + function collectRewardsToInterval(bytes32 _entityId, uint64 _interval) external notLocked { + bytes32 parentId = LibObject._getParent(msg.sender._getIdForAddress()); + + LibTokenizedVaultStaking._collectRewards(parentId, _entityId, _interval); + } + function payReward(bytes32 _stakingRewardId, bytes32 _entityId, bytes32 _rewardTokenId, uint256 _amount) external notLocked assertPrivilege(_entityId, LC.GROUP_ENTITY_ADMINS) { LibTokenizedVaultStaking._payReward(_stakingRewardId, _entityId, _rewardTokenId, _amount); } diff --git a/src/interfaces/IERC1271.sol b/src/interfaces/IERC1271.sol index a56057ba..42cef2b8 100644 --- a/src/interfaces/IERC1271.sol +++ b/src/interfaces/IERC1271.sol @@ -6,6 +6,7 @@ pragma solidity ^0.8.20; /** * @dev Interface of the ERC1271 standard signature validation method for * contracts as defined in https://eips.ethereum.org/EIPS/eip-1271[ERC-1271]. + * @dev We use this interface to generate java bindings for solidity contracts. */ interface IERC1271 { /** diff --git a/src/libs/LibAdmin.sol b/src/libs/LibAdmin.sol index 8eb73905..74c55fb5 100644 --- a/src/libs/LibAdmin.sol +++ b/src/libs/LibAdmin.sol @@ -141,12 +141,13 @@ library LibAdmin { s.locked[IDiamondProxy.stake.selector] = true; s.locked[IDiamondProxy.unstake.selector] = true; s.locked[IDiamondProxy.collectRewards.selector] = true; + s.locked[IDiamondProxy.collectRewardsToInterval.selector] = true; s.locked[IDiamondProxy.payReward.selector] = true; s.locked[IDiamondProxy.cancelSimplePolicy.selector] = true; s.locked[IDiamondProxy.createSimplePolicy.selector] = true; s.locked[IDiamondProxy.createEntity.selector] = true; - bytes4[] memory lockedFunctions = new bytes4[](21); + bytes4[] memory lockedFunctions = new bytes4[](22); lockedFunctions[0] = IDiamondProxy.startTokenSale.selector; lockedFunctions[1] = IDiamondProxy.paySimpleClaim.selector; lockedFunctions[2] = IDiamondProxy.paySimplePremium.selector; @@ -168,6 +169,7 @@ library LibAdmin { lockedFunctions[18] = IDiamondProxy.cancelSimplePolicy.selector; lockedFunctions[19] = IDiamondProxy.createSimplePolicy.selector; lockedFunctions[20] = IDiamondProxy.createEntity.selector; + lockedFunctions[21] = IDiamondProxy.collectRewardsToInterval.selector; emit FunctionsLocked(lockedFunctions); } @@ -195,8 +197,9 @@ library LibAdmin { s.locked[IDiamondProxy.cancelSimplePolicy.selector] = false; s.locked[IDiamondProxy.createSimplePolicy.selector] = false; s.locked[IDiamondProxy.createEntity.selector] = false; + s.locked[IDiamondProxy.collectRewardsToInterval.selector] = false; - bytes4[] memory lockedFunctions = new bytes4[](21); + bytes4[] memory lockedFunctions = new bytes4[](22); lockedFunctions[0] = IDiamondProxy.startTokenSale.selector; lockedFunctions[1] = IDiamondProxy.paySimpleClaim.selector; lockedFunctions[2] = IDiamondProxy.paySimplePremium.selector; @@ -218,6 +221,7 @@ library LibAdmin { lockedFunctions[18] = IDiamondProxy.cancelSimplePolicy.selector; lockedFunctions[19] = IDiamondProxy.createSimplePolicy.selector; lockedFunctions[20] = IDiamondProxy.createEntity.selector; + lockedFunctions[21] = IDiamondProxy.collectRewardsToInterval.selector; emit FunctionsUnlocked(lockedFunctions); } diff --git a/src/libs/LibTokenizedVaultStaking.sol b/src/libs/LibTokenizedVaultStaking.sol index 3c87604d..e62cc7f3 100644 --- a/src/libs/LibTokenizedVaultStaking.sol +++ b/src/libs/LibTokenizedVaultStaking.sol @@ -19,6 +19,7 @@ library LibTokenizedVaultStaking { /** * @dev First 4 bytes: "VTOK", next 8 bytes: interval, next 20 bytes: right 20 bytes of tokenId + * @param _entityId The ID of the entity. * @param _tokenId The internal ID of the token. * @param _interval The interval of staking. */ @@ -49,9 +50,16 @@ library LibTokenizedVaultStaking { emit TokenStakingStarted(_entityId, _config.tokenId, _config.initDate, _config.a, _config.r, _config.divider, _config.interval); } + /** + * @notice Checks if staking has been initialized for the given entity. + * @dev Staking is considered initialized if the initDate is set and the current timestamp is + * equal to or after the initDate. + * @param _entityId The ID of the entity to check staking initialization. + * @return bool indicating whether staking is initialized. + */ function _isStakingInitialized(bytes32 _entityId) internal view returns (bool) { AppStorage storage s = LibAppStorage.diamondStorage(); - return (s.stakingConfigs[_entityId].initDate > 0 && s.stakingConfigs[_entityId].initDate < block.timestamp); + return (s.stakingConfigs[_entityId].initDate > 0 && s.stakingConfigs[_entityId].initDate <= block.timestamp); } function _stakingConfig(bytes32 _entityId) internal view returns (StakingConfig memory) { @@ -78,7 +86,14 @@ library LibTokenizedVaultStaking { AppStorage storage s = LibAppStorage.diamondStorage(); return s.stakeCollected[_entityId][_entityId]; } - + /** + * @notice Pays rewards to a staker. + * @dev Rewards can be paid if the current timestamp is equal to or after the staking initDate. + * @param _stakingRewardId The ID for the staking reward. + * @param _entityId The ID of the entity whose rewards are being paid. + * @param _rewardTokenId The ID of the reward token. + * @param _rewardAmount The amount of reward to be paid. + */ function _payReward(bytes32 _stakingRewardId, bytes32 _entityId, bytes32 _rewardTokenId, uint256 _rewardAmount) internal { AppStorage storage s = LibAppStorage.diamondStorage(); @@ -110,7 +125,7 @@ library LibTokenizedVaultStaking { s.stakeBalance[vTokenId][_entityId] = stakingState.balance; s.stakeBoost[vTokenId][_entityId] = stakingState.boost; - // Update last colleted interval for the token itself + // Update last collected interval for the token itself s.stakeCollected[_entityId][_entityId] = interval; // Transfer the funds @@ -128,7 +143,8 @@ library LibTokenizedVaultStaking { bytes32 tokenId = s.stakingConfigs[_entityId].tokenId; - if (_amount < s.objectMinimumSell[tokenId]) revert InvalidStakingAmount(); + // Prevent staking below or equal to the minimum required + if (_amount <= s.objectMinimumSell[tokenId]) revert InvalidStakingAmount(); uint64 currentInterval = _currentInterval(_entityId); bytes32 vTokenIdMax = _vTokenIdBucket(_entityId, tokenId); @@ -152,18 +168,25 @@ library LibTokenizedVaultStaking { uint256 boost1 = ((((_getD(_entityId) - ratio) * _amount) / _getD(_entityId)) * _getA(_entityId)) / _getD(_entityId); uint256 boost2 = (((ratio * _amount) / _getD(_entityId)) * _getA(_entityId)) / _getD(_entityId); + uint256 balance1 = _amount - (ratio * _amount) / _getD(_entityId); uint256 balance2 = (ratio * _amount) / _getD(_entityId); s.stakeBalance[_vTokenId(_entityId, tokenId, currentInterval + 1)][_stakerId] += balance1 + boost1; s.stakeBalance[_vTokenId(_entityId, tokenId, currentInterval + 1)][_entityId] += balance1 + boost1; + s.stakeBalanceAdded[_vTokenId(_entityId, tokenId, currentInterval + 1)][_stakerId] += balance1; + s.stakeBalanceAdded[_vTokenId(_entityId, tokenId, currentInterval + 1)][_entityId] += balance1; + s.stakeBoost[_vTokenId(_entityId, tokenId, currentInterval + 1)][_stakerId] += (boost1 * _getR(_entityId)) / _getD(_entityId) + boost2; s.stakeBoost[_vTokenId(_entityId, tokenId, currentInterval + 1)][_entityId] += (boost1 * _getR(_entityId)) / _getD(_entityId) + boost2; s.stakeBalance[_vTokenId(_entityId, tokenId, currentInterval + 2)][_stakerId] += balance2; s.stakeBalance[_vTokenId(_entityId, tokenId, currentInterval + 2)][_entityId] += balance2; + s.stakeBalanceAdded[_vTokenId(_entityId, tokenId, currentInterval + 2)][_stakerId] += balance2; + s.stakeBalanceAdded[_vTokenId(_entityId, tokenId, currentInterval + 2)][_entityId] += balance2; + emit TokenStaked(_stakerId, _entityId, tokenId, _amount); } @@ -257,7 +280,7 @@ library LibTokenizedVaultStaking { uint256 totalDistributionAmount = s.stakingDistributionAmount[vTokenId_i]; if (totalDistributionAmount > 0) { uint256 currencyIndex; - (rewards, currencyIndex) = addUniqueValue(rewards, s.stakingDistributionDenomination[vTokenId_i]); + (rewards, currencyIndex) = _addUniqueValue(rewards, s.stakingDistributionDenomination[vTokenId_i]); // Use the same math as dividend distributions, assuming zero has already been collected uint256 userDistributionAmount = LibTokenizedVault._getWithdrawableDividendAndDeductionMath( @@ -348,7 +371,7 @@ library LibTokenizedVaultStaking { return s.stakingConfigs[_entityId].divider; } - function addUniqueValue(RewardsBalances memory rewards, bytes32 newValue) internal pure returns (RewardsBalances memory, uint256) { + function _addUniqueValue(RewardsBalances memory rewards, bytes32 newValue) internal pure returns (RewardsBalances memory, uint256) { require(rewards.currencies.length == rewards.amounts.length, "Different array lengths!"); uint256 length = rewards.currencies.length; @@ -361,7 +384,7 @@ library LibTokenizedVaultStaking { // prettier-ignore RewardsBalances memory rewards_ = RewardsBalances({ currencies: new bytes32[](length + 1), - amounts: new uint256[](rewards.amounts.length + 1), + amounts: new uint256[](length + 1), lastPaidInterval: 0 }); @@ -378,7 +401,7 @@ library LibTokenizedVaultStaking { /** * @dev Get the starting time of a given interval - * @param _entityId The internal ID of the token + * @param _entityId The internal ID of the entity * @param _interval The interval to get the time for */ function _calculateStartTimeOfInterval(bytes32 _entityId, uint64 _interval) internal view returns (uint64 intervalTime_) { @@ -390,36 +413,25 @@ library LibTokenizedVaultStaking { intervalTime_ = _calculateStartTimeOfInterval(_entityId, _currentInterval(_entityId)); } - function _stakedAmount(bytes32 _stakerId, bytes32 _entityId) internal view returns (uint256) { + function _getStakingAmounts(bytes32 _stakerId, bytes32 _entityId) internal view returns (uint256 stakedBalance_, uint256 boostedBalance_) { AppStorage storage s = LibAppStorage.diamondStorage(); - bytes32 tokenId = s.stakingConfigs[_entityId].tokenId; - bytes32 vTokenIdMax = _vTokenIdBucket(_entityId, tokenId); - - return s.stakeBalance[vTokenIdMax][_stakerId]; - } - - function _getStakingAmounts(bytes32 _stakerId, bytes32 _entityId) internal view returns (uint256 stakedBalance_, uint256 boostedBalance_) { uint64 currentInterval = _currentInterval(_entityId); + bytes32 tokenId = s.stakingConfigs[_entityId].tokenId; - stakedBalance_ = _stakedAmount(_stakerId, _entityId); + stakedBalance_ = s.stakeBalance[_vTokenIdBucket(_entityId, tokenId)][_stakerId]; if (!_isStakingInitialized(_entityId)) { // boost is always 1 before init return (stakedBalance_, boostedBalance_); } - (StakingState memory state, ) = _getStakingStateWithRewardsBalances(_stakerId, _entityId, currentInterval + 2); + (StakingState memory state, ) = _getStakingStateWithRewardsBalances(_stakerId, _entityId, currentInterval); - uint256 boostPrevious = state.boost; - uint256 balancePrevious = state.balance; - - for (uint i = 0; i < 2; i++) { - boostPrevious = (boostPrevious * _getD(_entityId)) / _getR(_entityId); - balancePrevious = balancePrevious - boostPrevious; - } + uint256 balance1 = s.stakeBalanceAdded[_vTokenId(_entityId, tokenId, currentInterval + 1)][_stakerId]; + uint256 balance2 = s.stakeBalanceAdded[_vTokenId(_entityId, tokenId, currentInterval + 2)][_stakerId]; - boostedBalance_ = balancePrevious; + boostedBalance_ = state.balance + balance1 + balance2; if (boostedBalance_ < stakedBalance_) { boostedBalance_ = stakedBalance_; diff --git a/src/shared/AppStorage.sol b/src/shared/AppStorage.sol index 7b12846e..852cb7a8 100644 --- a/src/shared/AppStorage.sol +++ b/src/shared/AppStorage.sol @@ -83,14 +83,29 @@ struct AppStorage { mapping(address userAddress => EntityApproval) selfOnboarding; // map address => { entityId, roleId } /// Staking mapping(bytes32 entityId => StakingConfig) stakingConfigs; // StakingConfig for an entity - mapping(bytes32 vTokenId => mapping(bytes32 stakerId => uint256 balance)) stakeBalance; // [vTokenId][ownerId] boost at interval + mapping(bytes32 vTokenId => mapping(bytes32 stakerId => uint256 balance)) stakeBalance; // [vTokenId][ownerId] balance at interval mapping(bytes32 vTokenId => mapping(bytes32 stakerId => uint256 boost)) stakeBoost; // [vTokenId][ownerId] boost at interval mapping(bytes32 entityId => mapping(bytes32 stakerId => uint64 interval)) stakeCollected; // last interval reward was collected or pain for a staker in staking entity mapping(bytes32 vTokenId => uint256 amount) stakingDistributionAmount; // [vTokenId] Reward at interval mapping(bytes32 vTokenId => bytes32 denomination) stakingDistributionDenomination; // [vTokenId] Reward currency mapping(bytes32 entityId => mapping(bytes32 stakerId => uint64 interval)) stakingSynced; // last interval when data was synced into storage for staker + mapping(bytes32 vTokenId => mapping(bytes32 stakerId => uint256 balance)) stakeBalanceAdded; // raw balance staked at an interval, withouth any boost included, only for reading future intervals (to calculate the total boosted balance) } +/// Staking-Related Mappings + +/// | Mapping Name | Key Structure | Value Type | Description | +/// |-----------------------------------|----------------------------------------|------------|-------------------------------------------------------------------------------------------------------------| +/// | `stakeCollected` | `[Entity ID][Staker ID]` | `uint64` | Records the last timestamp a staker collected their stake. | +/// | `stakeCollected` | `[Entity ID][Entity ID]` | `uint64` | Records the last timestamp an entity paid out rewards. | +/// | `stakeBalance` | `[vTokenId][Account ID]` | `uint256` | Tracks staked balances for accounts across different intervals. | +/// | `stakeBoost` | `[vTokenId][Account ID]` | `uint256` | Tracks boosted staked balances for accounts. | +/// | `stakeBalanceAdded` | `[vTokenId][Staker ID]` | `uint256` | Raw balance staked at an interval without any boost, used for future interval calculations. | +/// | `stakingDistributionAmount` | `[vTokenId]` | `uint256` | Stores the reward amount for each `vTokenId` at each interval. | +/// | `stakingDistributionDenomination` | `[vTokenId]` | `bytes32` | Stores the reward currency (`denomination`) for each `vTokenId` at each interval. | +/// | `stakingSynced` | `[Entity ID][Staker ID]` | `uint64` | Records the last interval when data was synced into storage for a staker. | +/// | `objectMinimumSell` | `[Token ID][Entity ID]` | `uint256` | Sets minimum staking and reward amounts for tokens per entity. | + struct FunctionLockedStorage { mapping(bytes4 => bool) locked; // function selector => is locked? } diff --git a/test/T02Admin.t.sol b/test/T02Admin.t.sol index 1a224d2c..1d0c969e 100644 --- a/test/T02Admin.t.sol +++ b/test/T02Admin.t.sol @@ -241,7 +241,7 @@ contract T02AdminTest is D03ProtocolDefaults, MockAccounts { assertEq(entries[0].topics[0], keccak256("FunctionsLocked(bytes4[])")); (s_functionSelectors) = abi.decode(entries[0].data, (bytes4[])); - bytes4[] memory lockedFunctions = new bytes4[](21); + bytes4[] memory lockedFunctions = new bytes4[](22); lockedFunctions[0] = IDiamondProxy.startTokenSale.selector; lockedFunctions[1] = IDiamondProxy.paySimpleClaim.selector; lockedFunctions[2] = IDiamondProxy.paySimplePremium.selector; @@ -263,6 +263,7 @@ contract T02AdminTest is D03ProtocolDefaults, MockAccounts { lockedFunctions[18] = IDiamondProxy.cancelSimplePolicy.selector; lockedFunctions[19] = IDiamondProxy.createSimplePolicy.selector; lockedFunctions[20] = IDiamondProxy.createEntity.selector; + lockedFunctions[21] = IDiamondProxy.collectRewardsToInterval.selector; for (uint256 i = 0; i < lockedFunctions.length; i++) { assertTrue(nayms.isFunctionLocked(lockedFunctions[i])); @@ -322,6 +323,9 @@ contract T02AdminTest is D03ProtocolDefaults, MockAccounts { vm.expectRevert("function is locked"); nayms.collectRewards(bytes32(0)); + vm.expectRevert("function is locked"); + nayms.collectRewardsToInterval(bytes32(0), 5); + vm.expectRevert("function is locked"); nayms.cancelSimplePolicy(bytes32(0)); @@ -354,6 +358,7 @@ contract T02AdminTest is D03ProtocolDefaults, MockAccounts { assertFalse(nayms.isFunctionLocked(IDiamondProxy.unstake.selector), "function unstake locked"); assertFalse(nayms.isFunctionLocked(IDiamondProxy.payReward.selector), "function payReward locked"); assertFalse(nayms.isFunctionLocked(IDiamondProxy.collectRewards.selector), "function collectRewards locked"); + assertFalse(nayms.isFunctionLocked(IDiamondProxy.collectRewardsToInterval.selector), "function collectRewardsToInterval locked"); assertFalse(nayms.isFunctionLocked(IDiamondProxy.cancelSimplePolicy.selector), "function cancelSimplePolicy locked"); assertFalse(nayms.isFunctionLocked(IDiamondProxy.createSimplePolicy.selector), "function createSimplePolicy locked"); assertFalse(nayms.isFunctionLocked(IDiamondProxy.createEntity.selector), "function createEntity locked"); diff --git a/test/T06Staking.t.sol b/test/T06Staking.t.sol index d386d4cf..21d87a1e 100644 --- a/test/T06Staking.t.sol +++ b/test/T06Staking.t.sol @@ -1325,6 +1325,42 @@ contract T06Staking is D03ProtocolDefaults { // printAppstorage(); } + function test_stake_QS_4_4() public { + uint256 startTime = block.timestamp + 1; + uint256 stake1Time = startTime + 15 days; + uint256 stake2Time = startTime + 31 days; + + initStaking(startTime); + + vm.warp(stake1Time); + + c.log("-- Stake 1 ETH -- ".yellow()); + startPrank(bob); + nayms.stake(nlf.entityId, 1 ether); + + (uint256 stakedBalance, uint256 boostedBalance) = nayms.getStakingAmounts(bob.entityId, nlf.entityId); + assertEq(stakedBalance, boostedBalance, "Bob should have no boost".red()); + printCurrentState(nlf.entityId, bob.entityId, "Bob"); + + vm.warp(stake2Time); + c.log("-- WARP 31 DAYS -- ".yellow()); + printCurrentState(nlf.entityId, bob.entityId, "Bob"); + + uint256 bobBoost = calculateBoost(startTime, stake2Time, R, I, SCALE_FACTOR); + uint256 boostedBalance1 = (0.5 ether * bobBoost) / SCALE_FACTOR / SCALE_FACTOR + 0.5 ether; + + (, uint256 boostedBalance2) = nayms.getStakingAmounts(bob.entityId, nlf.entityId); + assertEq(boostedBalance2, boostedBalance1, "Bob should have boost at[1]".red()); + + c.log("~~~ Stake 1 ETH -- ".yellow()); + nayms.stake(nlf.entityId, 1 ether); + + (, uint256 boostedBalance3) = nayms.getStakingAmounts(bob.entityId, nlf.entityId); + assertEq(boostedBalance3, boostedBalance1 + 1 ether, "Bob should have boost and more stake".red()); + + printCurrentState(nlf.entityId, bob.entityId, "Bob"); + } + function printAppstorage() public { uint64 interval = currentInterval() + 2;