fix: Test coverage: Stake.sol to 100% (#284)

Add 11 new targeted tests in Stake.t.sol to cover all reachable
uncovered branches and the untested permitAndSnatch() function:

- testRevert_TaxRateOutOfBounds_InSnatch: taxRate >= TAX_RATES.length in snatch()
- testRevert_PositionNotFound_NonLastInLoop: PositionNotFound inside the multi-position loop
- testRevert_TaxTooLow_NonLastInLoop: TaxTooLow inside the multi-position loop
- testSnatch_ExitLastPosition: _exitPosition() path for last snatched position
- testRevert_ExceededAvailableStake: no available stake, no positions provided
- testRevert_TooMuchSnatch_AvailableExceedsNeed: post-exit excess stake check
- testRevert_PositionNotFound_InChangeTax: changeTax() on non-existent position
- testRevert_TaxTooLow_InChangeTax: changeTax() with same/lower tax rate
- testRevert_NoPermission_InExitPosition: exitPosition() by non-owner
- testRevert_PositionNotFound_InPayTax: payTax() on non-existent position
- testPermitAndSnatch: EIP-712 permit + snatch in one transaction

Coverage achieved:
  Lines:     99.33% (148/149)
  Statements: 99.40% (167/168)
  Branches:  93.55% (29/31)  — 2 unreachable dead-code branches remain
  Functions: 100.00% (15/15)

The 2 uncovered branches are dead code: the require() failure in
_shrinkPosition (caller always guards sharesToTake < pos.share) and
the PositionNotFound guard in exitPosition() (unreachable because
owner and creationTime are always set/cleared together, so
pos.owner==msg.sender implies pos.creationTime!=0 for any live caller).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-02-26 03:59:20 +00:00
parent a7947c64c8
commit 93ddd28978

View file

@ -2,7 +2,7 @@
pragma solidity ^0.8.19;
import "../src/Kraiken.sol";
import { Stake, TooMuchSnatch } from "../src/Stake.sol";
import { ExceededAvailableStake, Stake, TooMuchSnatch } from "../src/Stake.sol";
import "./helpers/TestBase.sol";
import "forge-std/Test.sol";
import "forge-std/console.sol";
@ -418,4 +418,256 @@ contract StakeTest is TestConstants {
(uint256 share,,,,) = stakingPool.positions(positionId);
assertEq(share, 0, "Share should be zero after liquidation");
}
// 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");
}
}