2024-06-13 08:28:42 +02:00
|
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
|
pragma solidity ^0.8.19;
|
|
|
|
|
|
2025-07-11 13:47:42 +02:00
|
|
|
import "../src/Kraiken.sol";
|
2026-02-26 03:59:20 +00:00
|
|
|
import { ExceededAvailableStake, Stake, TooMuchSnatch } from "../src/Stake.sol";
|
2025-07-18 20:30:50 +02:00
|
|
|
import "./helpers/TestBase.sol";
|
2025-10-04 15:17:09 +02:00
|
|
|
import "forge-std/Test.sol";
|
|
|
|
|
import "forge-std/console.sol";
|
2024-06-13 08:28:42 +02:00
|
|
|
|
2025-07-25 19:09:11 +02:00
|
|
|
contract StakeTest is TestConstants {
|
2025-07-11 13:47:42 +02:00
|
|
|
Kraiken kraiken;
|
2024-06-19 10:33:28 +02:00
|
|
|
Stake stakingPool;
|
|
|
|
|
address liquidityPool;
|
|
|
|
|
address liquidityManager;
|
|
|
|
|
|
2025-10-04 15:17:09 +02:00
|
|
|
event PositionCreated(uint256 indexed positionId, address indexed owner, uint256 kraikenDeposit, uint256 share, uint32 taxRate);
|
2025-07-11 13:47:42 +02:00
|
|
|
event PositionRemoved(uint256 indexed positionId, address indexed owner, uint256 kraikenPayout);
|
2024-06-23 08:44:54 +02:00
|
|
|
|
2024-06-13 08:28:42 +02:00
|
|
|
function setUp() public {
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken = new Kraiken("KRAIKEN", "KRK");
|
|
|
|
|
stakingPool = new Stake(address(kraiken), makeAddr("taxRecipient"));
|
|
|
|
|
kraiken.setStakingPool(address(stakingPool));
|
2024-06-19 10:33:28 +02:00
|
|
|
liquidityManager = makeAddr("liquidityManager");
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.setLiquidityManager(liquidityManager);
|
2024-06-19 10:33:28 +02:00
|
|
|
}
|
2024-06-13 08:28:42 +02:00
|
|
|
|
2024-11-07 15:33:40 +00:00
|
|
|
function assertPosition(uint256 positionId, uint256 expectedShares, uint32 expectedTaxRate) private view {
|
2025-07-08 10:33:10 +02:00
|
|
|
(uint256 shares,,,, uint32 taxRate) = stakingPool.positions(positionId);
|
2024-11-07 15:33:40 +00:00
|
|
|
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 {
|
2025-07-08 10:33:10 +02:00
|
|
|
(uint256 remainingShare,,,,) = stakingPool.positions(positionId);
|
2024-11-07 15:33:40 +00:00
|
|
|
uint256 expectedInitialShares = stakingPool.assetsToShares(initialStake);
|
|
|
|
|
bool positionRemoved = remainingShare == 0;
|
|
|
|
|
bool positionShrunk = remainingShare < expectedInitialShares;
|
|
|
|
|
|
|
|
|
|
assertTrue(positionRemoved || positionShrunk, "Position was not correctly shrunk or removed");
|
|
|
|
|
}
|
2024-06-13 08:28:42 +02:00
|
|
|
|
2024-06-19 10:33:28 +02:00
|
|
|
function testBasicStaking() public {
|
|
|
|
|
// Setup
|
|
|
|
|
uint256 stakeAmount = 1 ether;
|
|
|
|
|
address staker = makeAddr("staker");
|
2024-06-13 08:28:42 +02:00
|
|
|
|
2024-06-19 10:33:28 +02:00
|
|
|
vm.startPrank(liquidityManager);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.mint(stakeAmount * 5);
|
|
|
|
|
kraiken.transfer(staker, stakeAmount);
|
2024-06-19 10:33:28 +02:00
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
vm.startPrank(staker);
|
|
|
|
|
|
|
|
|
|
// Approve and stake
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.approve(address(stakingPool), stakeAmount);
|
2024-06-19 10:33:28 +02:00
|
|
|
uint256[] memory empty;
|
|
|
|
|
uint256 sharesExpected = stakingPool.assetsToShares(stakeAmount);
|
|
|
|
|
vm.expectEmit(address(stakingPool));
|
2025-10-04 15:17:09 +02:00
|
|
|
emit PositionCreated(654_321, staker, stakeAmount, sharesExpected, 1);
|
2024-06-19 10:33:28 +02:00
|
|
|
uint256 positionId = stakingPool.snatch(stakeAmount, staker, 1, empty);
|
|
|
|
|
|
|
|
|
|
// Check results
|
2025-10-04 15:17:09 +02:00
|
|
|
assertEq(stakingPool.outstandingStake(), stakingPool.assetsToShares(stakeAmount), "Outstanding stake did not update correctly");
|
2025-07-08 10:33:10 +02:00
|
|
|
(uint256 share, address owner, uint32 creationTime,, uint32 taxRate) = stakingPool.positions(positionId);
|
2024-06-19 10:33:28 +02:00
|
|
|
assertEq(stakingPool.sharesToAssets(share), stakeAmount, "Stake amount in position is incorrect");
|
|
|
|
|
assertEq(owner, staker, "Stake owner is incorrect");
|
|
|
|
|
assertEq(creationTime, uint32(block.timestamp), "Creation time is incorrect");
|
|
|
|
|
assertEq(taxRate, 1, "Tax rate should be initialized to 1");
|
|
|
|
|
|
|
|
|
|
vm.stopPrank();
|
2024-06-13 08:28:42 +02:00
|
|
|
}
|
|
|
|
|
|
2024-06-19 10:33:28 +02:00
|
|
|
function testUnstaking() public {
|
|
|
|
|
// Setup: Create a staking position first
|
|
|
|
|
uint256 stakeAmount = 1 ether;
|
|
|
|
|
address staker = makeAddr("staker");
|
|
|
|
|
|
|
|
|
|
vm.startPrank(liquidityManager);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.mint(stakeAmount * 5); // Ensuring the staker has enough balance
|
|
|
|
|
kraiken.transfer(staker, stakeAmount);
|
2024-06-19 10:33:28 +02:00
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
// Staker stakes tokens
|
|
|
|
|
vm.startPrank(staker);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.approve(address(stakingPool), stakeAmount);
|
2024-06-13 08:28:42 +02:00
|
|
|
uint256[] memory empty;
|
2024-06-19 10:33:28 +02:00
|
|
|
uint256 positionId = stakingPool.snatch(stakeAmount, staker, 1, empty);
|
|
|
|
|
|
|
|
|
|
// Simulate time passage to accumulate tax liability
|
|
|
|
|
vm.warp(block.timestamp + 3 days);
|
|
|
|
|
|
|
|
|
|
// Calculate expected tax due at the moment of unstaking
|
|
|
|
|
uint256 taxAmount = stakingPool.taxDue(positionId, 0);
|
|
|
|
|
uint256 assetsAfterTax = stakeAmount - taxAmount;
|
|
|
|
|
|
|
|
|
|
// Expect the PositionRemoved event with the expected parameters
|
|
|
|
|
vm.expectEmit(true, true, true, true);
|
2024-06-23 08:44:54 +02:00
|
|
|
emit PositionRemoved(positionId, staker, assetsAfterTax);
|
2024-06-19 10:33:28 +02:00
|
|
|
|
|
|
|
|
// Perform unstaking
|
|
|
|
|
stakingPool.exitPosition(positionId);
|
|
|
|
|
|
|
|
|
|
// Check results after unstaking
|
2025-07-11 13:47:42 +02:00
|
|
|
assertEq(kraiken.balanceOf(staker), assetsAfterTax, "Assets after tax not returned correctly");
|
2024-06-19 10:33:28 +02:00
|
|
|
assertEq(stakingPool.outstandingStake(), 0, "Outstanding stake not updated correctly");
|
2024-06-13 08:28:42 +02:00
|
|
|
|
2024-06-19 10:33:28 +02:00
|
|
|
// Ensure the position is cleared
|
2025-07-08 10:33:10 +02:00
|
|
|
(, address owner, uint32 time,,) = stakingPool.positions(positionId);
|
2024-06-19 10:33:28 +02:00
|
|
|
assertEq(time, 0, "Position time not cleared");
|
|
|
|
|
assertEq(owner, address(0), "Position owner not cleared");
|
2024-06-13 08:28:42 +02:00
|
|
|
|
2024-06-19 10:33:28 +02:00
|
|
|
vm.stopPrank();
|
2024-06-13 08:28:42 +02:00
|
|
|
}
|
2024-06-21 15:57:23 +02:00
|
|
|
|
|
|
|
|
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);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.mint((initialStake1 + initialStake2) * 5);
|
|
|
|
|
kraiken.transfer(firstStaker, initialStake1);
|
|
|
|
|
kraiken.transfer(secondStaker, initialStake2);
|
|
|
|
|
kraiken.transfer(newStaker, snatchAmount);
|
2024-06-21 15:57:23 +02:00
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
// Setup initial stakers
|
2024-11-07 15:33:40 +00:00
|
|
|
uint256 positionId1 = doSnatch(firstStaker, initialStake1, 1);
|
|
|
|
|
uint256 positionId2 = doSnatch(secondStaker, initialStake2, 5);
|
2024-06-21 15:57:23 +02:00
|
|
|
|
|
|
|
|
// Snatch setup
|
|
|
|
|
vm.startPrank(newStaker);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.approve(address(stakingPool), snatchAmount);
|
2024-06-21 15:57:23 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-07 15:33:40 +00:00
|
|
|
function doSnatch(address staker, uint256 amount, uint32 taxRate) private returns (uint256 positionId) {
|
2024-06-21 15:57:23 +02:00
|
|
|
vm.startPrank(staker);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.approve(address(stakingPool), amount);
|
2024-06-21 15:57:23 +02:00
|
|
|
uint256[] memory empty;
|
|
|
|
|
positionId = stakingPool.snatch(amount, staker, taxRate, empty);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-18 20:30:50 +02:00
|
|
|
// Using bp() and denormTR() from TestBase
|
2024-06-21 15:57:23 +02:00
|
|
|
|
2024-11-07 15:33:40 +00:00
|
|
|
function testAvgTaxRateAndPercentageStaked() public {
|
|
|
|
|
uint256 smallstake = 0.3e17;
|
|
|
|
|
uint256 stakeOneThird = 1 ether;
|
|
|
|
|
uint256 stakeTwoThird = 2 ether;
|
|
|
|
|
address staker = makeAddr("staker");
|
|
|
|
|
|
|
|
|
|
// Mint and distribute tokens
|
|
|
|
|
vm.startPrank(liquidityManager);
|
2025-07-08 10:33:10 +02:00
|
|
|
// mint all the tokens we will need in the test
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.mint((smallstake + stakeOneThird + stakeTwoThird) * 5);
|
2025-07-08 10:33:10 +02:00
|
|
|
// send 20% of that to staker
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.transfer(staker, (smallstake + stakeOneThird + stakeTwoThird) * 2);
|
2024-11-07 15:33:40 +00:00
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
// Setup initial stakers
|
|
|
|
|
uint256 positionId1 = doSnatch(staker, smallstake, 0);
|
|
|
|
|
|
|
|
|
|
uint256 avgTaxRate;
|
2025-07-08 10:33:10 +02:00
|
|
|
uint256 percentageStaked;
|
2024-11-07 15:33:40 +00:00
|
|
|
avgTaxRate = stakingPool.getAverageTaxRate();
|
2025-07-08 10:33:10 +02:00
|
|
|
percentageStaked = stakingPool.getPercentageStaked();
|
2024-11-07 15:33:40 +00:00
|
|
|
|
2025-07-08 10:33:10 +02:00
|
|
|
// let this be about 10 basis points of tax rate
|
2024-11-07 15:33:40 +00:00
|
|
|
assertApproxEqRel(bp(denormTR(avgTaxRate)), 10, 1e17);
|
|
|
|
|
assertApproxEqRel(bp(percentageStaked), 10, 1e17);
|
|
|
|
|
|
|
|
|
|
vm.prank(staker);
|
|
|
|
|
stakingPool.exitPosition(positionId1);
|
|
|
|
|
uint256 positionId2 = doSnatch(staker, stakeOneThird, 2);
|
|
|
|
|
|
|
|
|
|
avgTaxRate = stakingPool.getAverageTaxRate();
|
2025-07-08 10:33:10 +02:00
|
|
|
percentageStaked = stakingPool.getPercentageStaked();
|
2024-11-07 15:33:40 +00:00
|
|
|
|
|
|
|
|
assertApproxEqRel(bp(denormTR(avgTaxRate)), 50, 1e17);
|
|
|
|
|
assertApproxEqRel(bp(percentageStaked), 300, 1e17);
|
|
|
|
|
|
|
|
|
|
vm.prank(staker);
|
|
|
|
|
stakingPool.exitPosition(positionId2);
|
|
|
|
|
positionId1 = doSnatch(staker, stakeOneThird, 10);
|
|
|
|
|
positionId2 = doSnatch(staker, stakeTwoThird, 11);
|
|
|
|
|
|
|
|
|
|
avgTaxRate = stakingPool.getAverageTaxRate();
|
2025-07-08 10:33:10 +02:00
|
|
|
percentageStaked = stakingPool.getPercentageStaked();
|
2024-11-07 15:33:40 +00:00
|
|
|
|
|
|
|
|
assertApproxEqRel(bp(denormTR(avgTaxRate)), 730, 1e17);
|
|
|
|
|
assertApproxEqRel(bp(percentageStaked), 1000, 1e17);
|
|
|
|
|
|
|
|
|
|
vm.startPrank(staker);
|
|
|
|
|
stakingPool.exitPosition(positionId1);
|
|
|
|
|
stakingPool.exitPosition(positionId2);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
positionId1 = doSnatch(staker, stakeOneThird, 29);
|
|
|
|
|
positionId2 = doSnatch(staker, stakeTwoThird, 29);
|
|
|
|
|
|
|
|
|
|
avgTaxRate = stakingPool.getAverageTaxRate();
|
2025-10-04 15:17:09 +02:00
|
|
|
assertApproxEqRel(bp(denormTR(avgTaxRate)), 97_000, 1e17);
|
2024-11-07 15:33:40 +00:00
|
|
|
|
|
|
|
|
vm.startPrank(staker);
|
|
|
|
|
stakingPool.exitPosition(positionId1);
|
|
|
|
|
stakingPool.exitPosition(positionId2);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
positionId2 = doSnatch(staker, stakeTwoThird, 15);
|
|
|
|
|
|
|
|
|
|
avgTaxRate = stakingPool.getAverageTaxRate();
|
2025-07-08 10:33:10 +02:00
|
|
|
percentageStaked = stakingPool.getPercentageStaked();
|
2024-11-07 15:33:40 +00:00
|
|
|
|
|
|
|
|
assertApproxEqRel(bp(denormTR(avgTaxRate)), 2500, 1e17);
|
|
|
|
|
assertApproxEqRel(bp(percentageStaked), 660, 1e17);
|
|
|
|
|
|
|
|
|
|
vm.startPrank(staker);
|
|
|
|
|
stakingPool.exitPosition(positionId2);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
positionId1 = doSnatch(staker, stakeOneThird, 15);
|
|
|
|
|
|
|
|
|
|
avgTaxRate = stakingPool.getAverageTaxRate();
|
2025-07-08 10:33:10 +02:00
|
|
|
percentageStaked = stakingPool.getPercentageStaked();
|
2024-11-07 15:33:40 +00:00
|
|
|
|
|
|
|
|
assertApproxEqRel(bp(denormTR(avgTaxRate)), 2500, 1e17);
|
|
|
|
|
assertApproxEqRel(bp(percentageStaked), 330, 1e17);
|
2024-06-21 15:57:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function testRevert_SharesTooLow() public {
|
|
|
|
|
address staker = makeAddr("staker");
|
|
|
|
|
vm.startPrank(liquidityManager);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.mint(10 ether);
|
|
|
|
|
uint256 tooSmallStake = kraiken.previousTotalSupply() / 4000; // Less than minStake calculation
|
|
|
|
|
kraiken.transfer(staker, tooSmallStake);
|
2024-06-21 15:57:23 +02:00
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
vm.startPrank(staker);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.approve(address(stakingPool), tooSmallStake);
|
2024-06-21 15:57:23 +02:00
|
|
|
|
|
|
|
|
uint256[] memory empty;
|
2025-10-04 15:17:09 +02:00
|
|
|
vm.expectRevert(abi.encodeWithSelector(Stake.StakeTooLow.selector, staker, tooSmallStake, kraiken.previousTotalSupply() / 3000));
|
2024-06-21 15:57:23 +02:00
|
|
|
stakingPool.snatch(tooSmallStake, staker, 1, empty);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function testRevert_TaxTooLow() public {
|
|
|
|
|
address existingStaker = makeAddr("existingStaker");
|
|
|
|
|
address newStaker = makeAddr("newStaker");
|
|
|
|
|
vm.startPrank(liquidityManager);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.mint(10 ether);
|
|
|
|
|
kraiken.transfer(existingStaker, 1 ether);
|
|
|
|
|
kraiken.transfer(newStaker, 1 ether);
|
2024-06-21 15:57:23 +02:00
|
|
|
vm.stopPrank();
|
|
|
|
|
|
2024-11-07 15:33:40 +00:00
|
|
|
uint256 positionId = doSnatch(existingStaker, 1 ether, 5); // Existing staker with tax rate 5
|
2024-06-21 15:57:23 +02:00
|
|
|
|
|
|
|
|
vm.startPrank(newStaker);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.transfer(newStaker, 1 ether);
|
|
|
|
|
kraiken.approve(address(stakingPool), 1 ether);
|
2024-06-21 15:57:23 +02:00
|
|
|
|
|
|
|
|
uint256[] memory positions = new uint256[](1);
|
2025-07-08 10:33:10 +02:00
|
|
|
positions[0] = positionId; // Assuming position ID 1 has tax rate 5
|
2024-06-21 15:57:23 +02:00
|
|
|
|
|
|
|
|
vm.expectRevert(abi.encodeWithSelector(Stake.TaxTooLow.selector, newStaker, 5, 5, positionId));
|
2025-07-08 10:33:10 +02:00
|
|
|
stakingPool.snatch(1 ether, newStaker, 5, positions); // Same tax rate should fail
|
2024-06-21 15:57:23 +02:00
|
|
|
vm.stopPrank();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function testRevert_TooMuchSnatch() public {
|
|
|
|
|
address staker = makeAddr("staker");
|
|
|
|
|
address ambitiousStaker = makeAddr("ambitiousStaker");
|
|
|
|
|
|
|
|
|
|
vm.startPrank(liquidityManager);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.mint(20 ether);
|
|
|
|
|
kraiken.transfer(staker, 2 ether);
|
|
|
|
|
kraiken.transfer(ambitiousStaker, 1 ether);
|
2024-06-21 15:57:23 +02:00
|
|
|
vm.stopPrank();
|
|
|
|
|
|
2024-11-07 15:33:40 +00:00
|
|
|
uint256 positionId = doSnatch(staker, 2 ether, 10);
|
2024-06-21 15:57:23 +02:00
|
|
|
|
|
|
|
|
vm.startPrank(ambitiousStaker);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.approve(address(stakingPool), 1 ether);
|
2024-06-21 15:57:23 +02:00
|
|
|
|
|
|
|
|
uint256[] memory positions = new uint256[](1);
|
|
|
|
|
positions[0] = positionId;
|
2025-10-04 15:17:09 +02:00
|
|
|
vm.expectRevert(abi.encodeWithSelector(TooMuchSnatch.selector, ambitiousStaker, 500_000 ether, 1_000_000 ether, 1_000_000 ether));
|
2024-06-21 15:57:23 +02:00
|
|
|
stakingPool.snatch(1 ether, ambitiousStaker, 20, positions);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function testRevert_PositionNotFound() public {
|
|
|
|
|
address staker = makeAddr("staker");
|
|
|
|
|
|
|
|
|
|
vm.startPrank(liquidityManager);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.mint(10 ether);
|
|
|
|
|
kraiken.transfer(staker, 1 ether);
|
2024-06-21 15:57:23 +02:00
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
vm.startPrank(staker);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.approve(address(stakingPool), 1 ether);
|
2024-06-21 15:57:23 +02:00
|
|
|
|
|
|
|
|
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);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.mint(10 ether);
|
|
|
|
|
kraiken.transfer(staker, 1 ether);
|
2024-06-21 15:57:23 +02:00
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
vm.startPrank(staker);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.approve(address(stakingPool), 1 ether);
|
2024-06-21 15:57:23 +02:00
|
|
|
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
|
2025-07-08 10:33:10 +02:00
|
|
|
(,,,, uint32 taxRate) = stakingPool.positions(positionId);
|
2024-06-21 15:57:23 +02:00
|
|
|
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);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.mint(10 ether);
|
|
|
|
|
kraiken.transfer(staker, 1 ether);
|
2024-06-21 15:57:23 +02:00
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
vm.startPrank(staker);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.approve(address(stakingPool), 1 ether);
|
2024-06-21 15:57:23 +02:00
|
|
|
uint256[] memory empty;
|
2025-07-08 10:33:10 +02:00
|
|
|
uint256 positionId = stakingPool.snatch(1 ether, staker, 5, empty); // Using tax rate index 5, which is 18% per year
|
|
|
|
|
(uint256 shareBefore,,,,) = stakingPool.positions(positionId);
|
2024-06-21 15:57:23 +02:00
|
|
|
|
|
|
|
|
// Immediately after staking, no tax due
|
|
|
|
|
stakingPool.payTax(positionId);
|
|
|
|
|
// Verify no change in position
|
2025-07-08 10:33:10 +02:00
|
|
|
(uint256 share,,,,) = stakingPool.positions(positionId);
|
2024-06-21 15:57:23 +02:00
|
|
|
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
|
2025-07-08 10:33:10 +02:00
|
|
|
(share,,,,) = stakingPool.positions(positionId);
|
2024-06-21 15:57:23 +02:00
|
|
|
uint256 daysElapsed = 30;
|
|
|
|
|
uint256 taxRate = 18; // Corresponding to 18% annually
|
|
|
|
|
uint256 daysInYear = 365;
|
|
|
|
|
uint256 taxBase = 100;
|
|
|
|
|
|
|
|
|
|
uint256 taxFractionForTime = taxRate * daysElapsed * 1 ether / daysInYear / taxBase;
|
2025-10-04 15:17:09 +02:00
|
|
|
uint256 expectedShareAfterTax = (1 ether - taxFractionForTime) * 1_000_000;
|
2024-06-21 15:57:23 +02:00
|
|
|
|
|
|
|
|
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);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.mint(10 ether);
|
|
|
|
|
kraiken.transfer(staker, 1 ether);
|
2024-06-21 15:57:23 +02:00
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
vm.startPrank(staker);
|
2025-07-11 13:47:42 +02:00
|
|
|
kraiken.approve(address(stakingPool), 1 ether);
|
2024-06-21 15:57:23 +02:00
|
|
|
uint256[] memory empty;
|
2025-07-08 10:33:10 +02:00
|
|
|
uint256 positionId = stakingPool.snatch(1 ether, staker, 12, empty); // Using tax rate index 5, which is 100% per year
|
2024-06-21 15:57:23 +02:00
|
|
|
vm.warp(block.timestamp + 365 days); // Move time forward to ensure maximum tax due
|
|
|
|
|
stakingPool.payTax(positionId);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
// Verify position is liquidated
|
2025-07-08 10:33:10 +02:00
|
|
|
(uint256 share,,,,) = stakingPool.positions(positionId);
|
2024-06-21 15:57:23 +02:00
|
|
|
assertEq(share, 0, "Share should be zero after liquidation");
|
|
|
|
|
}
|
2026-02-26 03:59:20 +00:00
|
|
|
|
|
|
|
|
// ── New branch-coverage tests ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// @dev Covers: require(taxRate < TAX_RATES.length) revert in snatch()
|
|
|
|
|
function testRevert_TaxRateOutOfBounds_InSnatch() public {
|
|
|
|
|
address staker = makeAddr("staker");
|
|
|
|
|
vm.startPrank(liquidityManager);
|
|
|
|
|
kraiken.mint(5 ether);
|
|
|
|
|
kraiken.transfer(staker, 1 ether);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
vm.startPrank(staker);
|
|
|
|
|
kraiken.approve(address(stakingPool), 1 ether);
|
|
|
|
|
uint256[] memory empty;
|
|
|
|
|
vm.expectRevert(bytes("tax rate out of bounds"));
|
|
|
|
|
stakingPool.snatch(1 ether, staker, 9999, empty);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// @dev Covers: PositionNotFound revert inside the non-last-position loop in snatch()
|
|
|
|
|
function testRevert_PositionNotFound_NonLastInLoop() public {
|
|
|
|
|
address staker = makeAddr("staker");
|
|
|
|
|
address newStaker = makeAddr("newStaker");
|
|
|
|
|
|
|
|
|
|
vm.startPrank(liquidityManager);
|
|
|
|
|
kraiken.mint(10 ether);
|
|
|
|
|
kraiken.transfer(staker, 1 ether);
|
|
|
|
|
kraiken.transfer(newStaker, 1 ether);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
uint256 realPositionId = doSnatch(staker, 1 ether, 5);
|
|
|
|
|
|
|
|
|
|
// positions[0] is the non-last (loop) position and does not exist
|
|
|
|
|
vm.startPrank(newStaker);
|
|
|
|
|
kraiken.approve(address(stakingPool), 1 ether);
|
|
|
|
|
uint256[] memory positions = new uint256[](2);
|
|
|
|
|
positions[0] = 9999; // non-existent → triggers PositionNotFound in loop
|
|
|
|
|
positions[1] = realPositionId;
|
|
|
|
|
vm.expectRevert(abi.encodeWithSelector(Stake.PositionNotFound.selector, 9999, newStaker));
|
|
|
|
|
stakingPool.snatch(1 ether, newStaker, 10, positions);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// @dev Covers: TaxTooLow revert inside the non-last-position loop in snatch()
|
|
|
|
|
function testRevert_TaxTooLow_NonLastInLoop() public {
|
|
|
|
|
address staker1 = makeAddr("staker1");
|
|
|
|
|
address staker2 = makeAddr("staker2");
|
|
|
|
|
address newStaker = makeAddr("newStaker");
|
|
|
|
|
|
|
|
|
|
vm.startPrank(liquidityManager);
|
|
|
|
|
kraiken.mint(15 ether);
|
|
|
|
|
kraiken.transfer(staker1, 1 ether);
|
|
|
|
|
kraiken.transfer(staker2, 1 ether);
|
|
|
|
|
kraiken.transfer(newStaker, 1 ether);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
// staker1 has taxRate 10, staker2 has taxRate 15
|
|
|
|
|
uint256 positionId1 = doSnatch(staker1, 1 ether, 10);
|
|
|
|
|
uint256 positionId2 = doSnatch(staker2, 1 ether, 15);
|
|
|
|
|
|
|
|
|
|
// newStaker uses taxRate 8 < positionId1.taxRate(10) → TaxTooLow in the loop
|
|
|
|
|
vm.startPrank(newStaker);
|
|
|
|
|
kraiken.approve(address(stakingPool), 1 ether);
|
|
|
|
|
uint256[] memory positions = new uint256[](2);
|
|
|
|
|
positions[0] = positionId1; // non-last: taxRate 10 checked in loop
|
|
|
|
|
positions[1] = positionId2; // last
|
|
|
|
|
vm.expectRevert(abi.encodeWithSelector(Stake.TaxTooLow.selector, newStaker, uint64(8), uint64(10), positionId1));
|
|
|
|
|
stakingPool.snatch(1 ether, newStaker, 8, positions);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// @dev Covers: _exitPosition() branch for the last snatched position
|
|
|
|
|
/// Triggered when lastSharesNeeded > lastPos.share * 80 / 100
|
|
|
|
|
function testSnatch_ExitLastPosition() public {
|
|
|
|
|
address staker = makeAddr("staker");
|
|
|
|
|
address newStaker = makeAddr("newStaker");
|
|
|
|
|
|
|
|
|
|
// Mint 20 ether so authorizedStake ≈ 4 ether in share-asset terms.
|
|
|
|
|
// Staker stakes 3.8 ether, leaving 0.2 ether of authorized stake free.
|
|
|
|
|
// NewStaker wants 3.9 ether → lastSharesNeeded (3.7 ether) > 80% of lastPos (3.04 ether)
|
|
|
|
|
// → _exitPosition is called on the last (only) position.
|
|
|
|
|
vm.startPrank(liquidityManager);
|
|
|
|
|
kraiken.mint(20 ether);
|
|
|
|
|
kraiken.transfer(staker, 3.8 ether);
|
|
|
|
|
kraiken.transfer(newStaker, 3.9 ether);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
uint256 positionId = doSnatch(staker, 3.8 ether, 3);
|
|
|
|
|
|
|
|
|
|
vm.startPrank(newStaker);
|
|
|
|
|
kraiken.approve(address(stakingPool), 3.9 ether);
|
|
|
|
|
uint256[] memory positions = new uint256[](1);
|
|
|
|
|
positions[0] = positionId;
|
|
|
|
|
uint256 newPositionId = stakingPool.snatch(3.9 ether, newStaker, 10, positions);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
// New position must exist with the right owner
|
|
|
|
|
(, address owner,,,) = stakingPool.positions(newPositionId);
|
|
|
|
|
assertEq(owner, newStaker, "New position owner should be newStaker");
|
|
|
|
|
|
|
|
|
|
// Old position must be fully cleared
|
|
|
|
|
(uint256 remainingShare,,,,) = stakingPool.positions(positionId);
|
|
|
|
|
assertEq(remainingShare, 0, "Old position should be fully exited");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// @dev Covers: ExceededAvailableStake revert in snatch() when no positions are provided
|
|
|
|
|
function testRevert_ExceededAvailableStake() public {
|
|
|
|
|
address staker = makeAddr("staker");
|
|
|
|
|
address staker2 = makeAddr("staker2");
|
|
|
|
|
|
|
|
|
|
// Mint 5 ether → authorizedStake = 1 ether in share terms.
|
|
|
|
|
// After staking 1 ether the pool is full.
|
|
|
|
|
vm.startPrank(liquidityManager);
|
|
|
|
|
kraiken.mint(5 ether);
|
|
|
|
|
kraiken.transfer(staker, 1 ether);
|
|
|
|
|
kraiken.transfer(staker2, 0.5 ether);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
doSnatch(staker, 1 ether, 1); // fills authorized stake completely
|
|
|
|
|
|
|
|
|
|
vm.startPrank(staker2);
|
|
|
|
|
kraiken.approve(address(stakingPool), 0.5 ether);
|
|
|
|
|
uint256[] memory empty;
|
|
|
|
|
uint256 sharesWanted = stakingPool.assetsToShares(0.5 ether);
|
|
|
|
|
vm.expectRevert(abi.encodeWithSelector(ExceededAvailableStake.selector, staker2, sharesWanted, 0));
|
|
|
|
|
stakingPool.snatch(0.5 ether, staker2, 1, empty);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// @dev Covers: TooMuchSnatch revert at the post-snatch check (line 250)
|
|
|
|
|
/// Two positions are provided; after dissolving the non-last position and
|
|
|
|
|
/// exiting the last one, the freed stake exceeds the smallest snatched position.
|
|
|
|
|
function testRevert_TooMuchSnatch_AvailableExceedsNeed() public {
|
|
|
|
|
address staker1 = makeAddr("staker1");
|
|
|
|
|
address staker2 = makeAddr("staker2");
|
|
|
|
|
address newStaker = makeAddr("newStaker");
|
|
|
|
|
|
|
|
|
|
// Mint 20 ether → authorizedStake = 4 ether.
|
|
|
|
|
// staker1 stakes 0.3 ether (small, becomes non-last), staker2 stakes 3.5 ether (last).
|
|
|
|
|
// outstanding = 3.8 ether, available = 0.2 ether.
|
|
|
|
|
// newStaker wants 3.5 ether; provides [staker1pos, staker2pos].
|
|
|
|
|
// After dissolving staker1pos: available = 0.5 ether < 3.5 ether → no early TooMuchSnatch.
|
|
|
|
|
// lastSharesNeeded = 3.5 - 0.5 = 3.0 ether > 80% of 3.5 ether → exit staker2pos.
|
|
|
|
|
// Post-exit available = 4 ether; 4 - 3.5 = 0.5 > smallestShare(0.3) → TooMuchSnatch.
|
|
|
|
|
vm.startPrank(liquidityManager);
|
|
|
|
|
kraiken.mint(20 ether);
|
|
|
|
|
kraiken.transfer(staker1, 0.3 ether);
|
|
|
|
|
kraiken.transfer(staker2, 3.5 ether);
|
|
|
|
|
kraiken.transfer(newStaker, 3.5 ether);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
uint256 posId1 = doSnatch(staker1, 0.3 ether, 3);
|
|
|
|
|
uint256 posId2 = doSnatch(staker2, 3.5 ether, 3);
|
|
|
|
|
|
|
|
|
|
// Compute expected revert values:
|
|
|
|
|
// After all exits outstandingStake = 0, so availableStake = authorizedStake = totalSupply*20/100
|
|
|
|
|
uint256 sharesWanted = stakingPool.assetsToShares(3.5 ether);
|
|
|
|
|
uint256 authorizedStakeVal = stakingPool.totalSupply() * 20 / 100;
|
|
|
|
|
uint256 smallestShare = stakingPool.assetsToShares(0.3 ether); // staker1 position (non-last, no tax)
|
|
|
|
|
|
|
|
|
|
vm.startPrank(newStaker);
|
|
|
|
|
kraiken.approve(address(stakingPool), 3.5 ether);
|
|
|
|
|
uint256[] memory positions = new uint256[](2);
|
|
|
|
|
positions[0] = posId1; // non-last: dissolved in loop
|
|
|
|
|
positions[1] = posId2; // last: exits fully
|
|
|
|
|
vm.expectRevert(abi.encodeWithSelector(TooMuchSnatch.selector, newStaker, sharesWanted, authorizedStakeVal, smallestShare));
|
|
|
|
|
stakingPool.snatch(3.5 ether, newStaker, 10, positions);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// @dev Covers: PositionNotFound revert in changeTax()
|
|
|
|
|
function testRevert_PositionNotFound_InChangeTax() public {
|
|
|
|
|
address staker = makeAddr("staker");
|
|
|
|
|
vm.prank(staker);
|
|
|
|
|
vm.expectRevert(abi.encodeWithSelector(Stake.PositionNotFound.selector, 9999, staker));
|
|
|
|
|
stakingPool.changeTax(9999, 5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// @dev Covers: require(taxRate > pos.taxRate, "tax too low to snatch") in changeTax()
|
|
|
|
|
function testRevert_TaxTooLow_InChangeTax() public {
|
|
|
|
|
address staker = makeAddr("staker");
|
|
|
|
|
|
|
|
|
|
vm.startPrank(liquidityManager);
|
|
|
|
|
kraiken.mint(5 ether);
|
|
|
|
|
kraiken.transfer(staker, 1 ether);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
vm.startPrank(staker);
|
|
|
|
|
kraiken.approve(address(stakingPool), 1 ether);
|
|
|
|
|
uint256[] memory empty;
|
|
|
|
|
uint256 positionId = stakingPool.snatch(1 ether, staker, 5, empty);
|
|
|
|
|
|
|
|
|
|
// Attempting to change to same or lower tax rate must revert
|
|
|
|
|
vm.expectRevert(bytes("tax too low to snatch"));
|
|
|
|
|
stakingPool.changeTax(positionId, 5); // same rate
|
|
|
|
|
|
|
|
|
|
vm.expectRevert(bytes("tax too low to snatch"));
|
|
|
|
|
stakingPool.changeTax(positionId, 3); // lower rate
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// @dev Covers: NoPermission revert in exitPosition() when called by non-owner
|
|
|
|
|
function testRevert_NoPermission_InExitPosition() public {
|
|
|
|
|
address staker = makeAddr("staker");
|
|
|
|
|
address notOwner = makeAddr("notOwner");
|
|
|
|
|
|
|
|
|
|
vm.startPrank(liquidityManager);
|
|
|
|
|
kraiken.mint(5 ether);
|
|
|
|
|
kraiken.transfer(staker, 1 ether);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
uint256 positionId = doSnatch(staker, 1 ether, 1);
|
|
|
|
|
|
|
|
|
|
vm.prank(notOwner);
|
|
|
|
|
vm.expectRevert(abi.encodeWithSelector(Stake.NoPermission.selector, notOwner, staker));
|
|
|
|
|
stakingPool.exitPosition(positionId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// @dev Covers: PositionNotFound revert in payTax() for a non-existent position
|
|
|
|
|
function testRevert_PositionNotFound_InPayTax() public {
|
|
|
|
|
address caller = makeAddr("caller");
|
|
|
|
|
vm.prank(caller);
|
|
|
|
|
vm.expectRevert(abi.encodeWithSelector(Stake.PositionNotFound.selector, 9999, caller));
|
|
|
|
|
stakingPool.payTax(9999);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// @dev Covers: permitAndSnatch() — the only uncovered function
|
|
|
|
|
function testPermitAndSnatch() public {
|
|
|
|
|
uint256 stakeAmount = 1 ether;
|
|
|
|
|
(address staker, uint256 stakerKey) = makeAddrAndKey("staker");
|
|
|
|
|
|
|
|
|
|
vm.startPrank(liquidityManager);
|
|
|
|
|
kraiken.mint(stakeAmount * 5);
|
|
|
|
|
kraiken.transfer(staker, stakeAmount);
|
|
|
|
|
vm.stopPrank();
|
|
|
|
|
|
|
|
|
|
uint256 deadline = block.timestamp + 1 hours;
|
|
|
|
|
uint256 nonce = kraiken.nonces(staker);
|
|
|
|
|
|
|
|
|
|
bytes32 permitTypehash = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
|
|
|
|
|
bytes32 structHash = keccak256(abi.encode(permitTypehash, staker, address(stakingPool), stakeAmount, nonce, deadline));
|
|
|
|
|
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", kraiken.DOMAIN_SEPARATOR(), structHash));
|
|
|
|
|
(uint8 v, bytes32 r, bytes32 s) = vm.sign(stakerKey, digest);
|
|
|
|
|
|
|
|
|
|
uint256[] memory empty;
|
|
|
|
|
vm.prank(staker);
|
|
|
|
|
uint256 positionId = stakingPool.permitAndSnatch(stakeAmount, staker, 1, empty, deadline, v, r, s);
|
|
|
|
|
|
|
|
|
|
(, address owner,,, uint32 taxRate) = stakingPool.positions(positionId);
|
|
|
|
|
assertEq(owner, staker, "Position owner should be staker");
|
|
|
|
|
assertEq(taxRate, 1, "Tax rate should be 1");
|
|
|
|
|
}
|
2024-06-13 08:28:42 +02:00
|
|
|
}
|