diff --git a/src/stake/LayerEdgeStaking.sol b/src/stake/LayerEdgeStaking.sol index 1d7004d..b9c1554 100644 --- a/src/stake/LayerEdgeStaking.sol +++ b/src/stake/LayerEdgeStaking.sol @@ -36,7 +36,6 @@ contract LayerEdgeStaking is uint256 public constant SECONDS_IN_YEAR = 365 days; uint256 public constant PRECISION = 1e18; uint256 public constant UNSTAKE_WINDOW = 7 days; - // $100 worth of EDGEN (~3k tokens) uint256 public constant MAX_USERS = 100_000_000; // Tier percentages @@ -91,7 +90,6 @@ contract LayerEdgeStaking is mapping(address => UserInfo) public users; mapping(uint256 => address) public stakerAddress; mapping(address => TierEvent[]) public stakerTierHistory; - mapping(address => uint256) public totalStakersSnapshot; uint256 public stakerCountInTree; uint256 public stakerCountOutOfTree; uint256 public totalStaked; @@ -519,14 +517,6 @@ contract LayerEdgeStaking is ); } - /** - * @notice Get the amount of reward tokens available in the contract - * @return Available rewards - */ - function getAvailableRewards() external view returns (uint256) { - return rewardsReserve; - } - /** * @notice Get the APY rate for a specific tier during a time period * @param tier The tier to get the APY for @@ -621,6 +611,61 @@ contract LayerEdgeStaking is return stakerCountInTree + stakerCountOutOfTree; } + function getCumulativeFrequency(uint256 rank) external view returns (uint256) { + return stakerTree.findByCumulativeFrequency(rank); + } + + /*////////////////////////////////////////////////////////////// + UI DATA PROVIDERS + //////////////////////////////////////////////////////////////*/ + function getAllInfoOfUser(address userAddr) + external + view + returns ( + UserInfo memory user, + Tier tier, + uint256 apy, + uint256 depositTime, + uint256 pendingRewards, + TierEvent[] memory tierHistory + ) + { + user = users[userAddr]; + tier = getCurrentTier(userAddr); + apy = getUserAPY(userAddr); + depositTime = user.depositTime; + pendingRewards = calculateUnclaimedInterest(userAddr); + tierHistory = stakerTierHistory[userAddr]; + } + + function getAllStakingInfo() + external + view + returns ( + uint256 _totalStaked, + uint256 _stakerCountInTree, + uint256 _stakerCountOutOfTree, + uint256 _rewardsReserve, + uint256 _minStakeAmount, + uint256 _tier1Count, + uint256 _tier2Count, + uint256 _tier3Count + ) + { + (_tier1Count, _tier2Count, _tier3Count) = getTierCountForStakerCount(stakerCountInTree); + + return ( + totalStaked, + stakerCountInTree, + stakerCountOutOfTree, + rewardsReserve, + minStakeAmount, + _tier1Count, + _tier2Count, + _tier3Count + ); + } + /*////////////////////////////////////////////////////////////// INTERNAL FUNCTIONS //////////////////////////////////////////////////////////////*/ @@ -663,13 +708,7 @@ contract LayerEdgeStaking is user.isFirstDepositMoreThanMinStake = true; _recordTierChange(userAddr, tier); - - // Record any boundary crossings only if active staker count has changed - if (totalStakersSnapshot[userAddr] != stakerCountInTree) { - _checkBoundariesAndRecord(false); - } - - totalStakersSnapshot[userAddr] = stakerCountInTree; + _checkBoundariesAndRecord(false); } // Update user balances @@ -708,13 +747,7 @@ contract LayerEdgeStaking is stakerCountInTree--; user.outOfTree = true; stakerCountOutOfTree++; - - // Record any boundary crossings only if active staker count has changed - if (totalStakersSnapshot[userAddr] != stakerCountInTree) { - _checkBoundariesAndRecord(true); - } - - totalStakersSnapshot[userAddr] = stakerCountInTree; + _checkBoundariesAndRecord(true); } // Transfer tokens from contract to user @@ -770,7 +803,11 @@ contract LayerEdgeStaking is function _recordTierChange(address user, Tier newTier) internal { // Get current tier - Tier old = getCurrentTier(user); + Tier old = Tier.Tier3; + + if (stakerTierHistory[user].length > 0) { + old = stakerTierHistory[user][stakerTierHistory[user].length - 1].to; + } // If this is the same tier as before, no change to record if ( @@ -796,29 +833,74 @@ contract LayerEdgeStaking is // new thresholds (uint256 new_t1, uint256 new_t2,) = getTierCountForStakerCount(n); - // for each boundary, if it shifted by ±1, find the user crossing - if (new_t1 != 0 && new_t1 != old_t1) { - // someone moved across Tier1↔Tier2 - // the user at rank = min(old_t1, new_t1)+1 if promotion, or old_t1 if demotion - uint256 crossRank = new_t1 > old_t1 - ? new_t1 // promotion: the one newly entering Tier1 - : old_t1; // demotion: the one kicked out of Tier1 - uint256 joinIdCross = stakerTree.findByCumulativeFrequency(crossRank); - address userCross = stakerAddress[joinIdCross]; - Tier toTier = _computeTierByRank(joinIdCross, n); - _recordTierChange(userCross, toTier); + // Tier 1 boundary handling + if (new_t1 != 0) { + if (new_t1 != old_t1) { + // Tier boundary change - handle as before + uint256 crossRank = new_t1 > old_t1 + ? new_t1 // promotion: the one newly entering Tier1 + : old_t1; // demotion: the one kicked out of Tier1 + _findAndRecordTierChange(crossRank, n); + } + // Handle case where Tier 1 count stays the same + else if (isRemoval && new_t1 > 0) { + // If a user was removed but tier 1 count didn't change + // We need to update the user at position new_t1 (someone from Tier 2 may need promotion) + _findAndRecordTierChange(new_t1, n); + } else if (!isRemoval) { + // If a user was added, the user at position old_t1 might have changed tiers + _findAndRecordTierChange(old_t1, n); + } } - if (new_t2 != old_t2) { - // Tier2↔Tier3 boundary - uint256 crossRank = new_t2 > old_t2 ? new_t2 : old_t2; - uint256 joinIdCross = stakerTree.findByCumulativeFrequency(crossRank); - address userCross = stakerAddress[joinIdCross]; - Tier toTier = _computeTierByRank(joinIdCross, n); - _recordTierChange(userCross, toTier); + // Tier 2 boundary handling + if (new_t1 + new_t2 > 0) { + // Ensure there are stakers in Tier 1 or Tier 2 + if (new_t2 != old_t2) { + // Tier 2 boundary changed - handle as before + uint256 crossRank; + if (new_t2 > old_t2) { + // Promotion + crossRank = new_t1 + new_t2; // Add new_t1 count to land on correct index/rank + } else { + // Demotion + crossRank = new_t1 + old_t2; // Add new_t1 count to land on correct index/rank + } + _findAndRecordTierChange(crossRank, n); + } + // Handle case where Tier 2 count stays the same + else if (isRemoval) { + // If a user was removed but tier 2 count didn't change + // We need to update the user at boundary between Tier 2 and Tier 3 + uint256 crossRank = new_t1 + new_t2; // Boundary position + _findAndRecordTierChange(crossRank, n); + } else if (!isRemoval) { + // If a user was added, the user at old boundary between Tier 2 and Tier 3 might have changed + uint256 crossRank = old_t1 + old_t2; // Old boundary position + _findAndRecordTierChange(crossRank, n); + } } } + /** + * @notice Find the user at a given rank and record the tier change + * @param rank The rank of the user + * @param _stakerCountInTree The total number of stakers + */ + function _findAndRecordTierChange(uint256 rank, uint256 _stakerCountInTree) internal { + uint256 joinIdCross = stakerTree.findByCumulativeFrequency(rank); + address userCross = stakerAddress[joinIdCross]; + uint256 _rank = stakerTree.query(joinIdCross); + Tier toTier = _computeTierByRank(_rank, _stakerCountInTree); + _recordTierChange(userCross, toTier); + } + + /** + * @notice Compute the tier for a user based on their rank and total stakers + * @param rank The rank of the user + * @param totalStakers The total number of stakers + * @return The tier of the user + */ function _computeTierByRank(uint256 rank, uint256 totalStakers) internal pure returns (Tier) { if (rank == 0 || rank > totalStakers) return Tier.Tier3; (uint256 tier1Count, uint256 tier2Count,) = getTierCountForStakerCount(totalStakers); diff --git a/test/stake/LayerEdgeStakingTest.t.sol b/test/stake/LayerEdgeStakingTest.t.sol index 3bbe6c4..18c4a5a 100644 --- a/test/stake/LayerEdgeStakingTest.t.sol +++ b/test/stake/LayerEdgeStakingTest.t.sol @@ -2108,4 +2108,337 @@ contract LayerEdgeStakingTest is Test { assertEq(staking.stakerCountInTree(), 0); assertEq(staking.stakerCountOutOfTree(), 1); } + + function test_LayerEdgeStaking_TierPromotionDemotion() public { + // This test will verify the behavior of tier promotions and demotions + // according to the audit finding + + console2.log("alice", address(alice)); + console2.log("bob", address(bob)); + console2.log("charlie", address(charlie)); + console2.log("david", address(david)); + console2.log("eve", address(eve)); + console2.log("frank", address(frank)); + console2.log("grace", address(grace)); + + // 1. Setup 6 stakers to create specific tier distribution (1 in Tier1, 1 in Tier2, 4 in Tier3) + setupMultipleStakers(6); + + // Verify initial tier distribution + (uint256 tier1Count, uint256 tier2Count, uint256 tier3Count) = staking.getTierCounts(); + assertEq(tier1Count, 1, "Should have 1 staker in Tier1"); + assertEq(tier2Count, 1, "Should have 1 staker in Tier2"); + assertEq(tier3Count, 4, "Should have 4 stakers in Tier3"); + + // Check tiers of each user + assertEq( + uint256(staking.getCurrentTier(alice)), uint256(LayerEdgeStaking.Tier.Tier1), "Alice should be in Tier1" + ); + assertEq(uint256(staking.getCurrentTier(bob)), uint256(LayerEdgeStaking.Tier.Tier2), "Bob should be in Tier2"); + assertEq( + uint256(staking.getCurrentTier(charlie)), uint256(LayerEdgeStaking.Tier.Tier3), "Charlie should be in Tier3" + ); + + // Advance time to accumulate some interest at initial tiers + vm.warp(block.timestamp + 30 days); + + // Check Charlie's interest accrual at Tier3 + (,,,, uint256 charlieInterestBeforePromotion) = staking.getUserInfo(charlie); + uint256 expectedTier3Interest = (MIN_STAKE * 20 * PRECISION * 30 days) / (365 days * PRECISION) / 100; + assertApproxEqAbs( + charlieInterestBeforePromotion, expectedTier3Interest, 2, "Charlie should have accrued Tier3 interest" + ); + + // 2. Add one more staker (Grace) to test promotion + // According to the audit, this should trigger a promotion from Tier3 to Tier2 + vm.startPrank(grace); + token.approve(address(staking), MIN_STAKE); + staking.stake(MIN_STAKE); + vm.stopPrank(); + + // Verify updated tier distribution - should now be 1 in Tier1, 2 in Tier2, 4 in Tier3 + (tier1Count, tier2Count, tier3Count) = staking.getTierCounts(); + assertEq(tier1Count, 1, "Should have 1 staker in Tier1"); + assertEq(tier2Count, 2, "Should have 2 stakers in Tier2"); + assertEq(tier3Count, 4, "Should have 4 stakers in Tier3"); + + //Get staker tier history of bob and charlie + uint256 bobTierHistoryLength = staking.stakerTierHistoryLength(bob); + uint256 charlieTierHistoryLength = staking.stakerTierHistoryLength(charlie); + console2.log("bobTierHistoryLength", bobTierHistoryLength); + console2.log("charlieTierHistoryLength", charlieTierHistoryLength); + + for (uint256 i = 0; i < bobTierHistoryLength; i++) { + (LayerEdgeStaking.Tier from, LayerEdgeStaking.Tier to, uint256 timestamp) = + staking.stakerTierHistory(bob, i); + console2.log("bobTierHistory", uint256(from), uint256(to), timestamp); + } + + for (uint256 i = 0; i < charlieTierHistoryLength; i++) { + (LayerEdgeStaking.Tier from, LayerEdgeStaking.Tier to, uint256 timestamp) = + staking.stakerTierHistory(charlie, i); + console2.log("charlieTierHistory", uint256(from), uint256(to), timestamp); + } + + // Check if Charlie was promoted to Tier2 (should be first in Tier3 previously) + // The audit suggests this might not happen correctly, so we're verifying + assertEq( + uint256(staking.getCurrentTier(alice)), uint256(LayerEdgeStaking.Tier.Tier1), "Alice should remain in Tier1" + ); + assertEq( + uint256(staking.getCurrentTier(bob)), uint256(LayerEdgeStaking.Tier.Tier2), "Bob should remain in Tier2" + ); + assertEq( + uint256(staking.getCurrentTier(charlie)), + uint256(LayerEdgeStaking.Tier.Tier2), + "Charlie should be promoted to Tier2" + ); + + vm.warp(block.timestamp + 30 days); + + // Check Charlie's interest accrual after promotion to Tier2 + (,,,, uint256 charlieInterestAfterPromotion) = staking.getUserInfo(charlie); + + // Expected interest should include: + // 1. Interest already earned at Tier3 (charlieInterestBeforePromotion) + // 2. New interest earned at Tier2 rate (35%) + uint256 expectedTier2Interest = (MIN_STAKE * 35 * PRECISION * 30 days) / (365 days * PRECISION) / 100; + uint256 expectedTotalInterest = charlieInterestBeforePromotion + expectedTier2Interest; + + assertApproxEqAbs( + charlieInterestAfterPromotion, + expectedTotalInterest, + 2, + "Charlie should have accrued additional interest at Tier2 rate" + ); + } + + function test_LayerEdgeStaking_TierReassignmentOnRemoval() public { + // This test specifically checks the issue mentioned in the audit report: + // "For example, suppose there are currently 7 activeStakers: 1 in Tier1, 2 in Tier2, and 4 in Tier3. + // After removing one user, there will be 1 Tier1, 1 Tier2 and 4 Tier3." + + // Setup 7 stakers to create the specific distribution mentioned in the audit + setupMultipleStakers(7); + + // Verify initial tier distribution + (uint256 tier1Count, uint256 tier2Count, uint256 tier3Count) = staking.getTierCounts(); + + // Expected distribution with 7 stakers: + // Tier1: 20% of 7 = 1.4 => 1 staker (floored) + // Tier2: 30% of 7 = 2.1 => 2 stakers (floored) + // Tier3: Remaining 4 stakers + assertEq(tier1Count, 1, "Should have 1 staker in Tier1"); + assertEq(tier2Count, 2, "Should have 2 stakers in Tier2"); + assertEq(tier3Count, 4, "Should have 4 stakers in Tier3"); + + // Verify initial tier assignments + assertEq( + uint256(staking.getCurrentTier(alice)), uint256(LayerEdgeStaking.Tier.Tier1), "Alice should be in Tier1" + ); + assertEq(uint256(staking.getCurrentTier(bob)), uint256(LayerEdgeStaking.Tier.Tier2), "Bob should be in Tier2"); + assertEq( + uint256(staking.getCurrentTier(charlie)), uint256(LayerEdgeStaking.Tier.Tier2), "Charlie should be in Tier2" + ); + assertEq( + uint256(staking.getCurrentTier(david)), uint256(LayerEdgeStaking.Tier.Tier3), "David should be in Tier3" + ); + + console2.log("--- Initial Tier Distribution ---"); + console2.log("Alice (first staker):", uint256(staking.getCurrentTier(alice))); + console2.log("Bob (second staker):", uint256(staking.getCurrentTier(bob))); + console2.log("Charlie (third staker):", uint256(staking.getCurrentTier(charlie))); + console2.log("David (fourth staker):", uint256(staking.getCurrentTier(david))); + console2.log("Eve (fifth staker):", uint256(staking.getCurrentTier(eve))); + console2.log("Frank (sixth staker):", uint256(staking.getCurrentTier(frank))); + console2.log("Grace (seventh staker):", uint256(staking.getCurrentTier(grace))); + + // Get tier history lengths before removals + uint256 bobTierHistoryLengthBefore = staking.stakerTierHistoryLength(bob); + uint256 charlieTierHistoryLengthBefore = staking.stakerTierHistoryLength(charlie); + + // Advance time past unstaking window + vm.warp(block.timestamp + 7 days + 1); + + // CASE 1: Remove Alice (Tier1 user) + // According to the audit report, the correct behavior should be: + // "If the user in Tier1 was removed, then the first user in Tier2 should be moved to Tier1." + vm.startPrank(alice); + staking.unstake(MIN_STAKE); + vm.stopPrank(); + + // After Alice is removed, we should see: + // 1. Bob (originally first in Tier2) should move to Tier1 + // 2. Charlie should remain in Tier2 + // 3. No changes to Tier3 users + + console2.log("--- After Alice Removal ---"); + console2.log("Bob:", uint256(staking.getCurrentTier(bob))); + console2.log("Charlie:", uint256(staking.getCurrentTier(charlie))); + console2.log("David:", uint256(staking.getCurrentTier(david))); + + assertEq( + uint256(staking.getCurrentTier(bob)), + uint256(LayerEdgeStaking.Tier.Tier1), + "Bob should be promoted to Tier1 when Alice leaves" + ); + assertEq( + uint256(staking.getCurrentTier(charlie)), + uint256(LayerEdgeStaking.Tier.Tier2), + "Charlie should remain in Tier2" + ); + + // Verify Bob's tier history was updated + uint256 bobTierHistoryLengthAfter = staking.stakerTierHistoryLength(bob); + assertTrue( + bobTierHistoryLengthAfter > bobTierHistoryLengthBefore, + "Bob's tier history should be updated after Alice's removal" + ); + + //Log all history + for (uint256 i = 0; i < bobTierHistoryLengthAfter; i++) { + (LayerEdgeStaking.Tier fromTier, LayerEdgeStaking.Tier toTier,) = staking.stakerTierHistory(bob, i); + console2.log("bobTierHistory", uint256(fromTier), uint256(toTier)); + } + + if (bobTierHistoryLengthAfter > 0) { + (LayerEdgeStaking.Tier fromTier, LayerEdgeStaking.Tier toTier,) = + staking.stakerTierHistory(bob, bobTierHistoryLengthAfter - 1); + + //TODO: fix this test + assertEq( + uint256(fromTier), + uint256(LayerEdgeStaking.Tier.Tier2), + "Bob's recorded tier change should be from Tier2" + ); + assertEq( + uint256(toTier), uint256(LayerEdgeStaking.Tier.Tier1), "Bob's recorded tier change should be to Tier1" + ); + } + console2.log("before reset"); + + // Reset the test to re-check with a fresh set of stakers + vm.warp(0); + vm.roll(0); + setUp(); + setupMultipleStakers(7); + + // Verify initial tier distribution again + (tier1Count, tier2Count, tier3Count) = staking.getTierCounts(); + assertEq(tier1Count, 1, "Should have 1 staker in Tier1"); + assertEq(tier2Count, 2, "Should have 2 stakers in Tier2"); + assertEq(tier3Count, 4, "Should have 4 stakers in Tier3"); + + // CASE 2: Remove Bob (first user in Tier2) + // According to the audit report: + // "If the first user in Tier2 was removed, then no users need to be moved." + + // Get Charlie's tier history length before Bob's removal + charlieTierHistoryLengthBefore = staking.stakerTierHistoryLength(charlie); + + // Advance time past unstaking window + vm.warp(block.timestamp + 7 days + 1); + + // Bob unstakes + vm.startPrank(bob); + staking.unstake(MIN_STAKE); + vm.stopPrank(); + + console2.log("--- After Bob Removal ---"); + console2.log("Alice:", uint256(staking.getCurrentTier(alice))); + console2.log("Charlie:", uint256(staking.getCurrentTier(charlie))); + console2.log("David:", uint256(staking.getCurrentTier(david))); + + // After Bob is removed, we should see: + // 1. Alice should remain in Tier1 + // 2. Charlie should remain in Tier2 as the only Tier2 user + // 3. No other tier changes + + assertEq( + uint256(staking.getCurrentTier(alice)), uint256(LayerEdgeStaking.Tier.Tier1), "Alice should remain in Tier1" + ); + assertEq( + uint256(staking.getCurrentTier(charlie)), + uint256(LayerEdgeStaking.Tier.Tier2), + "Charlie should remain the only Tier2 user" + ); + + // Charlie's tier history should not change according to the audit report's expected behavior + uint256 charlieTierHistoryLengthAfter = staking.stakerTierHistoryLength(charlie); + assertEq(charlieTierHistoryLengthAfter, 2); + + // The audit report suggests that the bug would cause Charlie to be incorrectly demoted + // So we're checking if Charlie's tier history shows any tier changes it shouldn't have + if (charlieTierHistoryLengthAfter > charlieTierHistoryLengthBefore) { + console2.log("Charlie tier history changed when it shouldn't have!"); + for (uint256 i = charlieTierHistoryLengthBefore; i < charlieTierHistoryLengthAfter; i++) { + (LayerEdgeStaking.Tier from, LayerEdgeStaking.Tier to, uint256 timestamp) = + staking.stakerTierHistory(charlie, i); + console2.log("charlieTierHistory", uint256(from), uint256(to), timestamp); + } + } + + // Check tier counts after Bob's removal + (tier1Count, tier2Count, tier3Count) = staking.getTierCounts(); + assertEq(tier1Count, 1, "Should still have 1 staker in Tier1"); + assertEq(tier2Count, 1, "Should now have 1 staker in Tier2"); + assertEq(tier3Count, 4, "Should still have 4 stakers in Tier3"); + } + + //Multiple partial stake and unstake + function test_LayerEdgeStaking_MultiplePartialStakeAndUnstake() public { + //Alice stakes minstake + vm.startPrank(alice); + token.approve(address(staking), MIN_STAKE * 10); + staking.stake(MIN_STAKE); + + (uint256 balance,,,,,,,,,) = staking.users(alice); + assertEq(balance, MIN_STAKE); + assertEq(staking.totalStaked(), MIN_STAKE); + assertEq(staking.stakerCountInTree(), 1); + assertEq(staking.stakerCountOutOfTree(), 0); + + staking.stake(MIN_STAKE); + + (balance,,,,,,,,,) = staking.users(alice); + assertEq(balance, MIN_STAKE * 2); + assertEq(staking.totalStaked(), MIN_STAKE * 2); + assertEq(staking.stakerCountInTree(), 1); + assertEq(staking.stakerCountOutOfTree(), 0); + + staking.stake(MIN_STAKE); + + (balance,,,,,,,,,) = staking.users(alice); + assertEq(balance, MIN_STAKE * 3); + assertEq(staking.totalStaked(), MIN_STAKE * 3); + assertEq(staking.stakerCountInTree(), 1); + assertEq(staking.stakerCountOutOfTree(), 0); + + vm.warp(block.timestamp + 7 days + 1); + staking.unstake(MIN_STAKE); + + (balance,,,,,,,,,) = staking.users(alice); + assertEq(balance, MIN_STAKE * 2); + assertEq(staking.totalStaked(), MIN_STAKE * 2); + assertEq(staking.stakerCountInTree(), 1); + assertEq(staking.stakerCountOutOfTree(), 0); + + staking.unstake(MIN_STAKE); + + (balance,,,,,,,,,) = staking.users(alice); + assertEq(balance, MIN_STAKE); + assertEq(staking.totalStaked(), MIN_STAKE); + assertEq(staking.stakerCountInTree(), 1); + assertEq(staking.stakerCountOutOfTree(), 0); + + staking.unstake(1); + + (balance,,,,,,,,,) = staking.users(alice); + assertEq(balance, MIN_STAKE - 1); + assertEq(staking.totalStaked(), MIN_STAKE - 1); + assertEq(staking.stakerCountInTree(), 0); + assertEq(staking.stakerCountOutOfTree(), 1); + assertEq(uint256(staking.getCurrentTier(alice)), uint256(LayerEdgeStaking.Tier.Tier3)); + vm.stopPrank(); + } } diff --git a/test/stake/TierBoundaryAndInterestTest.t.sol b/test/stake/TierBoundaryAndInterestTest.t.sol index cfd4437..5bfd0c6 100644 --- a/test/stake/TierBoundaryAndInterestTest.t.sol +++ b/test/stake/TierBoundaryAndInterestTest.t.sol @@ -319,21 +319,49 @@ contract TierBoundaryAndInterestTest is Test { vm.prank(eve); staking.stake(MIN_STAKE); + //Assert eve record history + LayerEdgeStaking.TierEvent[] memory eveHistory = getTierHistory(eve); + assertEq(eveHistory.length, 1); + assertEq(uint256(eveHistory[0].to), uint256(LayerEdgeStaking.Tier.Tier3)); + vm.prank(frank); staking.stake(MIN_STAKE); + //Assert eve record history + eveHistory = getTierHistory(eve); + console2.log("eveHistory.length", eveHistory.length); + console2.log("eves tier", uint256(staking.getCurrentTier(eve))); + vm.prank(grace); staking.stake(MIN_STAKE); + //Assert eve record history + eveHistory = getTierHistory(eve); + console2.log("eveHistory.length", eveHistory.length); + console2.log("eves tier", uint256(staking.getCurrentTier(eve))); + vm.prank(heidi); staking.stake(MIN_STAKE); + //Assert eve record history + eveHistory = getTierHistory(eve); + console2.log("eveHistory.length", eveHistory.length); + console2.log("eves tier", uint256(staking.getCurrentTier(eve))); vm.prank(ivan); staking.stake(MIN_STAKE); + //Assert eve record history + eveHistory = getTierHistory(eve); + console2.log("eveHistory.length", eveHistory.length); + console2.log("eves tier", uint256(staking.getCurrentTier(eve))); + vm.prank(judy); staking.stake(MIN_STAKE); + //Assert eve record history + eveHistory = getTierHistory(eve); + console2.log("eveHistory.length", eveHistory.length); + console2.log("eves tier", uint256(staking.getCurrentTier(eve))); // Initial distribution should be: // Tier 1: Alice, Bob (first 2) // Tier 2: Charlie, Dave, Eve (next 3) @@ -351,6 +379,20 @@ contract TierBoundaryAndInterestTest is Test { vm.prank(bob); staking.unstake(MIN_STAKE); + //Assert bob out of tree + (,,,,,,, bool outOfTree,,) = staking.users(bob); + assertEq(outOfTree, true); + console2.log("bob out of tree", outOfTree); + + //Assert eve record history + console2.log("after bob unstake............."); + eveHistory = getTierHistory(eve); + console2.log("eveHistory.length", eveHistory.length); + console2.log("eves tier", uint256(staking.getCurrentTier(eve))); + console2.log("eves address", eve); + console2.log("daves address", dave); + console2.log("franks address", frank); + // Verify tier changes assertEq(uint256(staking.getCurrentTier(alice)), uint256(LayerEdgeStaking.Tier.Tier1)); assertEq(uint256(staking.getCurrentTier(charlie)), uint256(LayerEdgeStaking.Tier.Tier2)); // Charlie moved up to tier 1 @@ -364,10 +406,13 @@ contract TierBoundaryAndInterestTest is Test { assertEq(bobBalance, 0); // Check tier history for Eve - LayerEdgeStaking.TierEvent[] memory eveHistory = getTierHistory(eve); - assertEq(eveHistory.length, 1); + eveHistory = getTierHistory(eve); + assertEq(eveHistory.length, 3, "Length should be 3"); + console2.log("eveHistory[0].to", uint256(eveHistory[0].to)); + console2.log("eveHistory[1].to", uint256(eveHistory[1].to)); assertEq(uint256(eveHistory[0].to), uint256(LayerEdgeStaking.Tier.Tier3)); // Initial tier - // assertEq(uint256(eveHistory[1].to), uint256(LayerEdgeStaking.Tier.Tier3)); // Demoted tier + assertEq(uint256(eveHistory[1].to), uint256(LayerEdgeStaking.Tier.Tier2)); // Demoted tier + assertEq(uint256(eveHistory[2].to), uint256(LayerEdgeStaking.Tier.Tier3)); // Promoted tier } // Test multiple boundary shifts with interest calculation @@ -637,12 +682,16 @@ contract TierBoundaryAndInterestTest is Test { // Check tier history for Bob, who has moved tiers twice LayerEdgeStaking.TierEvent[] memory bobHistory = getTierHistory(bob); assertEq(bobHistory.length, 3); - assertEq(uint256(bobHistory[0].to), uint256(LayerEdgeStaking.Tier.Tier2)); // Initial tier + assertEq(uint256(bobHistory[0].from), uint256(LayerEdgeStaking.Tier.Tier3)); // Initial tier + assertEq(uint256(bobHistory[0].to), uint256(LayerEdgeStaking.Tier.Tier2)); + assertEq(uint256(bobHistory[1].from), uint256(LayerEdgeStaking.Tier.Tier2)); assertEq(uint256(bobHistory[1].to), uint256(LayerEdgeStaking.Tier.Tier1)); // Promoted when more users joined + assertEq(uint256(bobHistory[2].from), uint256(LayerEdgeStaking.Tier.Tier1)); assertEq(uint256(bobHistory[2].to), uint256(LayerEdgeStaking.Tier.Tier2)); // Demoted when users left } - // // Test APY changes, dynamic tier boundaries, and interest calculation accuracy + // Enable --via-ir to run this test + // Test APY changes, dynamic tier boundaries, and interest calculation accuracy // function test_StakingTierBoundry_APYChanges_With_RandomUserMovement() public { // // // This test verifies that the tiered staking system correctly: // // // 1. Assigns users to tiers based on staking order and tier percentages @@ -991,4 +1040,318 @@ contract TierBoundaryAndInterestTest is Test { } return history; } + + function test_TierBoundary_TierCountsUnchanged_WhenUserRemoved() public { + // This test verifies that when a user is removed and tier counts remain the same, + // the correct user's tier is still updated in the tier history + + // Setup 4 users with specific tier distribution + // This will create: 1 in Tier1, 1 in Tier2, 2 in Tier3 + vm.prank(alice); + staking.stake(MIN_STAKE); + + vm.prank(bob); + staking.stake(MIN_STAKE); + + vm.prank(charlie); + staking.stake(MIN_STAKE); + + vm.prank(dave); + staking.stake(MIN_STAKE); + + // Verify initial tier distribution + assertEq(uint256(staking.getCurrentTier(alice)), uint256(LayerEdgeStaking.Tier.Tier1)); + assertEq(uint256(staking.getCurrentTier(bob)), uint256(LayerEdgeStaking.Tier.Tier2)); + assertEq(uint256(staking.getCurrentTier(charlie)), uint256(LayerEdgeStaking.Tier.Tier3)); + assertEq(uint256(staking.getCurrentTier(dave)), uint256(LayerEdgeStaking.Tier.Tier3)); + + // Record initial tier counts + (uint256 old_t1, uint256 old_t2, uint256 old_t3) = staking.getTierCounts(); + assertEq(old_t1, 1); + assertEq(old_t2, 1); + assertEq(old_t3, 2); + + // Wait past the unstaking window + vm.warp(block.timestamp + 7 days + 1); + + // Now have Charlie (a Tier3 user) unstake + vm.prank(charlie); + staking.unstake(MIN_STAKE); + + // Check if tier counts actually remained the same + (uint256 new_t1, uint256 new_t2, uint256 new_t3) = staking.getTierCounts(); + assertEq(new_t1, 1); + assertEq(new_t2, 1); + assertEq(new_t3, 1); + + // Assert tier count condition: tier counts remain the same + assertEq(old_t1, new_t1, "Tier 1 count should remain the same"); + assertEq(old_t2, new_t2, "Tier 2 count should remain the same"); + assertEq(old_t3 - 1, new_t3, "Tier 3 count should decrease by 1"); + + // Verify alice and bob's tiers didn't change + assertEq(uint256(staking.getCurrentTier(alice)), uint256(LayerEdgeStaking.Tier.Tier1)); + assertEq(uint256(staking.getCurrentTier(bob)), uint256(LayerEdgeStaking.Tier.Tier2)); + assertEq(uint256(staking.getCurrentTier(dave)), uint256(LayerEdgeStaking.Tier.Tier3)); + + // But the more important check: verify Dave's tier history shows proper tracking + // even though tier counts didn't change + LayerEdgeStaking.TierEvent[] memory daveHistory = getTierHistory(dave); + + // Dave's tier shouldn't have changed since counts remained the same, so history length should be 1 + assertEq(daveHistory.length, 1); + assertEq(uint256(daveHistory[0].to), uint256(LayerEdgeStaking.Tier.Tier3)); + } + + function test_TierBoundary_Tier1UserRemoved_TierCountsUnchanged() public { + // This test verifies that when a Tier 1 user is removed and tier counts remain the same, + // the correct tier promotion for Tier 2 user is recorded + + // Setup 32 users exactly to create a specific tier distribution + // This will create: 6 in Tier1, 9 in Tier2, 17 in Tier3 + // 32 * 0.2 = 6.4 → 6 users in Tier1 + // 32 * 0.3 = 9.6 → 9 users in Tier2 + // 32 - 6 - 9 = 17 users in Tier3 + + // First stake with our named users + vm.prank(alice); + staking.stake(MIN_STAKE); + + vm.prank(bob); + staking.stake(MIN_STAKE); + + vm.prank(charlie); + staking.stake(MIN_STAKE); + + vm.prank(dave); + staking.stake(MIN_STAKE); + + vm.prank(eve); + staking.stake(MIN_STAKE); + + // Add 27 more users to reach exactly 32 + for (uint256 i = 0; i < 27; i++) { + address user = address(uint160(uint256(keccak256(abi.encodePacked("user", i))))); + dealToken(user, MIN_STAKE); + vm.prank(user); + staking.stake(MIN_STAKE); + } + + // Verify we have exactly 32 stakers + assertEq(staking.stakerCountInTree(), 32); + + // Get tier counts to verify our setup + (uint256 old_t1, uint256 old_t2, uint256 old_t3) = staking.getTierCounts(); + assertEq(old_t1, 6, "Should have 6 users in Tier 1"); + assertEq(old_t2, 9, "Should have 9 users in Tier 2"); + assertEq(old_t3, 17, "Should have 17 users in Tier 3"); + + // Verify Alice is in Tier 1 (she staked first) + assertEq(uint256(staking.getCurrentTier(alice)), uint256(LayerEdgeStaking.Tier.Tier1)); + + // Find a user at position 7 (first user in Tier 2) + uint256 tier2FirstUserJoinId = staking.getCumulativeFrequency(7); + address tier2FirstUser = staking.stakerAddress(tier2FirstUserJoinId); + + // Ensure this user is actually in Tier 2 + assertEq(uint256(staking.getCurrentTier(tier2FirstUser)), uint256(LayerEdgeStaking.Tier.Tier2)); + + // Wait past the unstaking window + vm.warp(block.timestamp + 7 days + 1); + + // Now have Alice (a Tier1 user) unstake + vm.prank(alice); + staking.unstake(MIN_STAKE); + + // Now we have 31 users, tier counts should be: + // 31 * 0.2 = 6.2 → 6 users in Tier1 (unchanged) + // 31 * 0.3 = 9.3 → 9 users in Tier2 (unchanged) + // 31 - 6 - 9 = 16 users in Tier3 (decreased by 1) + + // Verify tier counts + (uint256 new_t1, uint256 new_t2, uint256 new_t3) = staking.getTierCounts(); + assertEq(new_t1, 6, "Should still have 6 users in Tier 1"); + assertEq(new_t2, 9, "Should still have 9 users in Tier 2"); + assertEq(new_t3, 16, "Should have 16 users in Tier 3"); + + // The critical check: verify the first Tier 2 user was promoted to Tier 1 + // This is the key scenario the audit report mentioned + assertEq( + uint256(staking.getCurrentTier(tier2FirstUser)), + uint256(LayerEdgeStaking.Tier.Tier1), + "First Tier 2 user should be promoted to Tier 1" + ); + + // Also check their tier history to ensure the promotion was recorded + LayerEdgeStaking.TierEvent[] memory userHistory = getTierHistory(tier2FirstUser); + assertEq(userHistory.length, 3, "User should have 2 tier events"); + assertEq(uint256(userHistory[0].to), uint256(LayerEdgeStaking.Tier.Tier3), "User should have started in Tier 3"); + assertEq( + uint256(userHistory[1].to), uint256(LayerEdgeStaking.Tier.Tier2), "User should have been promoted to Tier 2" + ); + assertEq( + uint256(userHistory[2].to), uint256(LayerEdgeStaking.Tier.Tier1), "User should have been promoted to Tier 1" + ); + } + + function test_TierBoundary_TierCountsUnchanged_WhenUserAdded() public { + // This test verifies that when a user is added and tier counts remain the same, + // the appropriate tier assignments are still updated in the tier history + + // Setup 33 users to create a specific tier distribution where adding + // a user doesn't change tier counts + // 33 * 0.2 = 6.6 → 6 users in Tier1 + // 33 * 0.3 = 9.9 → 9 users in Tier2 + // 33 - 6 - 9 = 18 users in Tier3 + + // After adding one more user (34 total): + // 34 * 0.2 = 6.8 → 6 users in Tier1 (unchanged) + // 34 * 0.3 = 10.2 → 10 users in Tier2 (increased by 1) + // 34 - 6 - 10 = 18 users in Tier3 (unchanged) + + // Stake with our named users first + vm.prank(alice); + staking.stake(MIN_STAKE); + + vm.prank(bob); + staking.stake(MIN_STAKE); + + vm.prank(charlie); + staking.stake(MIN_STAKE); + + vm.prank(dave); + staking.stake(MIN_STAKE); + + vm.prank(eve); + staking.stake(MIN_STAKE); + + // Add 28 more users to reach exactly 33 + for (uint256 i = 0; i < 28; i++) { + address user = address(uint160(uint256(keccak256(abi.encodePacked("user", i))))); + dealToken(user, MIN_STAKE); + vm.prank(user); + staking.stake(MIN_STAKE); + } + + // Verify we have exactly 33 stakers + assertEq(staking.stakerCountInTree(), 33); + + // Get tier counts to verify our setup + (uint256 old_t1, uint256 old_t2, uint256 old_t3) = staking.getTierCounts(); + assertEq(old_t1, 6, "Should have 6 users in Tier 1"); + assertEq(old_t2, 9, "Should have 9 users in Tier 2"); + assertEq(old_t3, 18, "Should have 18 users in Tier 3"); + + // Find a user at position 16 (boundary between Tier2 and Tier3) + uint256 boundaryUserJoinId = staking.getCumulativeFrequency(15); + address lastTier2User = staking.stakerAddress(boundaryUserJoinId); + + // Find the first user in Tier3 + uint256 firstTier3UserJoinId = staking.getCumulativeFrequency(16); + address firstTier3User = staking.stakerAddress(firstTier3UserJoinId); + + // Verify these users' tiers + assertEq(uint256(staking.getCurrentTier(lastTier2User)), uint256(LayerEdgeStaking.Tier.Tier2)); + assertEq(uint256(staking.getCurrentTier(firstTier3User)), uint256(LayerEdgeStaking.Tier.Tier3)); + + // Now add one more user (frank) + vm.prank(frank); + staking.stake(MIN_STAKE); + + // Verify tier counts after adding the user + (uint256 new_t1, uint256 new_t2, uint256 new_t3) = staking.getTierCounts(); + assertEq(new_t1, 6, "Should still have 6 users in Tier 1"); + assertEq(new_t2, 10, "Should now have 10 users in Tier 2"); + assertEq(new_t3, 18, "Should still have 18 users in Tier 3"); + + // The critical check: verify the first user from Tier 3 was promoted to Tier 2 + assertEq( + uint256(staking.getCurrentTier(firstTier3User)), + uint256(LayerEdgeStaking.Tier.Tier2), + "First Tier 3 user should be promoted to Tier 2" + ); + + // Check tier history to ensure the promotion was recorded + LayerEdgeStaking.TierEvent[] memory userHistory = getTierHistory(firstTier3User); + assertEq(userHistory.length, 2, "User should have 2 tier events"); + assertEq(uint256(userHistory[0].to), uint256(LayerEdgeStaking.Tier.Tier3), "User should have started in Tier 3"); + assertEq( + uint256(userHistory[1].to), uint256(LayerEdgeStaking.Tier.Tier2), "User should have been promoted to Tier 2" + ); + } + + function test_TierBoundary_Tier2UserRemoved_TierCountsUnchanged() public { + // This test verifies that when a Tier 2 user is removed and tier counts remain the same, + // the correct tier promotion occurs from Tier 3 to Tier 2 + + // Setup 32 users exactly to create a specific tier distribution + // This will create: 6 in Tier1, 9 in Tier2, 17 in Tier3 + + // First stake with our named users + vm.prank(alice); + staking.stake(MIN_STAKE); + + vm.prank(bob); + staking.stake(MIN_STAKE); + + vm.prank(charlie); + staking.stake(MIN_STAKE); + + vm.prank(dave); + staking.stake(MIN_STAKE); + + vm.prank(eve); + staking.stake(MIN_STAKE); + + // Add 27 more users to reach exactly 32 + for (uint256 i = 0; i < 27; i++) { + address user = address(uint160(uint256(keccak256(abi.encodePacked("user", i))))); + dealToken(user, MIN_STAKE); + vm.prank(user); + staking.stake(MIN_STAKE); + } + + // Verify we have exactly 32 stakers + assertEq(staking.stakerCountInTree(), 32); + + // Find the last user in Tier 2 (at position 15) + uint256 lastTier2UserJoinId = staking.getCumulativeFrequency(15); + address lastTier2User = staking.stakerAddress(lastTier2UserJoinId); + + // Find the first user in Tier 3 (at position 16) + uint256 firstTier3UserJoinId = staking.getCumulativeFrequency(16); + address firstTier3User = staking.stakerAddress(firstTier3UserJoinId); + + // Verify these users' tiers + assertEq(uint256(staking.getCurrentTier(lastTier2User)), uint256(LayerEdgeStaking.Tier.Tier2)); + assertEq(uint256(staking.getCurrentTier(firstTier3User)), uint256(LayerEdgeStaking.Tier.Tier3)); + + // Wait past the unstaking window + vm.warp(block.timestamp + 7 days + 1); + + // Now have the last Tier 2 user unstake + vm.prank(lastTier2User); + staking.unstake(MIN_STAKE); + + // Verify tier counts after removal + (uint256 new_t1, uint256 new_t2, uint256 new_t3) = staking.getTierCounts(); + assertEq(new_t1, 6, "Should still have 6 users in Tier 1"); + assertEq(new_t2, 9, "Should still have 9 users in Tier 2"); + assertEq(new_t3, 16, "Should have 16 users in Tier 3"); + + // The key check: verify the first Tier 3 user was promoted to Tier 2 + assertEq( + uint256(staking.getCurrentTier(firstTier3User)), + uint256(LayerEdgeStaking.Tier.Tier2), + "First Tier 3 user should be promoted to Tier 2" + ); + + // Check tier history to ensure the promotion was recorded + LayerEdgeStaking.TierEvent[] memory userHistory = getTierHistory(firstTier3User); + assertEq(userHistory.length, 2, "User should have 2 tier events"); + assertEq(uint256(userHistory[0].to), uint256(LayerEdgeStaking.Tier.Tier3), "User should have started in Tier 3"); + assertEq( + uint256(userHistory[1].to), uint256(LayerEdgeStaking.Tier.Tier2), "User should have been promoted to Tier 2" + ); + } }