@rev-net/core-v6 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/REVNET_SECURITY_CHECKLIST.md +164 -0
  4. package/SECURITY.md +68 -0
  5. package/SKILLS.md +166 -0
  6. package/deployments/revnet-core-v5/arbitrum/REVDeployer.json +2821 -0
  7. package/deployments/revnet-core-v5/arbitrum/REVLoans.json +2260 -0
  8. package/deployments/revnet-core-v5/arbitrum_sepolia/REVDeployer.json +2821 -0
  9. package/deployments/revnet-core-v5/arbitrum_sepolia/REVLoans.json +2260 -0
  10. package/deployments/revnet-core-v5/base/REVDeployer.json +2825 -0
  11. package/deployments/revnet-core-v5/base/REVLoans.json +2264 -0
  12. package/deployments/revnet-core-v5/base_sepolia/REVDeployer.json +2825 -0
  13. package/deployments/revnet-core-v5/base_sepolia/REVLoans.json +2264 -0
  14. package/deployments/revnet-core-v5/ethereum/REVDeployer.json +2825 -0
  15. package/deployments/revnet-core-v5/ethereum/REVLoans.json +2264 -0
  16. package/deployments/revnet-core-v5/optimism/REVDeployer.json +2821 -0
  17. package/deployments/revnet-core-v5/optimism/REVLoans.json +2260 -0
  18. package/deployments/revnet-core-v5/optimism_sepolia/REVDeployer.json +2825 -0
  19. package/deployments/revnet-core-v5/optimism_sepolia/REVLoans.json +2264 -0
  20. package/deployments/revnet-core-v5/sepolia/REVDeployer.json +2825 -0
  21. package/deployments/revnet-core-v5/sepolia/REVLoans.json +2264 -0
  22. package/docs/book.css +13 -0
  23. package/docs/book.toml +13 -0
  24. package/docs/solidity.min.js +74 -0
  25. package/docs/src/README.md +88 -0
  26. package/docs/src/SUMMARY.md +20 -0
  27. package/docs/src/src/README.md +7 -0
  28. package/docs/src/src/REVDeployer.sol/contract.REVDeployer.md +968 -0
  29. package/docs/src/src/REVLoans.sol/contract.REVLoans.md +1047 -0
  30. package/docs/src/src/interfaces/IREVDeployer.sol/interface.IREVDeployer.md +243 -0
  31. package/docs/src/src/interfaces/IREVLoans.sol/interface.IREVLoans.md +296 -0
  32. package/docs/src/src/interfaces/README.md +5 -0
  33. package/docs/src/src/structs/README.md +14 -0
  34. package/docs/src/src/structs/REVAutoIssuance.sol/struct.REVAutoIssuance.md +19 -0
  35. package/docs/src/src/structs/REVBuybackHookConfig.sol/struct.REVBuybackHookConfig.md +19 -0
  36. package/docs/src/src/structs/REVBuybackPoolConfig.sol/struct.REVBuybackPoolConfig.md +21 -0
  37. package/docs/src/src/structs/REVConfig.sol/struct.REVConfig.md +35 -0
  38. package/docs/src/src/structs/REVCroptopAllowedPost.sol/struct.REVCroptopAllowedPost.md +28 -0
  39. package/docs/src/src/structs/REVDeploy721TiersHookConfig.sol/struct.REVDeploy721TiersHookConfig.md +34 -0
  40. package/docs/src/src/structs/REVDescription.sol/struct.REVDescription.md +23 -0
  41. package/docs/src/src/structs/REVLoan.sol/struct.REVLoan.md +28 -0
  42. package/docs/src/src/structs/REVLoanSource.sol/struct.REVLoanSource.md +16 -0
  43. package/docs/src/src/structs/REVStageConfig.sol/struct.REVStageConfig.md +44 -0
  44. package/docs/src/src/structs/REVSuckerDeploymentConfig.sol/struct.REVSuckerDeploymentConfig.md +16 -0
  45. package/foundry.lock +11 -0
  46. package/foundry.toml +23 -0
  47. package/package.json +31 -0
  48. package/remappings.txt +1 -0
  49. package/script/Deploy.s.sol +350 -0
  50. package/script/helpers/RevnetCoreDeploymentLib.sol +72 -0
  51. package/slither-ci.config.json +10 -0
  52. package/sphinx.lock +507 -0
  53. package/src/REVDeployer.sol +1257 -0
  54. package/src/REVLoans.sol +1333 -0
  55. package/src/interfaces/IREVDeployer.sol +198 -0
  56. package/src/interfaces/IREVLoans.sol +241 -0
  57. package/src/structs/REVAutoIssuance.sol +11 -0
  58. package/src/structs/REVConfig.sol +17 -0
  59. package/src/structs/REVCroptopAllowedPost.sol +20 -0
  60. package/src/structs/REVDeploy721TiersHookConfig.sol +25 -0
  61. package/src/structs/REVDescription.sol +14 -0
  62. package/src/structs/REVLoan.sol +19 -0
  63. package/src/structs/REVLoanSource.sol +11 -0
  64. package/src/structs/REVStageConfig.sol +34 -0
  65. package/src/structs/REVSuckerDeploymentConfig.sol +11 -0
  66. package/test/REV.integrations.t.sol +420 -0
  67. package/test/REVAutoIssuanceFuzz.t.sol +276 -0
  68. package/test/REVDeployerAuditRegressions.t.sol +328 -0
  69. package/test/REVInvincibility.t.sol +1275 -0
  70. package/test/REVInvincibilityHandler.sol +357 -0
  71. package/test/REVLifecycle.t.sol +364 -0
  72. package/test/REVLoans.invariants.t.sol +642 -0
  73. package/test/REVLoansAttacks.t.sol +739 -0
  74. package/test/REVLoansAuditRegressions.t.sol +314 -0
  75. package/test/REVLoansFeeRecovery.t.sol +704 -0
  76. package/test/REVLoansSourced.t.sol +1732 -0
  77. package/test/REVLoansUnSourced.t.sol +331 -0
  78. package/test/TestPR09_ConversionDocumentation.t.sol +304 -0
  79. package/test/TestPR10_LiquidationBehavior.t.sol +340 -0
  80. package/test/TestPR11_LowFindings.t.sol +571 -0
  81. package/test/TestPR12_FlashLoanSurplus.t.sol +305 -0
  82. package/test/TestPR13_CrossSourceReallocation.t.sol +302 -0
  83. package/test/TestPR15_CashOutCallerValidation.t.sol +320 -0
  84. package/test/TestPR16_ZeroRepayment.t.sol +297 -0
  85. package/test/TestPR21_Uint112Overflow.t.sol +251 -0
  86. package/test/TestPR22_HookArrayOOB.t.sol +221 -0
  87. package/test/TestPR26_BurnHeldTokens.t.sol +331 -0
  88. package/test/TestPR27_CEIPattern.t.sol +448 -0
  89. package/test/TestPR29_SwapTerminalPermission.t.sol +206 -0
  90. package/test/TestPR32_MixedFixes.t.sol +529 -0
  91. package/test/helpers/MaliciousContracts.sol +233 -0
  92. package/test/mock/MockBuybackDataHook.sol +61 -0
