AuditPoC.t.sol
Proof-of-concept for audit findings. These are EXPECTED TO FAIL on the
161 lines6.6 KBSolidity
| 1 | // SPDX-License-Identifier: MIT |
| 2 | pragma solidity ^0.8.24; |
| 3 | |
| 4 | import {Base} from "./Base.t.sol"; |
| 5 | import {K401Staking} from "../src/K401Staking.sol"; |
| 6 | import {K401Seat6551Account} from "../src/K401Seat6551.sol"; |
| 7 | import {K401SeatRegistry} from "../src/K401SeatRegistry.sol"; |
| 8 | |
| 9 | /// @dev Proof-of-concept for audit findings. These are EXPECTED TO FAIL on the |
| 10 | /// current code — each one asserts the property that should hold. |
| 11 | contract AuditPoCTest is Base { |
| 12 | function _rebaseOnce() internal returns (uint256 minted) { |
| 13 | _skip(8 hours + 1); |
| 14 | pair.sync(); |
| 15 | oracle.checkpoint(); |
| 16 | vm.prank(keeper); |
| 17 | (minted,) = distributor.rebase(); |
| 18 | } |
| 19 | |
| 20 | /// @notice H-1: claims must never exceed the tokens the staking contract holds. |
| 21 | function test_PoC_stakingPoolStaysSolventAcrossTwoEpochs() public { |
| 22 | _primeOracle(); |
| 23 | _giveSeats(alice, 20); |
| 24 | _giveSeats(bob, 20); |
| 25 | uint256[] memory aIds = k401.seatsOf(alice); |
| 26 | uint256[] memory bIds = k401.seatsOf(bob); |
| 27 | |
| 28 | // Alice stakes and earns one epoch. She never touches the contract again, |
| 29 | // so her userIndex stays at her entry index. |
| 30 | vm.prank(alice); |
| 31 | staking.clockIn(_ids(aIds[0])); |
| 32 | _rebaseOnce(); |
| 33 | |
| 34 | // Bob joins between epochs. |
| 35 | vm.prank(bob); |
| 36 | staking.clockIn(_ids(bIds[0])); |
| 37 | |
| 38 | // Second epoch. |
| 39 | _rebaseOnce(); |
| 40 | |
| 41 | uint256 claims = staking.pending(alice) + staking.pending(bob); |
| 42 | uint256 held = k401.balanceOf(address(staking)); |
| 43 | |
| 44 | emit log_named_uint("alice pending", staking.pending(alice)); |
| 45 | emit log_named_uint("bob pending", staking.pending(bob)); |
| 46 | emit log_named_uint("total claims ", claims); |
| 47 | emit log_named_uint("tokens held ", held); |
| 48 | |
| 49 | assertLe(claims, held, "staking is insolvent: claims exceed tokens held"); |
| 50 | } |
| 51 | |
| 52 | /// @notice H-1b: the same user must earn the same amount whether or not they |
| 53 | /// poked the contract in between. Settling must not change entitlement. |
| 54 | function test_PoC_earningsAreNotPathDependent() public { |
| 55 | _primeOracle(); |
| 56 | |
| 57 | // --- Run A: alice never settles between the two epochs. |
| 58 | uint256 snap = vm.snapshotState(); |
| 59 | _giveSeats(alice, 20); |
| 60 | _giveSeats(bob, 20); |
| 61 | uint256[] memory aIds = k401.seatsOf(alice); |
| 62 | uint256[] memory bIds = k401.seatsOf(bob); |
| 63 | vm.prank(alice); |
| 64 | staking.clockIn(_ids(aIds[0])); |
| 65 | _rebaseOnce(); |
| 66 | vm.prank(bob); |
| 67 | staking.clockIn(_ids(bIds[0])); |
| 68 | _rebaseOnce(); |
| 69 | uint256 unsettled = staking.pending(alice); |
| 70 | |
| 71 | // --- Run B: identical, except alice clocks in a second Seat in between, |
| 72 | // which settles her position at the intermediate index. |
| 73 | vm.revertToState(snap); |
| 74 | _giveSeats(alice, 20); |
| 75 | _giveSeats(bob, 20); |
| 76 | aIds = k401.seatsOf(alice); |
| 77 | bIds = k401.seatsOf(bob); |
| 78 | vm.prank(alice); |
| 79 | staking.clockIn(_ids(aIds[0])); |
| 80 | _rebaseOnce(); |
| 81 | // Only difference: alice touches the contract in between, which settles her |
| 82 | // position. Clock a Seat in and straight back out so her principal is unchanged. |
| 83 | vm.prank(alice); |
| 84 | staking.clockIn(_ids(aIds[1])); |
| 85 | vm.prank(alice); |
| 86 | staking.clockOut(_ids(aIds[1])); // phase 1: principal back to one Seat |
| 87 | vm.prank(bob); |
| 88 | staking.clockIn(_ids(bIds[0])); |
| 89 | _rebaseOnce(); |
| 90 | uint256 settled = staking.pending(alice); |
| 91 | |
| 92 | emit log_named_uint("never settled", unsettled); |
| 93 | emit log_named_uint("settled once ", settled); |
| 94 | assertApproxEqRel(unsettled, settled, 0.01e18, "not settling pays more"); |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | /// @dev Separate contract so the TBA import stays local. |
| 99 | contract AuditPoCTbaTest is Base { |
| 100 | uint8 internal constant NVDA_ID = 0; |
| 101 | |
| 102 | /** |
| 103 | * @notice H-4: burning a Seat must sever its token bound account. |
| 104 | * @dev DN404 hands a burned tokenId back out once `nextTokenId` wraps `idLimit`, and |
| 105 | * a TBA resolves its controller through `ownerOf(tokenId)`. Before the fix the |
| 106 | * later holder of a recycled id inherited — and could drain — the previous |
| 107 | * Seat's equity. Forcing a real wrap needs `idLimit` mints, so this asserts the |
| 108 | * property that makes the wrap harmless: a new generation gets a new account, |
| 109 | * and the old account answers to nobody. |
| 110 | */ |
| 111 | function test_PoC_burnSeversTheOldTokenBoundAccount() public { |
| 112 | _primeOracle(); |
| 113 | _giveSeats(alice, 2); |
| 114 | uint256[] memory aIds = k401.seatsOf(alice); |
| 115 | uint256 victimId = aIds[aIds.length - 1]; // DN404 burns LIFO |
| 116 | |
| 117 | usdg.mint(address(stockDesk), 10_000e6); |
| 118 | stockDesk.executeBatch(NVDA_ID, 10_000e6, 0); |
| 119 | address oldTba = stockDesk.deliver(victimId, _syms(NVDA_ID)); |
| 120 | uint256 parked = nvda.balanceOf(oldTba); |
| 121 | assertGt(parked, 0, "seat holds equity"); |
| 122 | assertEq(K401Seat6551Account(payable(oldTba)).owner(), alice, "alice controls it while she holds the Seat"); |
| 123 | |
| 124 | uint32 genBefore = registry.generationOf(victimId); |
| 125 | |
| 126 | // Alice destroys the Seat by sending the whole token to a skip-NFT sink. |
| 127 | address sink = makeAddr("sink"); |
| 128 | vm.prank(sink); |
| 129 | k401.setSkipNFT(true); |
| 130 | vm.prank(alice); |
| 131 | k401.transfer(sink, 1e18); |
| 132 | assertFalse(k401.seatExists(victimId), "seat was burned"); |
| 133 | assertGt(registry.generationOf(victimId), genBefore, "generation bumped on destroy"); |
| 134 | |
| 135 | // 1. The id now points at a DIFFERENT, empty account. |
| 136 | assertTrue(seat6551.tbaOf(victimId) != oldTba, "recycled id gets a fresh account"); |
| 137 | assertEq(nvda.balanceOf(seat6551.tbaOf(victimId)), 0, "fresh account is empty"); |
| 138 | |
| 139 | // 2. The old account answers to nobody, so a later holder of the recycled id |
| 140 | // cannot reach it even if they find the address. |
| 141 | assertEq(K401Seat6551Account(payable(oldTba)).owner(), address(0), "old account is inert"); |
| 142 | vm.prank(carol); |
| 143 | vm.expectRevert(K401Seat6551Account.NotAccountOwner.selector); |
| 144 | K401Seat6551Account(payable(oldTba)).withdrawToken(address(nvda), carol, parked); |
| 145 | } |
| 146 | |
| 147 | /// @notice M-1/H-4: the registry refuses to burn a Seat that still holds value. |
| 148 | function test_PoC_cannotFuseAwayASeatThatStillHoldsStock() public { |
| 149 | _primeOracle(); |
| 150 | _giveSeats(alice, 3); |
| 151 | uint256[] memory ids = k401.seatsOf(alice); |
| 152 | |
| 153 | usdg.mint(address(stockDesk), 10_000e6); |
| 154 | stockDesk.executeBatch(NVDA_ID, 10_000e6, 0); |
| 155 | |
| 156 | vm.prank(alice); |
| 157 | vm.expectRevert(K401SeatRegistry.SeatHasUndeliveredStock.selector); |
| 158 | registry.fuse(_ids(ids[0], ids[1], ids[2])); |
| 159 | } |
| 160 | } |
| 161 |
Click any line number to deep-link to it — the target line highlights on load.