Staking.t.sol
Clock in/out, index accrual, materialize() and the 24h cooldown.
234 lines8.0 KBSolidity
| 1 | // SPDX-License-Identifier: MIT |
| 2 | pragma solidity ^0.8.24; |
| 3 | |
| 4 | import {Base} from "./Base.t.sol"; |
| 5 | import {K401} from "../src/K401.sol"; |
| 6 | import {K401Staking} from "../src/K401Staking.sol"; |
| 7 | import {SeatMode} from "../src/interfaces/IK401Interfaces.sol"; |
| 8 | |
| 9 | contract StakingTest is Base { |
| 10 | function _rebaseOnce() internal returns (uint256 minted) { |
| 11 | _skip(8 hours + 1); |
| 12 | pair.sync(); |
| 13 | oracle.checkpoint(); |
| 14 | vm.prank(keeper); |
| 15 | (minted,) = distributor.rebase(); |
| 16 | } |
| 17 | |
| 18 | function test_clockInLocksTheSeatInPlace() public { |
| 19 | _giveSeats(alice, 3); |
| 20 | uint256[] memory ids = k401.seatsOf(alice); |
| 21 | |
| 22 | vm.prank(alice); |
| 23 | staking.clockIn(_ids(ids[0])); |
| 24 | |
| 25 | // The Seat did NOT move and was NOT burned. |
| 26 | assertEq(mirror.ownerOf(ids[0]), alice, "Seat stays in the wallet"); |
| 27 | assertEq(k401.balanceOf(alice), 3e18, "liquid balance untouched"); |
| 28 | assertEq(k401.seatBalanceOf(alice), 3); |
| 29 | assertTrue(k401.isSeatLocked(ids[0])); |
| 30 | assertEq(k401.lockedSeats(alice), 1); |
| 31 | assertEq(uint8(registry.modeOf(ids[0])), uint8(SeatMode.ON_THE_CLOCK)); |
| 32 | assertEq(staking.totalStaked(), 1e18); |
| 33 | } |
| 34 | |
| 35 | function test_lockedSeatCannotBeTransferred() public { |
| 36 | _giveSeats(alice, 3); |
| 37 | uint256[] memory ids = k401.seatsOf(alice); |
| 38 | vm.prank(alice); |
| 39 | staking.clockIn(_ids(ids[0])); |
| 40 | |
| 41 | vm.prank(alice); |
| 42 | vm.expectRevert(K401.SeatIsLocked.selector); |
| 43 | mirror.transferFrom(alice, bob, ids[0]); |
| 44 | } |
| 45 | |
| 46 | function test_lockedBalanceCannotBeMovedOutAsErc20() public { |
| 47 | _giveSeats(alice, 3); |
| 48 | uint256[] memory ids = k401.seatsOf(alice); |
| 49 | vm.prank(alice); |
| 50 | staking.clockIn(_ids(ids[0], ids[1])); |
| 51 | |
| 52 | // 2 Seats locked -> only 1e18 is movable. |
| 53 | vm.prank(alice); |
| 54 | k401.transfer(bob, 1e18); |
| 55 | assertEq(k401.balanceOf(alice), 2e18); |
| 56 | |
| 57 | vm.prank(alice); |
| 58 | vm.expectRevert(K401.LockedBalance.selector); |
| 59 | k401.transfer(bob, 1); |
| 60 | } |
| 61 | |
| 62 | /// @dev The pinning scheme must keep the *specific* locked ids alive, not just the count. |
| 63 | function test_lockedIdsSurviveLifoBurnOrdering() public { |
| 64 | _giveSeats(alice, 5); |
| 65 | uint256[] memory ids = k401.seatsOf(alice); |
| 66 | |
| 67 | // Lock the LAST id, which is the first one DN404's LIFO burn would reach. |
| 68 | vm.prank(alice); |
| 69 | staking.clockIn(_ids(ids[4])); |
| 70 | |
| 71 | vm.prank(alice); |
| 72 | k401.transfer(bob, 4e18); |
| 73 | |
| 74 | assertEq(k401.seatBalanceOf(alice), 1); |
| 75 | assertEq(mirror.ownerOf(ids[4]), alice, "the locked id specifically survived"); |
| 76 | assertTrue(k401.isSeatLocked(ids[4])); |
| 77 | } |
| 78 | |
| 79 | function test_clockOutIsTwoPhaseWithA24hCooldown() public { |
| 80 | _giveSeats(alice, 2); |
| 81 | uint256[] memory ids = k401.seatsOf(alice); |
| 82 | vm.prank(alice); |
| 83 | staking.clockIn(_ids(ids[0])); |
| 84 | |
| 85 | // phase 1: request |
| 86 | vm.prank(alice); |
| 87 | staking.clockOut(_ids(ids[0])); |
| 88 | assertEq(staking.totalStaked(), 0, "stops earning immediately"); |
| 89 | assertTrue(k401.isSeatLocked(ids[0]), "still locked during the cooldown"); |
| 90 | assertEq(staking.unlockAt(ids[0]), _now() + 24 hours); |
| 91 | |
| 92 | // too early |
| 93 | _skip(23 hours); |
| 94 | vm.prank(alice); |
| 95 | vm.expectRevert(K401Staking.CooldownActive.selector); |
| 96 | staking.clockOut(_ids(ids[0])); |
| 97 | |
| 98 | // phase 2: finalise |
| 99 | _skip(1 hours + 1); |
| 100 | vm.prank(alice); |
| 101 | staking.clockOut(_ids(ids[0])); |
| 102 | assertFalse(k401.isSeatLocked(ids[0])); |
| 103 | assertEq(uint8(registry.modeOf(ids[0])), uint8(SeatMode.VESTED)); |
| 104 | assertEq(staking.stakedCountOf(alice), 0); |
| 105 | |
| 106 | vm.prank(alice); |
| 107 | mirror.transferFrom(alice, bob, ids[0]); |
| 108 | assertEq(mirror.ownerOf(ids[0]), bob); |
| 109 | } |
| 110 | |
| 111 | function test_indexAccountingSplitsProRata() public { |
| 112 | _primeOracle(); |
| 113 | _giveSeats(alice, 30); |
| 114 | _giveSeats(bob, 10); |
| 115 | |
| 116 | uint256[] memory aIds = k401.seatsOf(alice); |
| 117 | uint256[] memory bIds = k401.seatsOf(bob); |
| 118 | uint256[] memory a3 = new uint256[](3); |
| 119 | for (uint256 i; i < 3; ++i) { |
| 120 | a3[i] = aIds[i]; |
| 121 | } |
| 122 | vm.prank(alice); |
| 123 | staking.clockIn(a3); |
| 124 | vm.prank(bob); |
| 125 | staking.clockIn(_ids(bIds[0])); |
| 126 | |
| 127 | uint256 minted = _rebaseOnce(); |
| 128 | uint256 toStaking = minted - (minted * 50) / 10_000; |
| 129 | |
| 130 | // alice has 3 of 4 staked Seats. |
| 131 | assertApproxEqRel(staking.pending(alice), (toStaking * 3) / 4, 0.0001e18); |
| 132 | assertApproxEqRel(staking.pending(bob), toStaking / 4, 0.0001e18); |
| 133 | assertGt(staking.index(), 1e18); |
| 134 | } |
| 135 | |
| 136 | function test_materializeMintsWholeSeatsAndCarriesTheRemainder() public { |
| 137 | _primeOracle(); |
| 138 | _giveSeats(alice, 200); |
| 139 | uint256[] memory ids = k401.seatsOf(alice); |
| 140 | uint256[] memory many = new uint256[](150); |
| 141 | for (uint256 i; i < 150; ++i) { |
| 142 | many[i] = ids[i]; |
| 143 | } |
| 144 | vm.prank(alice); |
| 145 | staking.clockIn(many); |
| 146 | |
| 147 | _rebaseOnce(); |
| 148 | |
| 149 | uint256 pendingBefore = staking.pending(alice); |
| 150 | assertGe(pendingBefore, 1e18, "need at least one whole Seat for this test"); |
| 151 | uint256 balBefore = k401.balanceOf(alice); |
| 152 | uint256 seatsBefore = k401.seatBalanceOf(alice); |
| 153 | |
| 154 | vm.prank(alice); |
| 155 | uint256 got = staking.materialize(); |
| 156 | |
| 157 | assertEq(got % 1e18, 0, "only whole Seats are materialised"); |
| 158 | assertEq(k401.balanceOf(alice), balBefore + got); |
| 159 | assertEq(k401.seatBalanceOf(alice), seatsBefore + got / 1e18); |
| 160 | assertEq(staking.pending(alice), pendingBefore - got, "remainder carries forward"); |
| 161 | assertLt(staking.pending(alice), 1e18); |
| 162 | } |
| 163 | |
| 164 | function test_materializeRevertsWithNothingWhole() public { |
| 165 | _giveSeats(alice, 1); |
| 166 | uint256[] memory ids = k401.seatsOf(alice); |
| 167 | vm.prank(alice); |
| 168 | staking.clockIn(_ids(ids[0])); |
| 169 | |
| 170 | vm.prank(alice); |
| 171 | vm.expectRevert(K401Staking.NothingToMaterialize.selector); |
| 172 | staking.materialize(); |
| 173 | } |
| 174 | |
| 175 | /// @dev A user who clocks in AFTER an emission must not retroactively earn any of it. |
| 176 | function test_noRetroactiveEarningsForLateStakers() public { |
| 177 | _primeOracle(); |
| 178 | _giveSeats(alice, 20); |
| 179 | _giveSeats(bob, 20); |
| 180 | uint256[] memory aIds = k401.seatsOf(alice); |
| 181 | uint256[] memory bIds = k401.seatsOf(bob); |
| 182 | |
| 183 | vm.prank(alice); |
| 184 | staking.clockIn(_ids(aIds[0])); |
| 185 | _rebaseOnce(); |
| 186 | uint256 alicePending = staking.pending(alice); |
| 187 | assertGt(alicePending, 0); |
| 188 | |
| 189 | vm.prank(bob); |
| 190 | staking.clockIn(_ids(bIds[0])); |
| 191 | assertEq(staking.pending(bob), 0, "no retroactive claim on past epochs"); |
| 192 | |
| 193 | // Alice's already-accrued amount is not diluted by bob joining. |
| 194 | assertEq(staking.pending(alice), alicePending); |
| 195 | } |
| 196 | |
| 197 | /// @dev First-depositor / rounding: the very first staker cannot inflate the index. |
| 198 | function test_firstDepositorCannotSkewTheIndex() public { |
| 199 | _primeOracle(); |
| 200 | assertEq(staking.index(), 1e18); |
| 201 | _giveSeats(alice, 5); |
| 202 | uint256[] memory ids = k401.seatsOf(alice); |
| 203 | vm.prank(alice); |
| 204 | staking.clockIn(_ids(ids[0])); |
| 205 | assertEq(staking.index(), 1e18, "clocking in does not move the index"); |
| 206 | assertEq(staking.pending(alice), 0); |
| 207 | |
| 208 | _rebaseOnce(); |
| 209 | // The sole staker earns the whole epoch and nothing more. |
| 210 | assertApproxEqAbs(staking.pending(alice), k401.balanceOf(address(staking)), 1e6); |
| 211 | } |
| 212 | |
| 213 | function test_notifyEmissionIsDistributorOnly() public { |
| 214 | vm.expectRevert(K401Staking.NotAuthorized.selector); |
| 215 | staking.notifyEmission(1e18); |
| 216 | } |
| 217 | |
| 218 | function test_lockSeatIsStakingOnly() public { |
| 219 | _giveSeats(alice, 1); |
| 220 | uint256[] memory ids = k401.seatsOf(alice); |
| 221 | vm.prank(alice); |
| 222 | vm.expectRevert(K401.NotAuthorized.selector); |
| 223 | k401.lockSeat(alice, ids[0]); |
| 224 | } |
| 225 | |
| 226 | function test_cannotClockInSomeoneElsesSeat() public { |
| 227 | _giveSeats(alice, 1); |
| 228 | uint256[] memory ids = k401.seatsOf(alice); |
| 229 | vm.prank(bob); |
| 230 | vm.expectRevert(K401Staking.NotSeatOwner.selector); |
| 231 | staking.clockIn(_ids(ids[0])); |
| 232 | } |
| 233 | } |
| 234 |
Click any line number to deep-link to it — the target line highlights on load.