@@ -0,0 +1,364 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.23;
3
+
4
+ import "forge-std/Test.sol";
5
+ import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
6
+ import /* {*} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
7
+ import /* {*} from */ "./../src/REVDeployer.sol";
8
+ import "@croptop/core-v6/src/CTPublisher.sol";
9
+ import {MockBuybackDataHook} from "./mock/MockBuybackDataHook.sol";
10
+
11
+ import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
12
+ import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
13
+ import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
14
+ import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
15
+ import "@bananapus/swap-terminal-v6/script/helpers/SwapTerminalDeploymentLib.sol";
16
+
17
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
18
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
19
+ import {REVLoans} from "../src/REVLoans.sol";
20
+ import {REVLoan} from "../src/structs/REVLoan.sol";
21
+ import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
22
+ import {REVDescription} from "../src/structs/REVDescription.sol";
23
+ import {IREVLoans} from "./../src/interfaces/IREVLoans.sol";
24
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
25
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
26
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
27
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
28
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
29
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
30
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
31
+
32
+ /// @notice Full revnet lifecycle E2E: deploy 3-stage -> pay -> advance stages -> cash out.
33
+ contract REVLifecycle_Local is TestBaseWorkflow, JBTest {
34
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
35
+ bytes32 ERC20_SALT = "REV_TOKEN";
36
+
37
+ REVDeployer REV_DEPLOYER;
38
+ JB721TiersHook EXAMPLE_HOOK;
39
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
40
+ IJB721TiersHookStore HOOK_STORE;
41
+ IJBAddressRegistry ADDRESS_REGISTRY;
42
+ IREVLoans LOANS_CONTRACT;
43
+ IJBSuckerRegistry SUCKER_REGISTRY;
44
+ CTPublisher PUBLISHER;
45
+ MockBuybackDataHook MOCK_BUYBACK;
46
+
47
+ uint256 FEE_PROJECT_ID;
48
+ uint256 REVNET_ID;
49
+
50
+ address USER1 = makeAddr("user1");
51
+ address USER2 = makeAddr("user2");
52
+
53
+ uint256 DECIMAL_MULTIPLIER = 10 ** 18;
54
+
55
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
56
+
57
+ function setUp() public override {
58
+ super.setUp();
59
+
60
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
61
+
62
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
63
+ HOOK_STORE = new JB721TiersHookStore();
64
+ EXAMPLE_HOOK = new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, multisig());
65
+ ADDRESS_REGISTRY = new JBAddressRegistry();
66
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
67
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
68
+ MOCK_BUYBACK = new MockBuybackDataHook();
69
+
70
+ LOANS_CONTRACT = new REVLoans({
71
+ controller: jbController(),
72
+ projects: jbProjects(),
73
+ revId: FEE_PROJECT_ID,
74
+ owner: address(this),
75
+ permit2: permit2(),
76
+ trustedForwarder: TRUSTED_FORWARDER
77
+ });
78
+
79
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
80
+ jbController(),
81
+ SUCKER_REGISTRY,
82
+ FEE_PROJECT_ID,
83
+ HOOK_DEPLOYER,
84
+ PUBLISHER,
85
+ IJBRulesetDataHook(address(MOCK_BUYBACK)),
86
+ address(LOANS_CONTRACT),
87
+ TRUSTED_FORWARDER
88
+ );
89
+
90
+ vm.prank(multisig());
91
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
92
+
93
+ // Deploy a 3-stage revnet
94
+ _deployThreeStageRevnet();
95
+
96
+ // Fund users
97
+ vm.deal(USER1, 100e18);
98
+ vm.deal(USER2, 100e18);
99
+ }
100
+
101
+ function _deployThreeStageRevnet() internal {
102
+ JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
103
+ accountingContextsToAccept[0] = JBAccountingContext({
104
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
105
+ });
106
+
107
+ JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
108
+ terminalConfigurations[0] =
109
+ JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
110
+
111
+ JBSplit[] memory splits = new JBSplit[](1);
112
+ splits[0].beneficiary = payable(multisig());
113
+ splits[0].percent = 10_000;
114
+
115
+ REVStageConfig[] memory stageConfigurations = new REVStageConfig[](3);
116
+
117
+ // Stage 0: High issuance, moderate cash out tax
118
+ stageConfigurations[0] = REVStageConfig({
119
+ startsAtOrAfter: uint40(block.timestamp),
120
+ autoIssuances: new REVAutoIssuance[](0),
121
+ splitPercent: 0,
122
+ splits: splits,
123
+ initialIssuance: uint112(1000 * DECIMAL_MULTIPLIER),
124
+ issuanceCutFrequency: 90 days,
125
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
126
+ cashOutTaxRate: 5000, // 50%
127
+ extraMetadata: 0
128
+ });
129
+
130
+ // Stage 1: Lower issuance (inherited with cut)
131
+ stageConfigurations[1] = REVStageConfig({
132
+ startsAtOrAfter: uint40(block.timestamp + 365 days),
133
+ autoIssuances: new REVAutoIssuance[](0),
134
+ splitPercent: 0,
135
+ splits: splits,
136
+ initialIssuance: 0, // inherit
137
+ issuanceCutFrequency: 180 days,
138
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
139
+ cashOutTaxRate: 3000, // 30%
140
+ extraMetadata: 0
141
+ });
142
+
143
+ // Stage 2: Final stage
144
+ stageConfigurations[2] = REVStageConfig({
145
+ startsAtOrAfter: uint40(block.timestamp + (2 * 365 days)),
146
+ autoIssuances: new REVAutoIssuance[](0),
147
+ splitPercent: 0,
148
+ splits: splits,
149
+ initialIssuance: 1,
150
+ issuanceCutFrequency: 0,
151
+ issuanceCutPercent: 0,
152
+ cashOutTaxRate: 500, // 5%
153
+ extraMetadata: 0
154
+ });
155
+
156
+ REVConfig memory revnetConfiguration = REVConfig({
157
+ description: REVDescription("Lifecycle", "LIFE", "ipfs://lifecycle", "LIFE_TOKEN"),
158
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
159
+ splitOperator: multisig(),
160
+ stageConfigurations: stageConfigurations
161
+ });
162
+
163
+ vm.prank(multisig());
164
+ REVNET_ID = REV_DEPLOYER.deployFor({
165
+ revnetId: FEE_PROJECT_ID,
166
+ configuration: revnetConfiguration,
167
+ terminalConfigurations: terminalConfigurations,
168
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
169
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("LIFECYCLE_TEST")
170
+ })
171
+ });
172
+ }
173
+
174
+ //*********************************************************************//
175
+ // --- Lifecycle Tests ---------------------------------------------- //
176
+ //*********************************************************************//
177
+
178
+ /// @notice Full lifecycle: deploy -> pay in stage 0 -> warp to stage 1 -> pay -> cash out
179
+ function test_fullLifecycle_threeStages() public {
180
+ // Stage 0: User1 pays 5 ETH
181
+ vm.prank(USER1);
182
+ uint256 tokens1 =
183
+ jbMultiTerminal().pay{value: 5e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 5e18, USER1, 0, "", "");
184
+ assertGt(tokens1, 0, "user1 should receive tokens in stage 0");
185
+
186
+ // Check total supply after first payment
187
+ uint256 totalSupply1 = jbTokens().totalSupplyOf(REVNET_ID);
188
+ assertGt(totalSupply1, 0, "total supply should be > 0");
189
+
190
+ // Warp to stage 1 (1 year later)
191
+ vm.warp(block.timestamp + 365 days);
192
+
193
+ // Stage 1: User2 pays 5 ETH (should get fewer tokens due to weight decay)
194
+ vm.prank(USER2);
195
+ uint256 tokens2 =
196
+ jbMultiTerminal().pay{value: 5e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 5e18, USER2, 0, "", "");
197
+ assertGt(tokens2, 0, "user2 should receive tokens in stage 1");
198
+
199
+ // Total supply increased
200
+ uint256 totalSupply2 = jbTokens().totalSupplyOf(REVNET_ID);
201
+ assertGt(totalSupply2, totalSupply1, "total supply should increase");
202
+
203
+ // Warp to stage 2 (2 years from start)
204
+ vm.warp(block.timestamp + 365 days);
205
+
206
+ // Stage 2: User1 cashes out some tokens
207
+ uint256 cashOutAmount = tokens1 / 2;
208
+ vm.prank(USER1);
209
+ uint256 reclaimed = jbMultiTerminal()
210
+ .cashOutTokensOf({
211
+ holder: USER1,
212
+ projectId: REVNET_ID,
213
+ cashOutCount: cashOutAmount,
214
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
215
+ minTokensReclaimed: 0,
216
+ beneficiary: payable(USER1),
217
+ metadata: ""
218
+ });
219
+ assertGt(reclaimed, 0, "should reclaim some ETH");
220
+
221
+ // Total supply should decrease after cash out
222
+ uint256 totalSupply3 = jbTokens().totalSupplyOf(REVNET_ID);
223
+ assertLt(totalSupply3, totalSupply2, "total supply should decrease after cash out");
224
+ }
225
+
226
+ /// @notice Payment in stage 0 gives more tokens than equivalent payment in stage 1.
227
+ function test_stageDecay_fewerTokensLater() public {
228
+ // Stage 0: Pay 1 ETH
229
+ vm.prank(USER1);
230
+ uint256 tokensStage0 =
231
+ jbMultiTerminal().pay{value: 1e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 1e18, USER1, 0, "", "");
232
+
233
+ // Warp to stage 1
234
+ vm.warp(block.timestamp + 365 days);
235
+
236
+ // Stage 1: Pay 1 ETH
237
+ vm.prank(USER2);
238
+ uint256 tokensStage1 =
239
+ jbMultiTerminal().pay{value: 1e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 1e18, USER2, 0, "", "");
240
+
241
+ // User1 should have received more tokens (earlier stage = higher issuance)
242
+ assertGt(tokensStage0, tokensStage1, "stage 0 payment should yield more tokens than stage 1");
243
+ }
244
+
245
+ /// @notice Cash out tax rate differs between stages.
246
+ function test_cashOutTax_changesBetweenStages() public {
247
+ // Pay in stage 0
248
+ vm.prank(USER1);
249
+ uint256 tokens =
250
+ jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, USER1, 0, "", "");
251
+
252
+ // Cash out half in stage 0 (50% tax)
253
+ uint256 halfTokens = tokens / 2;
254
+ uint256 user1BalBefore = USER1.balance;
255
+ vm.prank(USER1);
256
+ uint256 reclaimedStage0 = jbMultiTerminal()
257
+ .cashOutTokensOf({
258
+ holder: USER1,
259
+ projectId: REVNET_ID,
260
+ cashOutCount: halfTokens,
261
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
262
+ minTokensReclaimed: 0,
263
+ beneficiary: payable(USER1),
264
+ metadata: ""
265
+ });
266
+ assertGt(reclaimedStage0, 0, "should reclaim in stage 0");
267
+
268
+ // Cash out tax with 50% rate means you get less than proportional share
269
+ // (for the only holder with all tokens cashing out half, bonding curve applies)
270
+ assertLt(reclaimedStage0, 10e18 / 2, "50% tax should reduce reclaim below proportional share");
271
+ }
272
+
273
+ /// @notice Terminal balance conservation across lifecycle.
274
+ function test_balanceConservation() public {
275
+ uint256 payAmount = 10e18;
276
+
277
+ // Pay into revnet
278
+ vm.prank(USER1);
279
+ uint256 tokens =
280
+ jbMultiTerminal().pay{value: payAmount}(REVNET_ID, JBConstants.NATIVE_TOKEN, payAmount, USER1, 0, "", "");
281
+
282
+ // Record balance
283
+ uint256 terminalBalance =
284
+ jbTerminalStore().balanceOf(address(jbMultiTerminal()), REVNET_ID, JBConstants.NATIVE_TOKEN);
285
+ assertEq(terminalBalance, payAmount, "balance should equal payment");
286
+
287
+ // Cash out all tokens
288
+ vm.prank(USER1);
289
+ uint256 reclaimed = jbMultiTerminal()
290
+ .cashOutTokensOf({
291
+ holder: USER1,
292
+ projectId: REVNET_ID,
293
+ cashOutCount: tokens,
294
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
295
+ minTokensReclaimed: 0,
296
+ beneficiary: payable(USER1),
297
+ metadata: ""
298
+ });
299
+
300
+ // With 50% cash out tax and single holder, reclaiming full supply
301
+ // should return less than full amount (due to tax)
302
+ assertGt(reclaimed, 0, "should reclaim something");
303
+ assertLe(reclaimed, payAmount, "should not reclaim more than paid");
304
+
305
+ // Remaining balance should account for what was reclaimed
306
+ uint256 terminalBalanceAfter =
307
+ jbTerminalStore().balanceOf(address(jbMultiTerminal()), REVNET_ID, JBConstants.NATIVE_TOKEN);
308
+ assertEq(
309
+ terminalBalanceAfter + reclaimed, terminalBalance, "balance conservation: remaining + reclaimed = original"
310
+ );
311
+ }
312
+
313
+ /// @notice Multiple payers, early payer has advantage.
314
+ function test_earlyPayerAdvantage() public {
315
+ // User1 pays first
316
+ vm.prank(USER1);
317
+ uint256 tokens1 =
318
+ jbMultiTerminal().pay{value: 5e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 5e18, USER1, 0, "", "");
319
+
320
+ // User2 pays same amount later
321
+ vm.prank(USER2);
322
+ uint256 tokens2 =
323
+ jbMultiTerminal().pay{value: 5e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 5e18, USER2, 0, "", "");
324
+
325
+ // Both should receive same tokens (same stage, no decay within a cycle without issuanceCutFrequency)
326
+ assertEq(tokens1, tokens2, "same amount in same stage should yield same tokens");
327
+
328
+ // But when user1 cashes out, they get a smaller share because the surplus grew
329
+ // relative to their proportion of the total supply
330
+ vm.prank(USER1);
331
+ uint256 reclaimed1 = jbMultiTerminal()
332
+ .cashOutTokensOf({
333
+ holder: USER1,
334
+ projectId: REVNET_ID,
335
+ cashOutCount: tokens1,
336
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
337
+ minTokensReclaimed: 0,
338
+ beneficiary: payable(USER1),
339
+ metadata: ""
340
+ });
341
+
342
+ // Should reclaim proportional share (minus tax)
343
+ assertGt(reclaimed1, 0, "user1 should reclaim some ETH");
344
+ assertLt(reclaimed1, 5e18, "with tax, user1 gets less than they paid");
345
+ }
346
+
347
+ /// @notice Ruleset IDs match stage indices.
348
+ function test_rulesetProgression() public {
349
+ // Check stage 0 ruleset
350
+ JBRuleset memory ruleset0 = jbRulesets().currentOf(REVNET_ID);
351
+ assertGt(ruleset0.id, 0, "should have a valid ruleset");
352
+ assertEq(ruleset0.cycleNumber, 1, "first ruleset cycle should be 1");
353
+
354
+ // Warp to stage 1
355
+ vm.warp(block.timestamp + 365 days);
356
+ JBRuleset memory ruleset1 = jbRulesets().currentOf(REVNET_ID);
357
+ assertGe(ruleset1.cycleNumber, 1, "cycle should be >= 1");
358
+
359
+ // Warp to stage 2
360
+ vm.warp(block.timestamp + 365 days);
361
+ JBRuleset memory ruleset2 = jbRulesets().currentOf(REVNET_ID);
362
+ assertGe(ruleset2.cycleNumber, 1, "cycle should be >= 1");
363
+ }
364
+ }