From 54d2c2040a5831dd737c576c230624eff483d9f1 Mon Sep 17 00:00:00 2001 From: JulesCrown Date: Fri, 21 Jun 2024 15:57:23 +0200 Subject: [PATCH] more staking tests --- onchain/src/Harb.sol | 3 + onchain/src/Stake.sol | 16 ++- onchain/test/BaseLineLP.t.sol | 8 +- onchain/test/Stake.t.sol | 242 +++++++++++++++++++++++++++++++++- 4 files changed, 258 insertions(+), 11 deletions(-) diff --git a/onchain/src/Harb.sol b/onchain/src/Harb.sol index b33c8a4..a366169 100644 --- a/onchain/src/Harb.sol +++ b/onchain/src/Harb.sol @@ -102,6 +102,9 @@ contract Harb is ERC20, ERC20Permit { /// @param _amount Amount of tokens to mint function mint(uint256 _amount) external onlyLiquidityManager { _mint(address(liquidityManager), _amount); + if (previousTotalSupply == 0) { + previousTotalSupply = twabController.totalSupply(address(this)); + } } /// @notice Allows the liquidityManager to burn tokens from a its account diff --git a/onchain/src/Stake.sol b/onchain/src/Stake.sol index b58116d..fa9b3bf 100644 --- a/onchain/src/Stake.sol +++ b/onchain/src/Stake.sol @@ -19,13 +19,14 @@ contract Stake { uint256 internal constant MAX_STAKE = 20; // 20% of HARB supply uint256 internal constant TAX_RATE_BASE = 100; uint256 internal constant TAX_FLOOR_DURATION = 60 * 60 * 24 * 3; //this duration is the minimum basis for fee calculation, regardless of actual holding time. + uint256 internal constant MIN_SUPPLY_FRACTION = 3000; uint256[] public TAX_RATES = [1, 3, 5, 8, 12, 18, 24, 30, 40, 50, 60, 80, 100, 130, 180, 250, 320, 420, 540, 700, 920, 1200, 1600, 2000, 2600, 3400, 4400, 5700, 7500, 9700]; /** * @dev Attempted to deposit more assets than the max amount for `receiver`. */ error TaxTooLow(address receiver, uint64 taxRateWanted, uint64 taxRateMet, uint256 positionId); - error SharesTooLow(address receiver, uint256 assets, uint256 sharesWanted, uint256 minStake); + error StakeTooLow(address receiver, uint256 assets, uint256 minStake); error NoPermission(address requester, address owner); error PositionNotFound(uint256 positionId, address requester); @@ -107,9 +108,9 @@ contract Stake { { // check that position size is at least minStake // to prevent excessive fragmentation, increasing snatch cost - uint256 minStake = harb.previousTotalSupply() / 3000; - if (sharesWanted < minStake) { - revert SharesTooLow(receiver, assets, sharesWanted, minStake); + uint256 minStake = harb.previousTotalSupply() / MIN_SUPPLY_FRACTION; + if (assets < minStake) { + revert StakeTooLow(receiver, assets, minStake); } } require(taxRate < TAX_RATES.length, "tax rate out of bounds"); @@ -126,7 +127,7 @@ contract Stake { } // check that tax lower if (taxRate <= pos.taxRate) { - revert TaxTooLow(receiver, taxRate, pos.taxRate, i); + revert TaxTooLow(receiver, taxRate, pos.taxRate, positionsToSnatch[i]); } if (pos.share < smallestPositionShare) { smallestPositionShare = pos.share; @@ -149,7 +150,7 @@ contract Stake { } // check that tax lower if (taxRate <= lastPos.taxRate) { - revert TaxTooLow(receiver, taxRate, lastPos.taxRate, index); + revert TaxTooLow(receiver, taxRate, lastPos.taxRate, positionsToSnatch[index]); } if (lastPos.share < smallestPositionShare) { smallestPositionShare = lastPos.share; @@ -265,6 +266,7 @@ contract Stake { emit PositionRemoved(positionId, pos.share, pos.lastTaxTime); delete pos.owner; delete pos.creationTime; + delete pos.share; } } @@ -275,6 +277,7 @@ contract Stake { emit PositionRemoved(positionId, pos.share, pos.lastTaxTime); delete pos.owner; delete pos.creationTime; + delete pos.share; SafeERC20.safeTransfer(harb, owner, assets); } @@ -282,6 +285,7 @@ contract Stake { require (sharesToTake < pos.share, "position too small"); uint256 assets = sharesToAssets(sharesToTake); pos.share -= sharesToTake; + outstandingStake -= sharesToTake; emit PositionShrunk(positionId, pos.share, pos.lastTaxTime, sharesToTake); SafeERC20.safeTransfer(harb, pos.owner, assets); } diff --git a/onchain/test/BaseLineLP.t.sol b/onchain/test/BaseLineLP.t.sol index 1a77eea..cbb6470 100644 --- a/onchain/test/BaseLineLP.t.sol +++ b/onchain/test/BaseLineLP.t.sol @@ -162,11 +162,11 @@ contract BaseLineLPTest is PoolSerializer { lm.slide(); appendPossitions(lm, pool, token0isWeth); - // large sell into floor - harb.setLiquidityManager(address(account)); - vm.prank(account); + // large sell into floor + vm.startPrank(address(lm)); harb.mint(3600000 ether); - harb.setLiquidityManager(address(lm)); + harb.transfer(address(account), 3600000 ether); + vm.stopPrank(); pool.swap( account, // Recipient of the output tokens !token0isWeth, diff --git a/onchain/test/Stake.t.sol b/onchain/test/Stake.t.sol index 7fd29f0..b592cf5 100644 --- a/onchain/test/Stake.t.sol +++ b/onchain/test/Stake.t.sol @@ -5,7 +5,7 @@ import "forge-std/Test.sol"; import "forge-std/console.sol"; import {TwabController} from "pt-v5-twab-controller/TwabController.sol"; import "../src/Harb.sol"; -import "../src/Stake.sol"; +import {TooMuchSnatch, Stake} from "../src/Stake.sol"; contract StakeTest is Test { TwabController tc; @@ -103,4 +103,244 @@ contract StakeTest is Test { vm.stopPrank(); } + + function testSnatchFunction() public { + uint256 initialStake1 = 1 ether; + uint256 initialStake2 = 2 ether; + address firstStaker = makeAddr("firstStaker"); + address secondStaker = makeAddr("secondStaker"); + address newStaker = makeAddr("newStaker"); + uint256 snatchAmount = 1.5 ether; + + // Mint and distribute tokens + vm.startPrank(liquidityManager); + harb.mint((initialStake1 + initialStake2) * 5); + harb.transfer(firstStaker, initialStake1); + harb.transfer(secondStaker, initialStake2); + harb.transfer(newStaker, snatchAmount); + vm.stopPrank(); + + // Setup initial stakers + uint256 positionId1 = setupStaker(firstStaker, initialStake1, 1); + uint256 positionId2 = setupStaker(secondStaker, initialStake2, 5); + + // Snatch setup + vm.startPrank(newStaker); + harb.approve(address(stakingPool), snatchAmount); + uint256 snatchShares = stakingPool.assetsToShares(snatchAmount); + uint256[] memory targetPositions = new uint256[](2); + targetPositions[0] = positionId1; + targetPositions[1] = positionId2; + + // Perform snatch + uint256 newPositionId = stakingPool.snatch(snatchAmount, newStaker, 10, targetPositions); + + // Verify new position + assertPosition(newPositionId, snatchShares, 10); + + // Check old positions + verifyPositionShrunkOrRemoved(1, initialStake1); + verifyPositionShrunkOrRemoved(2, initialStake2); + + vm.stopPrank(); + } + + function setupStaker(address staker, uint256 amount, uint32 taxRate) private returns (uint256 positionId) { + vm.startPrank(staker); + harb.approve(address(stakingPool), amount); + uint256[] memory empty; + positionId = stakingPool.snatch(amount, staker, taxRate, empty); + vm.stopPrank(); + } + + function assertPosition(uint256 positionId, uint256 expectedShares, uint32 expectedTaxRate) private view { + (uint256 shares, , , , uint32 taxRate) = stakingPool.positions(positionId); + assertEq(shares, expectedShares, "Incorrect share amount for new position"); + assertEq(taxRate, expectedTaxRate, "Incorrect tax rate for new position"); + } + + function verifyPositionShrunkOrRemoved(uint256 positionId, uint256 initialStake) private view { + (uint256 remainingShare, , , , ) = stakingPool.positions(positionId); + uint256 expectedInitialShares = stakingPool.assetsToShares(initialStake); + bool positionRemoved = remainingShare == 0; + bool positionShrunk = remainingShare < expectedInitialShares; + + assertTrue(positionRemoved || positionShrunk, "Position was not correctly shrunk or removed"); + } + + function testRevert_SharesTooLow() public { + address staker = makeAddr("staker"); + vm.startPrank(liquidityManager); + harb.mint(10 ether); + uint256 tooSmallStake = harb.previousTotalSupply() / 4000; // Less than minStake calculation + harb.transfer(staker, tooSmallStake); + vm.stopPrank(); + + vm.startPrank(staker); + harb.approve(address(stakingPool), tooSmallStake); + + uint256[] memory empty; + vm.expectRevert(abi.encodeWithSelector(Stake.StakeTooLow.selector, staker, tooSmallStake, harb.previousTotalSupply() / 3000)); + stakingPool.snatch(tooSmallStake, staker, 1, empty); + vm.stopPrank(); + } + + function testRevert_TaxTooLow() public { + address existingStaker = makeAddr("existingStaker"); + address newStaker = makeAddr("newStaker"); + vm.startPrank(liquidityManager); + harb.mint(10 ether); + harb.transfer(existingStaker, 1 ether); + harb.transfer(newStaker, 1 ether); + vm.stopPrank(); + + uint256 positionId = setupStaker(existingStaker, 1 ether, 5); // Existing staker with tax rate 5 + + vm.startPrank(newStaker); + harb.transfer(newStaker, 1 ether); + harb.approve(address(stakingPool), 1 ether); + + uint256[] memory positions = new uint256[](1); + positions[0] = positionId; // Assuming position ID 1 has tax rate 5 + + vm.expectRevert(abi.encodeWithSelector(Stake.TaxTooLow.selector, newStaker, 5, 5, positionId)); + stakingPool.snatch(1 ether, newStaker, 5, positions); // Same tax rate should fail + vm.stopPrank(); + } + + function testRevert_TooMuchSnatch() public { + address staker = makeAddr("staker"); + address ambitiousStaker = makeAddr("ambitiousStaker"); + + vm.startPrank(liquidityManager); + harb.mint(20 ether); + harb.transfer(staker, 2 ether); + harb.transfer(ambitiousStaker, 1 ether); + vm.stopPrank(); + + uint256 positionId = setupStaker(staker, 2 ether, 10); + + vm.startPrank(ambitiousStaker); + harb.approve(address(stakingPool), 1 ether); + + uint256[] memory positions = new uint256[](1); + positions[0] = positionId; + vm.expectRevert(abi.encodeWithSelector(TooMuchSnatch.selector, ambitiousStaker, 500000 ether, 1000000 ether, 1000000 ether)); + stakingPool.snatch(1 ether, ambitiousStaker, 20, positions); + vm.stopPrank(); + } + + function testRevert_PositionNotFound() public { + address staker = makeAddr("staker"); + + vm.startPrank(liquidityManager); + harb.mint(10 ether); + harb.transfer(staker, 1 ether); + vm.stopPrank(); + + vm.startPrank(staker); + harb.approve(address(stakingPool), 1 ether); + + uint256[] memory nonExistentPositions = new uint256[](1); + nonExistentPositions[0] = 999; // Assumed non-existent position ID + + vm.expectRevert(abi.encodeWithSelector(Stake.PositionNotFound.selector, 999, staker)); + stakingPool.snatch(1 ether, staker, 15, nonExistentPositions); + vm.stopPrank(); + } + + function testSuccessfulChangeTaxAndReverts() public { + uint32 newTaxRate = 3; // New valid tax rate + + address staker = makeAddr("staker"); + + vm.startPrank(liquidityManager); + harb.mint(10 ether); + harb.transfer(staker, 1 ether); + vm.stopPrank(); + + vm.startPrank(staker); + harb.approve(address(stakingPool), 1 ether); + uint256[] memory empty; + uint256 positionId = stakingPool.snatch(1 ether, staker, 1, empty); + + uint32 invalidTaxRate = 9999; // Assuming this is out of bounds + vm.expectRevert(bytes("tax rate out of bounds")); + stakingPool.changeTax(positionId, invalidTaxRate); + + stakingPool.changeTax(positionId, newTaxRate); + vm.stopPrank(); + + // Verify the change + (, , , , uint32 taxRate) = stakingPool.positions(positionId); + assertEq(taxRate, newTaxRate, "Tax rate did not update correctly"); + + // notOwner tries to change tax rate + address notOwner = makeAddr("notOwner"); + vm.prank(notOwner); + vm.expectRevert(abi.encodeWithSelector(Stake.NoPermission.selector, notOwner, staker)); + stakingPool.changeTax(positionId, newTaxRate * 2); + } + + function testNormalTaxPayment() public { + address staker = makeAddr("staker"); + + vm.startPrank(liquidityManager); + harb.mint(10 ether); + harb.transfer(staker, 1 ether); + vm.stopPrank(); + + vm.startPrank(staker); + harb.approve(address(stakingPool), 1 ether); + uint256[] memory empty; + uint256 positionId = stakingPool.snatch(1 ether, staker, 5, empty); // Using tax rate index 5, which is 18% per year + (uint256 shareBefore, , , , ) = stakingPool.positions(positionId); + + + // Immediately after staking, no tax due + stakingPool.payTax(positionId); + // Verify no change in position + (uint256 share, , , , ) = stakingPool.positions(positionId); + assertEq(share, shareBefore, "Share should not change when no tax is due"); + + // Move time forward 30 days + vm.warp(block.timestamp + 30 days); + stakingPool.payTax(positionId); + vm.stopPrank(); + + // Check that the tax was paid and position updated + (share, , , , ) = stakingPool.positions(positionId); + uint256 daysElapsed = 30; + uint256 taxRate = 18; // Corresponding to 18% annually + uint256 daysInYear = 365; + uint256 taxBase = 100; + + uint256 taxFractionForTime = taxRate * daysElapsed * 1 ether / daysInYear / taxBase; + uint256 expectedShareAfterTax = (1 ether - taxFractionForTime) * 1000000; + + assertTrue(share < shareBefore, "Share should decrease correctly after tax payment 1"); + assertEq(share, expectedShareAfterTax, "Share should decrease correctly after tax payment 2"); + } + + function testLiquidation() public { + address staker = makeAddr("staker"); + + vm.startPrank(liquidityManager); + harb.mint(10 ether); + harb.transfer(staker, 1 ether); + vm.stopPrank(); + + vm.startPrank(staker); + harb.approve(address(stakingPool), 1 ether); + uint256[] memory empty; + uint256 positionId = stakingPool.snatch(1 ether, staker, 12, empty); // Using tax rate index 5, which is 100% per year + vm.warp(block.timestamp + 365 days); // Move time forward to ensure maximum tax due + stakingPool.payTax(positionId); + vm.stopPrank(); + + // Verify position is liquidated + (uint256 share, , , , ) = stakingPool.positions(positionId); + assertEq(share, 0, "Share should be zero after liquidation"); + } + }