SKIP TO CONTENT

Demo build — mock protocol data. No chain, no ABIs yet.

  • SYNCING: ……

AuditPoC.t.sol

Proof-of-concept for audit findings. These are EXPECTED TO FAIL on the

161 lines6.6 KBSolidity
Source of test/AuditPoC.t.sol, 161 lines of Solidity
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3 
4import {Base} from "./Base.t.sol";
5import {K401Staking} from "../src/K401Staking.sol";
6import {K401Seat6551Account} from "../src/K401Seat6551.sol";
7import {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.
11contract 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.
99contract 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.