SKIP TO CONTENT

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

  • SYNCING: ……

Invariants.t.sol

The protocol-wide invariants from the spec, fuzzed.

531 lines20.8 KBSolidity
Source of test/Invariants.t.sol, 531 lines of Solidity
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3 
4import {Base, IMirror} from "./Base.t.sol";
5import {K401} from "../src/K401.sol";
6import {K401Oracle} from "../src/K401Oracle.sol";
7import {K401Treasury} from "../src/K401Treasury.sol";
8import {K401StockDesk} from "../src/K401StockDesk.sol";
9import {K401Buyback} from "../src/K401Buyback.sol";
10import {K401Seat6551Account} from "../src/K401Seat6551.sol";
11import {Symbol, SeatMode} from "../src/interfaces/IK401Interfaces.sol";
12 
13/**
14 * @notice SPEC.md section 2 — the ten invariants, one test (or group) each.
15 */
16contract InvariantsTest is Base {
17 uint8 internal constant NVDA = uint8(Symbol.NVDA);
18 
19 /*//////////////////////////////////////////////////////////////
20 1. rfvPerToken() >= 1e18 after every possible operation
21 //////////////////////////////////////////////////////////////*/
22 
23 function test_INV1_rfvPerTokenNeverBelowOne_acrossFullLifecycle() public {
24 assertGe(treasury.rfvPerToken(), 1e18, "genesis");
25 
26 _primeOracle();
27 assertGe(treasury.rfvPerToken(), 1e18, "after oracle");
28 
29 _giveSeats(alice, 20);
30 assertGe(treasury.rfvPerToken(), 1e18, "after distribution");
31 
32 // clock in
33 uint256[] memory ids = k401.seatsOf(alice);
34 vm.prank(alice);
35 staking.clockIn(_ids(ids[0], ids[1], ids[2]));
36 assertGe(treasury.rfvPerToken(), 1e18, "after clockIn");
37 
38 // rebase (mints new supply)
39 _skip(8 hours + 1);
40 pair.sync();
41 oracle.checkpoint();
42 vm.prank(keeper);
43 distributor.rebase();
44 assertGe(treasury.rfvPerToken(), 1e18, "after rebase");
45 
46 // bond (adds reserve, mints against it)
47 vm.prank(owner);
48 uint256 mid = bonds.createMarket(address(usdg), false, 500, 100);
49 usdg.mint(bob, 15_000e6);
50 vm.startPrank(bob);
51 usdg.approve(address(bonds), type(uint256).max);
52 bonds.bond(mid, 15_000e6);
53 vm.stopPrank();
54 assertGe(treasury.rfvPerToken(), 1e18, "after bond");
55 
56 // buyback (burns supply, pays from its own USDG)
57 usdg.mint(address(buyback), 100_000e6);
58 _giveSeats(carol, 10);
59 vm.startPrank(carol);
60 k401.approve(address(buyback), type(uint256).max);
61 buyback.sell(5e18, 0);
62 vm.stopPrank();
63 assertGe(treasury.rfvPerToken(), 1e18, "after buyback");
64 
65 // pair trade (fee accrual + splitter distribution)
66 _tradeIntoPair(bob, 1_000e18);
67 splitter.distribute(0);
68 assertGe(treasury.rfvPerToken(), 1e18, "after fee distribution");
69 
70 // fuse + upgrade (burn 401K)
71 uint256[] memory carolIds = k401.seatsOf(carol);
72 vm.prank(carol);
73 registry.fuse(_ids(carolIds[0], carolIds[1], carolIds[2]));
74 assertGe(treasury.rfvPerToken(), 1e18, "after fuse");
75 }
76 
77 /*//////////////////////////////////////////////////////////////
78 2. Distributor mints 0 when twap <= nav()
79 //////////////////////////////////////////////////////////////*/
80 
81 function test_INV2_zeroEmissionAtOrBelowBacking() public {
82 _primeOracle();
83 _giveSeats(alice, 10);
84 uint256[] memory ids = k401.seatsOf(alice);
85 vm.prank(alice);
86 staking.clockIn(_ids(ids[0], ids[1]));
87 
88 // Push NAV to exactly the market price: twap ~= 2.0, so fund to 2.0 per token.
89 uint256 supply = k401.totalSupply();
90 uint256 needWad = (oracle.consult() * supply) / 1e18;
91 uint256 haveWad = treasury.reserveValueWad();
92 usdg.mint(address(treasury), (needWad - haveWad) / 1e12);
93 
94 assertLe(oracle.consult(), treasury.nav(), "twap must be <= nav for this case");
95 
96 _skip(8 hours + 1);
97 pair.sync();
98 oracle.checkpoint();
99 
100 uint256 supplyBefore = k401.totalSupply();
101 uint256 indexBefore = staking.index();
102 vm.prank(keeper);
103 (uint256 minted,) = distributor.rebase();
104 
105 assertEq(minted, 0, "no emission at or below backing");
106 assertEq(k401.totalSupply(), supplyBefore, "supply unchanged");
107 assertEq(staking.index(), indexBefore, "index unchanged");
108 assertEq(distributor.lastRateWad(), 0, "rate is exactly zero");
109 }
110 
111 function test_INV2_rateIsExactlyZeroAtParity() public view {
112 assertEq(distributor.rateFor(1e18), 0);
113 assertEq(distributor.rateFor(1e18 - 1), 0);
114 assertEq(distributor.rateFor(0), 0);
115 }
116 
117 /*//////////////////////////////////////////////////////////////
118 3. Distributor never mints past RFV coverage
119 //////////////////////////////////////////////////////////////*/
120 
121 function test_INV3_mintClampedByRfvHeadroom() public {
122 _primeOracle();
123 _giveSeats(alice, 100);
124 uint256[] memory ids = k401.seatsOf(alice);
125 uint256[] memory some = new uint256[](50);
126 for (uint256 i; i < 50; ++i) {
127 some[i] = ids[i];
128 }
129 vm.prank(alice);
130 staking.clockIn(some);
131 
132 // Drain the headroom to a token dust amount by "spending" reserve down to supply.
133 uint256 headroom = treasury.maxMintable();
134 assertGt(headroom, 0);
135 vm.prank(owner);
136 treasury.addReserveManager(owner);
137 // Leave exactly 10e18 of headroom.
138 uint256 pullWad = headroom - 10e18;
139 vm.prank(owner);
140 treasury.manage(address(usdg), pullWad / 1e12, owner);
141 assertApproxEqAbs(treasury.maxMintable(), 10e18, 1e12);
142 
143 // Unclamped emission would be 45bps of 1,000,000 = 4,500 tokens.
144 (,, uint256 previewMint,) = distributor.preview();
145 assertLe(previewMint, treasury.maxMintable() + 1);
146 
147 _skip(8 hours + 1);
148 pair.sync();
149 oracle.checkpoint();
150 vm.prank(keeper);
151 (uint256 minted,) = distributor.rebase();
152 
153 assertLt(minted, 4_500e18, "unclamped emission would have been much larger");
154 assertGe(treasury.rfvPerToken(), 1e18, "floor survives the clamped mint");
155 }
156 
157 /*//////////////////////////////////////////////////////////////
158 4. Bond price never below nav()
159 //////////////////////////////////////////////////////////////*/
160 
161 function test_INV4_bondPriceFlooredAtNav() public {
162 _primeOracle();
163 vm.prank(owner);
164 uint256 mid = bonds.createMarket(address(usdg), false, 2_000, 100); // 20% discount
165 
166 // twap 2.0, 20% off = 1.6; nav = 1.2 -> discounted wins.
167 assertEq(bonds.bondPrice(mid), (oracle.consult() * 8_000) / 10_000);
168 assertGe(bonds.bondPrice(mid), treasury.nav());
169 
170 // Now push NAV above the discounted price: floor must bind.
171 usdg.mint(address(treasury), 700_000e6); // nav -> 1.9
172 assertGt(treasury.nav(), (oracle.consult() * 8_000) / 10_000);
173 assertEq(bonds.bondPrice(mid), treasury.nav(), "floored at NAV");
174 assertGe(bonds.bondPrice(mid), treasury.nav());
175 }
176 
177 function testFuzz_INV4_bondPriceNeverBelowNav(uint96 extraUsdg, uint16 discountBps) public {
178 discountBps = uint16(bound(discountBps, 0, bonds.MAX_DISCOUNT_BPS()));
179 _primeOracle();
180 vm.prank(owner);
181 uint256 mid = bonds.createMarket(address(usdg), false, discountBps, 100);
182 usdg.mint(address(treasury), bound(extraUsdg, 0, 5_000_000e6));
183 assertGe(bonds.bondPrice(mid), treasury.nav());
184 }
185 
186 /*//////////////////////////////////////////////////////////////
187 5. Liquid ERC20 balances byte-identical before/after rebase()
188 //////////////////////////////////////////////////////////////*/
189 
190 function test_INV5_liquidBalancesAreByteIdenticalAcrossRebase() public {
191 _primeOracle();
192 _giveSeats(alice, 40);
193 _giveSeats(bob, 25);
194 _giveSeats(carol, 7);
195 
196 uint256[] memory aIds = k401.seatsOf(alice);
197 vm.prank(alice);
198 staking.clockIn(_ids(aIds[0], aIds[1], aIds[2]));
199 uint256[] memory bIds = k401.seatsOf(bob);
200 vm.prank(bob);
201 staking.clockIn(_ids(bIds[0]));
202 
203 address[6] memory watched =
204 [alice, bob, carol, address(pair), address(treasury), address(splitter)];
205 
206 uint256[6] memory balBefore;
207 uint256[6] memory nftBefore;
208 for (uint256 i; i < 6; ++i) {
209 balBefore[i] = k401.balanceOf(watched[i]);
210 nftBefore[i] = k401.seatBalanceOf(watched[i]);
211 }
212 uint256 seatsBefore = k401.totalSeats();
213 
214 _skip(8 hours + 1);
215 pair.sync();
216 oracle.checkpoint();
217 vm.prank(keeper);
218 (uint256 minted,) = distributor.rebase();
219 assertGt(minted, 0, "the test is meaningless without a live emission");
220 
221 for (uint256 i; i < 6; ++i) {
222 assertEq(k401.balanceOf(watched[i]), balBefore[i], "liquid ERC20 balance moved");
223 assertEq(k401.seatBalanceOf(watched[i]), nftBefore[i], "NFT count moved");
224 }
225 // The ONLY Seats created are the caller's 0.5% gas rebate; no holder's NFTs moved.
226 assertEq(
227 k401.totalSeats(), seatsBefore + k401.seatBalanceOf(keeper), "rebase minted NFTs to someone other than the caller"
228 );
229 
230 // The emission exists, it just lives in the staking contract as an index.
231 assertGt(staking.index(), 1e18);
232 assertGt(k401.balanceOf(address(staking)), 0);
233 assertGt(staking.pending(alice), 0);
234 
235 // ... and is only realised when the user pays for it themselves.
236 uint256 aliceBefore = k401.balanceOf(alice);
237 _skip(1); // accrue nothing; just to separate the calls
238 if (staking.pending(alice) >= 1e18) {
239 vm.prank(alice);
240 staking.materialize();
241 assertGt(k401.balanceOf(alice), aliceBefore);
242 }
243 }
244 
245 /*//////////////////////////////////////////////////////////////
246 6. NFT count == floor(sum of whole liquid balances) at all times
247 //////////////////////////////////////////////////////////////*/
248 
249 function test_INV6_nftCountTracksWholeBalances() public {
250 address[3] memory holders = [alice, bob, carol];
251 
252 _giveSeats(alice, 13);
253 _giveSeats(bob, 6);
254 _checkSeatAccounting(holders);
255 
256 vm.prank(alice);
257 k401.transfer(bob, 4.5e18);
258 _checkSeatAccounting(holders);
259 
260 vm.prank(bob);
261 k401.transfer(carol, 10.25e18);
262 _checkSeatAccounting(holders);
263 
264 vm.prank(carol);
265 k401.transfer(alice, 0.75e18);
266 _checkSeatAccounting(holders);
267 
268 _tradeIntoPair(alice, 3e18);
269 _checkSeatAccounting(holders);
270 }
271 
272 function _checkSeatAccounting(address[3] memory holders) internal view {
273 uint256 sum;
274 for (uint256 i; i < holders.length; ++i) {
275 // Non skip-NFT holders always hold exactly floor(balance) Seats.
276 assertEq(
277 k401.seatBalanceOf(holders[i]), k401.balanceOf(holders[i]) / 1e18, "holder seat count != floor(balance)"
278 );
279 sum += k401.seatBalanceOf(holders[i]);
280 }
281 // Everything else in this fixture (pair, protocol modules, owner) skips NFTs.
282 assertEq(k401.totalSeats(), sum, "total NFT supply != sum of holder Seats");
283 }
284 
285 /*//////////////////////////////////////////////////////////////
286 7. Tier and TBA contents survive an NFT transfer
287 //////////////////////////////////////////////////////////////*/
288 
289 function test_INV7_tierAndTbaContentsSurviveTransfer() public {
290 _primeOracle();
291 _fundNoSeats(alice, 200_000e18);
292 uint256[] memory ids = _giveSeatNfts(alice, 1);
293 uint256 id = ids[0];
294 
295 // Raise the tier twice: 1 -> 3.
296 vm.startPrank(alice);
297 registry.upgradeTier(id);
298 registry.upgradeTier(id);
299 vm.stopPrank();
300 assertEq(registry.tierOf(id), 3);
301 assertEq(registry.multiplierOf(id), 1.60e18);
302 
303 // Buy equity and deliver it into the Seat's TBA.
304 usdg.mint(address(stockDesk), 10_000e6);
305 stockDesk.executeBatch(NVDA, 10_000e6, 0);
306 address tba = stockDesk.deliver(id, _syms(NVDA));
307 uint256 held = nvda.balanceOf(tba);
308 assertGt(held, 0, "TBA received equity");
309 assertGt(registry.lifetimeStockUsd(id), 0);
310 
311 // Transfer the NFT.
312 vm.prank(alice);
313 mirror.transferFrom(alice, bob, id);
314 
315 assertEq(mirror.ownerOf(id), bob);
316 assertEq(registry.tierOf(id), 3, "TIER MUST PERSIST THROUGH TRANSFER");
317 assertEq(registry.multiplierOf(id), 1.60e18);
318 assertEq(stockDesk.equityToken(NVDA), address(nvda));
319 assertEq(nvda.balanceOf(tba), held, "TBA contents moved with the NFT");
320 assertEq(seat6551.tbaOf(id), tba, "TBA address is bound to the tokenId, not the owner");
321 
322 // And the new owner — not the old one — controls it, with zero fee.
323 vm.prank(alice);
324 vm.expectRevert(K401Seat6551Account.NotAccountOwner.selector);
325 K401Seat6551Account(payable(tba)).withdrawToken(address(nvda), alice, held);
326 
327 vm.prank(bob);
328 K401Seat6551Account(payable(tba)).withdrawToken(address(nvda), bob, held);
329 assertEq(nvda.balanceOf(bob), held, "zero protocol fee on TBA withdrawal");
330 }
331 
332 /*//////////////////////////////////////////////////////////////
333 8. Stale oracle (>4h or <30min) reverts every consumer
334 //////////////////////////////////////////////////////////////*/
335 
336 function test_INV8_freshDeployHasNoValidWindow_allConsumersRevert() public {
337 // Less than one 30 minute window has elapsed: nothing is valid yet.
338 assertFalse(oracle.isValid());
339 _assertAllConsumersRevertOnStaleOracle();
340 }
341 
342 /// @dev The "< 30 min" half of invariant 8: no sub-window can ever be committed,
343 /// so no consumer can ever be handed one.
344 function test_INV8_checkpointRefusesSubThirtyMinuteWindow() public {
345 _skip(29 minutes);
346 pair.sync();
347 vm.expectRevert(K401Oracle.PeriodTooShort.selector);
348 oracle.checkpoint();
349 assertFalse(oracle.isValid());
350 
351 _skip(2 minutes);
352 pair.sync();
353 oracle.checkpoint();
354 assertTrue(oracle.isValid());
355 assertGe(oracle.twapPeriod(), oracle.MIN_PERIOD());
356 
357 // Immediately re-checkpointing is refused too.
358 vm.expectRevert(K401Oracle.PeriodTooShort.selector);
359 oracle.checkpoint();
360 }
361 
362 function test_INV8_olderThanFourHours_allConsumersRevert() public {
363 _primeOracle();
364 assertTrue(oracle.isValid());
365 
366 _skip(4 hours + 1);
367 assertFalse(oracle.isValid(), "observation older than MAX_AGE");
368 _assertAllConsumersRevertOnStaleOracle();
369 
370 // A fresh checkpoint brings every consumer back online.
371 pair.sync();
372 oracle.checkpoint();
373 assertTrue(oracle.isValid());
374 oracle.consult();
375 }
376 
377 function _assertAllConsumersRevertOnStaleOracle() internal {
378 bytes4 stale = K401Oracle.StaleOracle.selector;
379 
380 // Distributor
381 _skip(8 hours + 1);
382 vm.expectRevert(stale);
383 distributor.rebase();
384 
385 // BondDepository
386 vm.prank(owner);
387 uint256 mid = bonds.createMarket(address(usdg), false, 500, 100);
388 vm.expectRevert(stale);
389 bonds.bondPrice(mid);
390 usdg.mint(bob, 1_000e6);
391 vm.startPrank(bob);
392 usdg.approve(address(bonds), type(uint256).max);
393 vm.expectRevert(stale);
394 bonds.bond(mid, 1_000e6);
395 vm.stopPrank();
396 
397 // Buyback
398 _giveSeats(carol, 2);
399 usdg.mint(address(buyback), 10_000e6);
400 vm.startPrank(carol);
401 k401.approve(address(buyback), type(uint256).max);
402 vm.expectRevert(K401Buyback.StaleOracle.selector);
403 buyback.sell(1e18, 0);
404 vm.stopPrank();
405 
406 // StockDesk
407 usdg.mint(address(stockDesk), 10_000e6);
408 vm.expectRevert(K401StockDesk.StaleOracle.selector);
409 stockDesk.executeBatch(NVDA, 10_000e6, 0);
410 }
411 
412 /*//////////////////////////////////////////////////////////////
413 9. Fee: 0 wallet<->wallet, 5% on pair transfers, 0 on whitelisted
414 //////////////////////////////////////////////////////////////*/
415 
416 function test_INV9_feeMatrix() public {
417 _giveSeats(alice, 100);
418 
419 // wallet -> wallet : zero
420 uint256 splitterBefore = k401.balanceOf(address(splitter));
421 vm.prank(alice);
422 k401.transfer(bob, 10e18);
423 assertEq(k401.balanceOf(bob), 10e18, "no fee wallet to wallet");
424 assertEq(k401.balanceOf(address(splitter)), splitterBefore, "splitter untouched");
425 
426 // wallet -> pair : 5%
427 uint256 pairBefore = k401.balanceOf(address(pair));
428 vm.prank(alice);
429 k401.transfer(address(pair), 20e18);
430 assertEq(k401.balanceOf(address(pair)) - pairBefore, 19e18, "95% reaches the pair");
431 assertEq(k401.balanceOf(address(splitter)) - splitterBefore, 1e18, "5% to the splitter");
432 
433 // pair -> wallet : 5%
434 splitterBefore = k401.balanceOf(address(splitter));
435 vm.prank(address(pair));
436 k401.transfer(carol, 20e18);
437 assertEq(k401.balanceOf(carol), 19e18, "buyer receives 95%");
438 assertEq(k401.balanceOf(address(splitter)) - splitterBefore, 1e18);
439 
440 // whitelisted -> pair : zero (protocol operations are exempt)
441 splitterBefore = k401.balanceOf(address(splitter));
442 pairBefore = k401.balanceOf(address(pair));
443 vm.prank(owner);
444 k401.transfer(address(pair), 50e18);
445 assertEq(k401.balanceOf(address(pair)) - pairBefore, 50e18, "whitelisted sender pays no fee");
446 assertEq(k401.balanceOf(address(splitter)), splitterBefore);
447 
448 // pair -> whitelisted : zero
449 splitterBefore = k401.balanceOf(address(splitter));
450 uint256 stakingBefore = k401.balanceOf(address(staking));
451 vm.prank(address(pair));
452 k401.transfer(address(staking), 10e18);
453 assertEq(k401.balanceOf(address(staking)) - stakingBefore, 10e18);
454 assertEq(k401.balanceOf(address(splitter)), splitterBefore);
455 }
456 
457 function testFuzz_INV9_feeIsExactlyFiveHundredBps(uint256 amount) public {
458 amount = bound(amount, 1e12, 400e18);
459 _giveSeats(alice, 401);
460 uint256 splitterBefore = k401.balanceOf(address(splitter));
461 uint256 pairBefore = k401.balanceOf(address(pair));
462 
463 vm.prank(alice);
464 k401.transfer(address(pair), amount);
465 
466 uint256 fee = (amount * 500) / 10_000;
467 assertEq(k401.balanceOf(address(splitter)) - splitterBefore, fee);
468 assertEq(k401.balanceOf(address(pair)) - pairBefore, amount - fee);
469 }
470 
471 function testFuzz_INV9_noFeeWalletToWallet(uint256 amount) public {
472 amount = bound(amount, 1, 200e18);
473 _giveSeats(alice, 201);
474 vm.prank(alice);
475 k401.transfer(bob, amount);
476 assertEq(k401.balanceOf(bob), amount);
477 assertEq(k401.balanceOf(address(splitter)), 0);
478 }
479 
480 /*//////////////////////////////////////////////////////////////
481 10. fuse() strictly reduces the supply of Seats
482 //////////////////////////////////////////////////////////////*/
483 
484 function test_INV10_fuseStrictlyReducesSeatSupply() public {
485 _giveSeats(alice, 9);
486 uint256[] memory ids = k401.seatsOf(alice);
487 
488 uint256 seatsBefore = k401.totalSeats();
489 uint256 supplyBefore = k401.totalSupply();
490 uint256 vestedMulBefore = registry.totalVestedMultiplier();
491 
492 vm.prank(alice);
493 (uint256 kept, uint8 newTier) = registry.fuse(_ids(ids[0], ids[1], ids[2]));
494 
495 assertEq(kept, ids[0]);
496 assertEq(newTier, 2);
497 assertEq(registry.tierOf(ids[0]), 2);
498 assertEq(k401.totalSeats(), seatsBefore - 2, "3 Seats in, 1 Seat out");
499 assertEq(k401.seatBalanceOf(alice), 7);
500 assertEq(k401.totalSupply(), supplyBefore - 2e18, "the 401K is burned, not moved");
501 assertEq(registry.tierOf(ids[1]), 0, "consumed Seat cleared");
502 assertEq(registry.tierOf(ids[2]), 0, "consumed Seat cleared");
503 // 9 x 1.00 -> 6 x 1.00 + 1 x 1.25
504 assertEq(registry.totalVestedMultiplier(), vestedMulBefore - 3e18 + 1.25e18);
505 
506 // Repeat: still strictly deflationary in Seats.
507 uint256[] memory ids2 = k401.seatsOf(alice);
508 uint256 seats2 = k401.totalSeats();
509 // ids2[0] is now tier 2; fuse three of the remaining tier-1 Seats.
510 vm.prank(alice);
511 registry.fuse(_ids(ids2[1], ids2[2], ids2[3]));
512 assertLt(k401.totalSeats(), seats2);
513 }
514 
515 /*//////////////////////////////////////////////////////////////
516 HELPERS
517 //////////////////////////////////////////////////////////////*/
518 
519 /// @dev Sells `amount` of 401K into the pair through the router (fee-bearing path).
520 function _tradeIntoPair(address who, uint256 amount) internal {
521 if (k401.balanceOf(who) < amount) _giveSeats(who, (amount / 1e18) + 2);
522 address[] memory path = new address[](2);
523 path[0] = address(k401);
524 path[1] = address(usdg);
525 vm.startPrank(who);
526 k401.approve(address(router), type(uint256).max);
527 router.swapExactTokensForTokens(amount, 0, path, who, _now() + 1);
528 vm.stopPrank();
529 }
530}
531 

Click any line number to deep-link to it — the target line highlights on load.