From 93ddd2897809c93257d1854161d4c207c4e03952 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 26 Feb 2026 03:59:20 +0000 Subject: [PATCH] fix: Test coverage: Stake.sol to 100% (#284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- onchain/test/Stake.t.sol | 254 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 253 insertions(+), 1 deletion(-) diff --git a/onchain/test/Stake.t.sol b/onchain/test/Stake.t.sol index d9aac0d..a327f5e 100644 --- a/onchain/test/Stake.t.sol +++ b/onchain/test/Stake.t.sol @@ -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"); + } }