@rev-net/core-v6 0.0.37 → 0.0.39

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 (107) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/README.md +6 -7
  3. package/foundry.toml +1 -1
  4. package/package.json +23 -16
  5. package/references/operations.md +1 -1
  6. package/references/runtime.md +1 -1
  7. package/script/Deploy.s.sol +12 -9
  8. package/src/REVDeployer.sol +60 -65
  9. package/src/REVHiddenTokens.sol +2 -2
  10. package/src/REVLoans.sol +17 -10
  11. package/src/REVOwner.sol +121 -14
  12. package/src/interfaces/IREVDeployer.sol +2 -1
  13. package/src/interfaces/IREVHiddenTokens.sol +4 -1
  14. package/src/interfaces/IREVOwner.sol +5 -0
  15. package/ADMINISTRATION.md +0 -73
  16. package/ARCHITECTURE.md +0 -116
  17. package/AUDIT_INSTRUCTIONS.md +0 -90
  18. package/RISKS.md +0 -107
  19. package/SKILLS.md +0 -46
  20. package/STYLE_GUIDE.md +0 -610
  21. package/USER_JOURNEYS.md +0 -195
  22. package/foundry.lock +0 -11
  23. package/slither-ci.config.json +0 -10
  24. package/sphinx.lock +0 -507
  25. package/test/REV.integrations.t.sol +0 -573
  26. package/test/REVAutoIssuanceFuzz.t.sol +0 -328
  27. package/test/REVDeployerRegressions.t.sol +0 -396
  28. package/test/REVInvincibility.t.sol +0 -1371
  29. package/test/REVInvincibilityHandler.sol +0 -387
  30. package/test/REVLifecycle.t.sol +0 -420
  31. package/test/REVLoans.invariants.t.sol +0 -724
  32. package/test/REVLoansAttacks.t.sol +0 -816
  33. package/test/REVLoansFeeRecovery.t.sol +0 -783
  34. package/test/REVLoansFindings.t.sol +0 -711
  35. package/test/REVLoansRegressions.t.sol +0 -364
  36. package/test/REVLoansSourceFeeRecovery.t.sol +0 -517
  37. package/test/REVLoansSourced.t.sol +0 -1839
  38. package/test/REVLoansUnSourced.t.sol +0 -409
  39. package/test/TestAuditFixVerification.t.sol +0 -675
  40. package/test/TestBurnHeldTokens.t.sol +0 -394
  41. package/test/TestCEIPattern.t.sol +0 -508
  42. package/test/TestCashOutCallerValidation.t.sol +0 -452
  43. package/test/TestConversionDocumentation.t.sol +0 -365
  44. package/test/TestCrossCurrencyReclaim.t.sol +0 -610
  45. package/test/TestCrossSourceReallocation.t.sol +0 -361
  46. package/test/TestERC2771MetaTx.t.sol +0 -585
  47. package/test/TestEmptyBuybackSpecs.t.sol +0 -300
  48. package/test/TestFlashLoanSurplus.t.sol +0 -365
  49. package/test/TestHiddenTokens.t.sol +0 -474
  50. package/test/TestHookArrayOOB.t.sol +0 -278
  51. package/test/TestLiquidationBehavior.t.sol +0 -398
  52. package/test/TestLoanSourceRotation.t.sol +0 -553
  53. package/test/TestLoansCashOutDelay.t.sol +0 -493
  54. package/test/TestLongTailEconomics.t.sol +0 -677
  55. package/test/TestLowFindings.t.sol +0 -677
  56. package/test/TestMixedFixes.t.sol +0 -593
  57. package/test/TestPermit2Signatures.t.sol +0 -683
  58. package/test/TestReallocationSandwich.t.sol +0 -412
  59. package/test/TestRevnetRegressions.t.sol +0 -350
  60. package/test/TestSplitWeightAdjustment.t.sol +0 -527
  61. package/test/TestSplitWeightE2E.t.sol +0 -605
  62. package/test/TestSplitWeightFork.t.sol +0 -855
  63. package/test/TestStageTransitionBorrowable.t.sol +0 -301
  64. package/test/TestSwapTerminalPermission.t.sol +0 -262
  65. package/test/TestTerminalEncodingInHash.t.sol +0 -326
  66. package/test/TestUint112Overflow.t.sol +0 -311
  67. package/test/TestZeroAmountLoanGuard.t.sol +0 -378
  68. package/test/TestZeroRepayment.t.sol +0 -354
  69. package/test/audit/CrossChainBuybackRouteMismatch.t.sol +0 -184
  70. package/test/audit/HiddenSupplyCashout.t.sol +0 -61
  71. package/test/audit/LoanIdOverflowGuard.t.sol +0 -523
  72. package/test/audit/NemesisVerification.t.sol +0 -97
  73. package/test/audit/OperatorDelegation.t.sol +0 -356
  74. package/test/audit/PhantomSurplusTerminal.t.sol +0 -367
  75. package/test/audit/REVOwnerCurrencyMismatch.t.sol +0 -188
  76. package/test/audit/REVOwnerRemoteSurplusCurrencyMismatch.t.sol +0 -140
  77. package/test/audit/ReallocatePermission.t.sol +0 -363
  78. package/test/audit/RemoteLoanAccountingGap.t.sol +0 -74
  79. package/test/audit/SupportsInterfaceTest.t.sol +0 -51
  80. package/test/audit/TestFeeAllowanceLeak.t.sol +0 -197
  81. package/test/audit/TestLoansAndDeployerFixes.t.sol +0 -576
  82. package/test/fork/ForkTestBase.sol +0 -727
  83. package/test/fork/TestAutoIssuanceFork.t.sol +0 -148
  84. package/test/fork/TestCashOutFork.t.sol +0 -253
  85. package/test/fork/TestIssuanceDecayFork.t.sol +0 -158
  86. package/test/fork/TestLoanAdversarialFork.t.sol +0 -744
  87. package/test/fork/TestLoanBorrowFork.t.sol +0 -163
  88. package/test/fork/TestLoanCrossRulesetFork.t.sol +0 -308
  89. package/test/fork/TestLoanERC20Fork.t.sol +0 -459
  90. package/test/fork/TestLoanLiquidationFork.t.sol +0 -135
  91. package/test/fork/TestLoanReallocateFork.t.sol +0 -113
  92. package/test/fork/TestLoanRepayFork.t.sol +0 -188
  93. package/test/fork/TestLoanTransferFork.t.sol +0 -143
  94. package/test/fork/TestPermit2PaymentFork.t.sol +0 -300
  95. package/test/fork/TestSplitWeightFork.t.sol +0 -189
  96. package/test/helpers/MaliciousContracts.sol +0 -247
  97. package/test/helpers/REVEmpty721Config.sol +0 -45
  98. package/test/mock/MockBuybackCashOutRecorder.sol +0 -84
  99. package/test/mock/MockBuybackDataHook.sol +0 -112
  100. package/test/mock/MockBuybackDataHookMintPath.sol +0 -68
  101. package/test/mock/MockSuckerRegistry.sol +0 -17
  102. package/test/regression/TestBurnPermissionRequired.t.sol +0 -294
  103. package/test/regression/TestCashOutBuybackFeeLeak.t.sol +0 -232
  104. package/test/regression/TestCrossRevnetLiquidation.t.sol +0 -255
  105. package/test/regression/TestCumulativeLoanCounter.t.sol +0 -361
  106. package/test/regression/TestLiquidateGapHandling.t.sol +0 -394
  107. package/test/regression/TestZeroPriceFeed.t.sol +0 -422
@@ -1,1371 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.28;
3
-
4
- // forge-lint: disable-next-line(unaliased-plain-import)
5
- import "forge-std/Test.sol";
6
- import {StdInvariant} from "forge-std/StdInvariant.sol";
7
- // forge-lint: disable-next-line(unaliased-plain-import)
8
- import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
9
- // import /* {*} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
10
- // forge-lint: disable-next-line(unaliased-plain-import)
11
- import /* {*} from */ "./../src/REVDeployer.sol";
12
- // forge-lint: disable-next-line(unaliased-plain-import)
13
- import /* {*} from */ "./../src/REVLoans.sol";
14
- // forge-lint: disable-next-line(unaliased-plain-import)
15
- import "@croptop/core-v6/src/CTPublisher.sol";
16
- import {MockBuybackDataHook} from "./mock/MockBuybackDataHook.sol";
17
-
18
- // forge-lint: disable-next-line(unaliased-plain-import)
19
- import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
20
- // forge-lint: disable-next-line(unaliased-plain-import)
21
- import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
22
- // forge-lint: disable-next-line(unaliased-plain-import)
23
- import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
24
- // forge-lint: disable-next-line(unaliased-plain-import)
25
- import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
26
- // forge-lint: disable-next-line(unaliased-plain-import)
27
- import "@bananapus/router-terminal-v6/script/helpers/RouterTerminalDeploymentLib.sol";
28
-
29
- import {JBCashOuts} from "@bananapus/core-v6/src/libraries/JBCashOuts.sol";
30
- import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
31
- import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
32
- import {MockERC20} from "@bananapus/core-v6/test/mock/MockERC20.sol";
33
- import {REVLoans} from "../src/REVLoans.sol";
34
- import {REVLoan} from "../src/structs/REVLoan.sol";
35
- import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
36
- import {REVLoanSource} from "../src/structs/REVLoanSource.sol";
37
- import {REVDescription} from "../src/structs/REVDescription.sol";
38
- import {IREVLoans} from "./../src/interfaces/IREVLoans.sol";
39
- import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
40
- import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
41
- import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
42
- import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
43
- import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
44
- import {JB721CheckpointsDeployer} from "@bananapus/721-hook-v6/src/JB721CheckpointsDeployer.sol";
45
- import {IJB721CheckpointsDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721CheckpointsDeployer.sol";
46
- import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
47
- import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
48
- import {mulDiv} from "@prb/math/src/Common.sol";
49
-
50
- import {REVInvincibilityHandler} from "./REVInvincibilityHandler.sol";
51
- import {BrokenFeeTerminal} from "./helpers/MaliciousContracts.sol";
52
- import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
53
- import {REVOwner} from "../src/REVOwner.sol";
54
- import {IREVDeployer} from "../src/interfaces/IREVDeployer.sol";
55
- import {MockSuckerRegistry} from "./mock/MockSuckerRegistry.sol";
56
-
57
- // =========================================================================
58
- // Shared config struct
59
- // =========================================================================
60
- struct InvincibilityProjectConfig {
61
- REVConfig configuration;
62
- JBTerminalConfig[] terminalConfigurations;
63
- REVSuckerDeploymentConfig suckerDeploymentConfiguration;
64
- }
65
-
66
- // =========================================================================
67
- // Section A + B: Property Verification & Economic Tests
68
- // =========================================================================
69
- contract REVInvincibility_PropertyTests is TestBaseWorkflow {
70
- using JBRulesetMetadataResolver for JBRuleset;
71
-
72
- // forge-lint: disable-next-line(mixed-case-variable)
73
- bytes32 REV_DEPLOYER_SALT = "REVDeployer";
74
-
75
- // forge-lint: disable-next-line(mixed-case-variable)
76
- REVDeployer REV_DEPLOYER;
77
- // forge-lint: disable-next-line(mixed-case-variable)
78
- JB721TiersHook EXAMPLE_HOOK;
79
- // forge-lint: disable-next-line(mixed-case-variable)
80
- IJB721TiersHookDeployer HOOK_DEPLOYER;
81
- // forge-lint: disable-next-line(mixed-case-variable)
82
- IJB721TiersHookStore HOOK_STORE;
83
- // forge-lint: disable-next-line(mixed-case-variable)
84
- IJBAddressRegistry ADDRESS_REGISTRY;
85
- // forge-lint: disable-next-line(mixed-case-variable)
86
- IREVLoans LOANS_CONTRACT;
87
- // forge-lint: disable-next-line(mixed-case-variable)
88
- MockERC20 TOKEN;
89
- // forge-lint: disable-next-line(mixed-case-variable)
90
- IJBSuckerRegistry SUCKER_REGISTRY;
91
- // forge-lint: disable-next-line(mixed-case-variable)
92
- CTPublisher PUBLISHER;
93
- // forge-lint: disable-next-line(mixed-case-variable)
94
- MockBuybackDataHook MOCK_BUYBACK;
95
- // forge-lint: disable-next-line(mixed-case-variable)
96
- REVOwner REV_OWNER;
97
-
98
- // forge-lint: disable-next-line(mixed-case-variable)
99
- uint256 FEE_PROJECT_ID;
100
- // forge-lint: disable-next-line(mixed-case-variable)
101
- uint256 REVNET_ID;
102
-
103
- // forge-lint: disable-next-line(mixed-case-variable)
104
- address USER = makeAddr("user");
105
- // forge-lint: disable-next-line(mixed-case-variable)
106
- address ATTACKER = makeAddr("attacker");
107
-
108
- address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
109
-
110
- // --- Setup helpers ---
111
-
112
- function _getFeeProjectConfig() internal view returns (InvincibilityProjectConfig memory) {
113
- JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
114
- accountingContextsToAccept[0] = JBAccountingContext({
115
- token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
116
- });
117
-
118
- JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
119
- terminalConfigurations[0] =
120
- JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
121
-
122
- JBSplit[] memory splits = new JBSplit[](1);
123
- splits[0].beneficiary = payable(multisig());
124
- splits[0].percent = 10_000;
125
-
126
- REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
127
- issuanceConfs[0] =
128
- REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(70_000e18), beneficiary: multisig()});
129
-
130
- REVStageConfig[] memory stageConfigurations = new REVStageConfig[](1);
131
- stageConfigurations[0] = REVStageConfig({
132
- startsAtOrAfter: uint40(block.timestamp),
133
- autoIssuances: issuanceConfs,
134
- splitPercent: 2000,
135
- splits: splits,
136
- initialIssuance: uint112(1000e18),
137
- issuanceCutFrequency: 90 days,
138
- issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
139
- cashOutTaxRate: 6000,
140
- extraMetadata: 0
141
- });
142
-
143
- return InvincibilityProjectConfig({
144
- configuration: REVConfig({
145
- description: REVDescription({
146
- name: "Revnet",
147
- ticker: "$REV",
148
- uri: "ipfs://QmNRHT91HcDgMcenebYX7rJigt77cgNcosvuhX21wkF3tx",
149
- salt: "REV_TOKEN"
150
- }),
151
- baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
152
- splitOperator: multisig(),
153
- stageConfigurations: stageConfigurations
154
- }),
155
- terminalConfigurations: terminalConfigurations,
156
- suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
157
- deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("REV"))
158
- })
159
- });
160
- }
161
-
162
- function _getRevnetConfig() internal view returns (InvincibilityProjectConfig memory) {
163
- JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
164
- accountingContextsToAccept[0] = JBAccountingContext({
165
- token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
166
- });
167
-
168
- JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
169
- terminalConfigurations[0] =
170
- JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
171
-
172
- JBSplit[] memory splits = new JBSplit[](1);
173
- splits[0].beneficiary = payable(multisig());
174
- splits[0].percent = 10_000;
175
-
176
- REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
177
- issuanceConfs[0] =
178
- REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(70_000e18), beneficiary: multisig()});
179
-
180
- REVStageConfig[] memory stageConfigurations = new REVStageConfig[](3);
181
- stageConfigurations[0] = REVStageConfig({
182
- startsAtOrAfter: uint40(block.timestamp),
183
- autoIssuances: issuanceConfs,
184
- splitPercent: 2000,
185
- splits: splits,
186
- initialIssuance: uint112(1000e18),
187
- issuanceCutFrequency: 90 days,
188
- issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
189
- cashOutTaxRate: 6000,
190
- extraMetadata: 0
191
- });
192
-
193
- stageConfigurations[1] = REVStageConfig({
194
- startsAtOrAfter: uint40(stageConfigurations[0].startsAtOrAfter + 365 days),
195
- autoIssuances: new REVAutoIssuance[](0),
196
- splitPercent: 2000,
197
- splits: splits,
198
- initialIssuance: 0,
199
- issuanceCutFrequency: 180 days,
200
- issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
201
- cashOutTaxRate: 1000,
202
- extraMetadata: 0
203
- });
204
-
205
- stageConfigurations[2] = REVStageConfig({
206
- startsAtOrAfter: uint40(stageConfigurations[1].startsAtOrAfter + (20 * 365 days)),
207
- autoIssuances: new REVAutoIssuance[](0),
208
- splitPercent: 0,
209
- splits: splits,
210
- initialIssuance: 1,
211
- issuanceCutFrequency: 0,
212
- issuanceCutPercent: 0,
213
- cashOutTaxRate: 500,
214
- extraMetadata: 0
215
- });
216
-
217
- return InvincibilityProjectConfig({
218
- configuration: REVConfig({
219
- // forge-lint: disable-next-line(named-struct-fields)
220
- description: REVDescription("NANA", "$NANA", "ipfs://nana", "NANA_TOKEN"),
221
- baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
222
- splitOperator: multisig(),
223
- stageConfigurations: stageConfigurations
224
- }),
225
- terminalConfigurations: terminalConfigurations,
226
- suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
227
- deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("NANA"))
228
- })
229
- });
230
- }
231
-
232
- function setUp() public override {
233
- super.setUp();
234
-
235
- FEE_PROJECT_ID = jbProjects().createFor(multisig());
236
-
237
- SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
238
- HOOK_STORE = new JB721TiersHookStore();
239
- EXAMPLE_HOOK = new JB721TiersHook(
240
- jbDirectory(),
241
- jbPermissions(),
242
- jbPrices(),
243
- jbRulesets(),
244
- HOOK_STORE,
245
- jbSplits(),
246
- IJB721CheckpointsDeployer(address(new JB721CheckpointsDeployer())),
247
- multisig()
248
- );
249
- ADDRESS_REGISTRY = new JBAddressRegistry();
250
- HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
251
- PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
252
- MOCK_BUYBACK = new MockBuybackDataHook();
253
- TOKEN = new MockERC20("1/2 ETH", "1/2");
254
-
255
- LOANS_CONTRACT = new REVLoans({
256
- controller: jbController(),
257
- suckerRegistry: IJBSuckerRegistry(address(new MockSuckerRegistry())),
258
- revId: FEE_PROJECT_ID,
259
- owner: address(this),
260
- permit2: permit2(),
261
- trustedForwarder: TRUSTED_FORWARDER
262
- });
263
-
264
- REV_OWNER = new REVOwner(
265
- IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
266
- jbDirectory(),
267
- FEE_PROJECT_ID,
268
- SUCKER_REGISTRY,
269
- address(LOANS_CONTRACT),
270
- address(0)
271
- );
272
-
273
- REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
274
- jbController(),
275
- SUCKER_REGISTRY,
276
- FEE_PROJECT_ID,
277
- HOOK_DEPLOYER,
278
- PUBLISHER,
279
- IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
280
- address(LOANS_CONTRACT),
281
- TRUSTED_FORWARDER,
282
- address(REV_OWNER)
283
- );
284
-
285
- REV_OWNER.setDeployer(REV_DEPLOYER);
286
-
287
- // Deploy fee project
288
- vm.prank(multisig());
289
- jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
290
-
291
- InvincibilityProjectConfig memory feeConfig = _getFeeProjectConfig();
292
- vm.prank(multisig());
293
- REV_DEPLOYER.deployFor({
294
- revnetId: FEE_PROJECT_ID,
295
- configuration: feeConfig.configuration,
296
- terminalConfigurations: feeConfig.terminalConfigurations,
297
- suckerDeploymentConfiguration: feeConfig.suckerDeploymentConfiguration,
298
- tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
299
- allowedPosts: REVEmpty721Config.emptyAllowedPosts()
300
- });
301
-
302
- // Deploy second revnet with loans
303
- InvincibilityProjectConfig memory revConfig = _getRevnetConfig();
304
- (REVNET_ID,) = REV_DEPLOYER.deployFor({
305
- revnetId: 0,
306
- configuration: revConfig.configuration,
307
- terminalConfigurations: revConfig.terminalConfigurations,
308
- suckerDeploymentConfiguration: revConfig.suckerDeploymentConfiguration,
309
- tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
310
- allowedPosts: REVEmpty721Config.emptyAllowedPosts()
311
- });
312
-
313
- vm.deal(USER, 10_000e18);
314
- vm.deal(ATTACKER, 10_000e18);
315
- }
316
-
317
- function _setupLoan(
318
- address user,
319
- uint256 ethAmount,
320
- uint256 prepaidFee
321
- )
322
- internal
323
- returns (uint256 loanId, uint256 tokenCount, uint256 borrowAmount)
324
- {
325
- vm.prank(user);
326
- tokenCount =
327
- jbMultiTerminal().pay{value: ethAmount}(REVNET_ID, JBConstants.NATIVE_TOKEN, ethAmount, user, 0, "", "");
328
-
329
- borrowAmount =
330
- LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
331
-
332
- if (borrowAmount == 0) return (0, tokenCount, 0);
333
-
334
- mockExpect(
335
- address(jbPermissions()),
336
- abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), user, REVNET_ID, 11, true, true)),
337
- abi.encode(true)
338
- );
339
-
340
- REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
341
-
342
- vm.prank(user);
343
- (loanId,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(user), prepaidFee, user);
344
- }
345
-
346
- // =====================================================================
347
- // SECTION A: Critical Property Verification (8 tests)
348
- // =====================================================================
349
-
350
- /// @notice Borrow with collateral > uint112.max silently truncates loan.amount.
351
- /// @dev Verifies the truncation pattern: uint112(overflowValue) wraps.
352
- function test_fixVerify_uint112Truncation() public {
353
- // Prove the truncation math: uint112(max+1) wraps to 0
354
- uint256 overflowValue = uint256(type(uint112).max) + 1;
355
- // forge-lint: disable-next-line(unsafe-typecast)
356
- uint112 truncated = uint112(overflowValue);
357
- assertEq(truncated, 0, "uint112 truncation wraps max+1 to 0");
358
-
359
- // Prove a more realistic overflow: max + 1000 wraps to 999
360
- uint256 slightlyOver = uint256(type(uint112).max) + 1000;
361
- // forge-lint: disable-next-line(unsafe-typecast)
362
- truncated = uint112(slightlyOver);
363
- assertEq(truncated, 999, "uint112 truncation wraps to low bits");
364
-
365
- // Verify normal operation stays within bounds
366
- uint256 payAmount = 100e18;
367
- vm.prank(USER);
368
- uint256 tokens =
369
- jbMultiTerminal().pay{value: payAmount}(REVNET_ID, JBConstants.NATIVE_TOKEN, payAmount, USER, 0, "", "");
370
-
371
- uint256 borrowable =
372
- LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
373
- assertLt(borrowable, type(uint112).max, "normal borrowable within uint112");
374
- assertLt(tokens, type(uint112).max, "normal token count within uint112");
375
- }
376
-
377
- /// @notice Array OOB when only buyback hook present (no tiered721Hook).
378
- /// @dev hookSpecifications[1] is written but array size is 1.
379
- function test_fixVerify_arrayOOB_noBuybackWithBuyback() public pure {
380
- bool usesTiered721Hook = false;
381
- bool usesBuybackHook = true;
382
-
383
- uint256 arraySize = (usesTiered721Hook ? 1 : 0) + (usesBuybackHook ? 1 : 0);
384
- assertEq(arraySize, 1, "array size is 1");
385
-
386
- // The bug: code writes to hookSpecifications[1] (OOB for size-1 array)
387
- // The fix: should write to index 0 when no tiered721Hook
388
- // forge-lint: disable-next-line(mixed-case-variable)
389
- bool wouldOOB = (!usesTiered721Hook && usesBuybackHook);
390
- assertTrue(wouldOOB, "this config triggers the OOB write at index [1]");
391
-
392
- uint256 correctIndex = usesTiered721Hook ? 1 : 0;
393
- assertEq(correctIndex, 0, "buyback hook should use index 0");
394
-
395
- // Verify safe write
396
- JBPayHookSpecification[] memory specs = new JBPayHookSpecification[](arraySize);
397
- specs[correctIndex] =
398
- JBPayHookSpecification({hook: IJBPayHook(address(0xbeef)), noop: false, amount: 1 ether, metadata: ""});
399
- }
400
-
401
- /// @notice Reentrancy — _adjust calls terminal.pay() BEFORE writing loan state.
402
- /// @dev Lines 910 (external call) vs 922-923 (state writes). CEI violation.
403
- function test_fixVerify_reentrancyDoubleBorrow() public {
404
- // Create a legitimate loan to confirm the system works
405
- uint256 payAmount = 10e18;
406
- vm.prank(USER);
407
- uint256 tokens =
408
- jbMultiTerminal().pay{value: payAmount}(REVNET_ID, JBConstants.NATIVE_TOKEN, payAmount, USER, 0, "", "");
409
-
410
- uint256 borrowable =
411
- LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
412
- assertTrue(borrowable > 0, "Should have borrowable amount");
413
-
414
- // The vulnerability: In _adjust (line 862-924):
415
- // Line 910: loan.source.terminal.pay{value: payValue}(...) — EXTERNAL CALL
416
- // Line 922: loan.amount = uint112(newBorrowAmount); — STATE WRITE
417
- // Line 923: loan.collateral = uint112(newCollateralCount); — STATE WRITE
418
- //
419
- // A malicious terminal receiving the fee payment at line 910 can call
420
- // borrowFrom() again. During that reentrant call, loan.amount and loan.collateral
421
- // still have their OLD values (0 for a new loan), so _borrowAmountFrom computes
422
- // using stale totalBorrowed/totalCollateral.
423
- //
424
- // Without a reentrancy guard, the attacker could extract more value than the
425
- // collateral supports. The fix should add a reentrancy guard or move state writes
426
- // before external calls.
427
-
428
- // Verify the state write ordering is the vulnerability
429
- // (We can't actually execute the attack through real contracts because
430
- // the fee terminal is the legitimate JBMultiTerminal, but the pattern
431
- // is confirmed by code inspection)
432
- assertTrue(true, "CEI pattern verified at lines 910 vs 922-923");
433
- }
434
-
435
- /// @notice hasMintPermissionFor returns false for random addresses.
436
- /// @dev With the buyback hook removed, hasMintPermissionFor should return false
437
- /// for addresses that are not the loans contract or a sucker.
438
- function test_fixVerify_hasMintPermission_noBuyback() public view {
439
- // The fee project was deployed without buyback hook in our setup
440
- JBRuleset memory currentRuleset = jbRulesets().currentOf(FEE_PROJECT_ID);
441
-
442
- // hasMintPermissionFor should return false for random addresses
443
- address randomAddr = address(0x12345);
444
- bool hasPerm = REV_OWNER.hasMintPermissionFor(FEE_PROJECT_ID, currentRuleset, randomAddr);
445
- assertFalse(hasPerm, "random address should not have mint permission");
446
- }
447
-
448
- /// @notice Zero-supply cash out no longer drains surplus (fixed in v6).
449
- /// @dev JBCashOuts.cashOutFrom now returns 0 when cashOutCount == 0.
450
- function test_fixVerify_zeroSupplyCashOutDrain() public pure {
451
- uint256 surplus = 100e18;
452
- uint256 cashOutCount = 0;
453
- uint256 totalSupply = 0;
454
- uint256 cashOutTaxRate = 6000;
455
-
456
- uint256 reclaimable = JBCashOuts.cashOutFrom(surplus, cashOutCount, totalSupply, cashOutTaxRate);
457
-
458
- // Fixed in v6: cashing out 0 tokens always returns 0
459
- assertEq(reclaimable, 0, "zero cash out returns nothing");
460
-
461
- // Normal case: with supply, cashing out 0 still returns 0
462
- uint256 normalReclaimable = JBCashOuts.cashOutFrom(surplus, 0, 1000e18, cashOutTaxRate);
463
- assertEq(normalReclaimable, 0, "Normal: cashing out 0 of non-zero supply returns 0");
464
- }
465
-
466
- /// @notice Broken fee terminal + broken addToBalanceOf fallback bricks cash-outs.
467
- /// @dev afterCashOutRecordedWith: try feeTerminal.pay() catch { addToBalanceOf() }
468
- /// If BOTH revert, the entire cash-out transaction reverts.
469
- function test_fixVerify_brokenFeeTerminalBricksCashOuts() public {
470
- BrokenFeeTerminal brokenTerminal = new BrokenFeeTerminal();
471
-
472
- // The vulnerability pattern:
473
- // In REVDeployer.afterCashOutRecordedWith (line 567-624):
474
- // Line 590: try feeTerminal.pay(...) {} catch {
475
- // Line 615: IJBTerminal(msg.sender).addToBalanceOf{value: payValue}(...)
476
- //
477
- // If feeTerminal.pay() reverts AND addToBalanceOf() reverts:
478
- // - The entire afterCashOutRecordedWith call reverts
479
- // - This makes ALL cash-outs for the revnet impossible
480
- //
481
- // In the current code, addToBalanceOf is NOT in a try/catch,
482
- // so a broken fee terminal permanently bricks cash-outs.
483
-
484
- assertTrue(brokenTerminal.payReverts(), "Pay reverts by default");
485
- assertTrue(brokenTerminal.addToBalanceReverts(), "AddToBalance reverts by default");
486
-
487
- // Verify both functions revert
488
- vm.expectRevert("BrokenFeeTerminal: pay reverts");
489
- brokenTerminal.pay(0, address(0), 0, address(0), 0, "", "");
490
-
491
- vm.expectRevert("BrokenFeeTerminal: addToBalance reverts");
492
- brokenTerminal.addToBalanceOf(0, address(0), 0, false, "", "");
493
- }
494
-
495
- /// @notice Auto-issuance stored at block.timestamp+i, not actual ruleset IDs.
496
- /// @dev _makeRulesetConfigurations stores at block.timestamp+i but autoIssueFor
497
- /// queries by actual ruleset ID. If they mismatch, tokens are unclaimable.
498
- function test_fixVerify_autoIssuanceStageIdMismatch() public {
499
- // Deploy a multi-stage revnet with auto-issuance on multiple stages
500
- JBAccountingContext[] memory ctx = new JBAccountingContext[](1);
501
- ctx[0] = JBAccountingContext({
502
- token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
503
- });
504
-
505
- JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
506
- tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: ctx});
507
-
508
- JBSplit[] memory splits = new JBSplit[](1);
509
- splits[0].beneficiary = payable(multisig());
510
- splits[0].percent = 10_000;
511
-
512
- REVStageConfig[] memory stages = new REVStageConfig[](2);
513
-
514
- REVAutoIssuance[] memory iss0 = new REVAutoIssuance[](1);
515
- iss0[0] = REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(50_000e18), beneficiary: multisig()});
516
-
517
- stages[0] = REVStageConfig({
518
- startsAtOrAfter: uint40(block.timestamp),
519
- autoIssuances: iss0,
520
- splitPercent: 2000,
521
- splits: splits,
522
- initialIssuance: uint112(1000e18),
523
- issuanceCutFrequency: 90 days,
524
- issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
525
- cashOutTaxRate: 6000,
526
- extraMetadata: 0
527
- });
528
-
529
- REVAutoIssuance[] memory iss1 = new REVAutoIssuance[](1);
530
- iss1[0] = REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(30_000e18), beneficiary: multisig()});
531
-
532
- stages[1] = REVStageConfig({
533
- startsAtOrAfter: uint40(stages[0].startsAtOrAfter + 365 days),
534
- autoIssuances: iss1,
535
- splitPercent: 1000,
536
- splits: splits,
537
- initialIssuance: 0,
538
- issuanceCutFrequency: 180 days,
539
- issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
540
- cashOutTaxRate: 3000,
541
- extraMetadata: 0
542
- });
543
-
544
- vm.prank(multisig());
545
- (uint256 h5RevnetId,) = REV_DEPLOYER.deployFor({
546
- revnetId: 0,
547
- configuration: REVConfig({
548
- // forge-lint: disable-next-line(named-struct-fields)
549
- description: REVDescription("H5Test", "H5T", "ipfs://h5", "H5_TOKEN"),
550
- baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
551
- splitOperator: multisig(),
552
- stageConfigurations: stages
553
- }),
554
- terminalConfigurations: tc,
555
- suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
556
- deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("H5_INVINCIBILITY")
557
- }),
558
- tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
559
- allowedPosts: REVEmpty721Config.emptyAllowedPosts()
560
- });
561
-
562
- // Stage 0 auto-issuance stored at block.timestamp
563
- uint256 stage0Amount = REV_DEPLOYER.amountToAutoIssue(h5RevnetId, block.timestamp, multisig());
564
- assertEq(stage0Amount, 50_000e18, "Stage 0 auto-issuance stored at block.timestamp");
565
-
566
- // Stage 1 auto-issuance stored at block.timestamp + 1 (the stage ID mismatch bug)
567
- uint256 stage1Amount = REV_DEPLOYER.amountToAutoIssue(h5RevnetId, block.timestamp + 1, multisig());
568
- assertEq(stage1Amount, 30_000e18, "Stage 1 auto-issuance stored at block.timestamp + 1");
569
-
570
- // The bug: stages are stored at (block.timestamp + i), not at the actual ruleset IDs.
571
- // In the test environment, stages queued in the same block happen to have sequential IDs
572
- // (block.timestamp, block.timestamp+1), so the storage keys coincidentally match.
573
- // However, if deployment happens at a different time than block.timestamp, or if stages
574
- // are added later, the keys diverge and auto-issuance becomes unclaimable.
575
- //
576
- // We verify the fragile assumption: the storage key depends on block.timestamp at deploy
577
- // time, NOT on the actual ruleset ID. A redeployment at a different timestamp would break.
578
- JBRuleset[] memory rulesets = jbRulesets().allOf(h5RevnetId, 0, 3);
579
- assertGe(rulesets.length, 2, "Should have at least 2 rulesets");
580
-
581
- // Document the storage keys used vs what autoIssueFor expects
582
- // autoIssueFor calls with the CURRENT ruleset's ID (from currentOf).
583
- // If the ruleset ID != block.timestamp+i, the amount at that key is 0.
584
- emit log_named_uint("Storage key for stage 1", block.timestamp + 1);
585
- emit log_named_uint("Actual ruleset[0].id (most recent)", rulesets[0].id);
586
- emit log_named_uint("Actual ruleset[1].id (first)", rulesets[1].id);
587
-
588
- // The fragility: stage 1 issuance is ONLY accessible at (block.timestamp + 1).
589
- // Any other key returns 0.
590
- uint256 wrongKey = block.timestamp + 100;
591
- uint256 amountAtWrongKey = REV_DEPLOYER.amountToAutoIssue(h5RevnetId, wrongKey, multisig());
592
- assertEq(amountAtWrongKey, 0, "auto-issuance unreachable at wrong key");
593
- }
594
-
595
- /// @notice Unvalidated source terminal — unbounded _loanSourcesOf array growth.
596
- /// @dev borrowFrom accepts any terminal in REVLoanSource without validation.
597
- function test_fixVerify_unvalidatedSourceTerminal() public {
598
- // The vulnerability: REVLoans._addTo (line 788-791) registers ANY terminal
599
- // as a loan source without validating it's an actual project terminal:
600
- // if (!isLoanSourceOf[revnetId][loan.source.terminal][loan.source.token]) {
601
- // isLoanSourceOf[...] = true;
602
- // _loanSourcesOf[revnetId].push(...)
603
- // }
604
- //
605
- // This means:
606
- // 1. An attacker can pass arbitrary terminals as loan sources
607
- // 2. The _loanSourcesOf array grows unboundedly
608
- // 3. Functions iterating over loan sources (like _totalBorrowedFrom) become
609
- // increasingly expensive, eventually hitting gas limits (DoS)
610
-
611
- // Loan sources are registered lazily — only when the first borrow from that source occurs.
612
- // Before any borrows, the array is empty.
613
- REVLoanSource[] memory sourcesBefore = LOANS_CONTRACT.loanSourcesOf(REVNET_ID);
614
- assertEq(sourcesBefore.length, 0, "No loan sources registered before first borrow");
615
-
616
- // Create a legitimate loan — this registers the source
617
- _setupLoan(USER, 5e18, 25);
618
-
619
- // Now verify the source was registered
620
- REVLoanSource[] memory sourcesAfter = LOANS_CONTRACT.loanSourcesOf(REVNET_ID);
621
- assertEq(sourcesAfter.length, 1, "One loan source registered after first borrow");
622
- assertEq(address(sourcesAfter[0].terminal), address(jbMultiTerminal()), "Source should be multi terminal");
623
-
624
- // The vulnerability is that _addTo registers ANY terminal passed in REVLoanSource.
625
- // There's no validation that the terminal is actually a terminal for the project.
626
- // This means an attacker could register fake terminals, growing the array unboundedly.
627
- assertTrue(
628
- LOANS_CONTRACT.isLoanSourceOf(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN),
629
- "source registered without terminal validation"
630
- );
631
- }
632
-
633
- // =====================================================================
634
- // SECTION B: Economic Attack Scenarios (10 tests)
635
- // =====================================================================
636
-
637
- /// @notice Loan amplification spiral: borrow → addToBalance → borrow again.
638
- /// @dev totalBorrowed in surplus formula should prevent infinite amplification.
639
- function test_econ_loanAmplificationSpiral() public {
640
- // Step 1: Pay to get tokens
641
- uint256 payAmount = 10e18;
642
- (,, uint256 borrow1) = _setupLoan(USER, payAmount, 25);
643
- assertTrue(borrow1 > 0, "First loan should have borrow amount");
644
-
645
- // Step 2: Add borrowed amount back to balance (inflating surplus)
646
- vm.deal(address(this), borrow1);
647
- jbMultiTerminal().addToBalanceOf{value: borrow1}(REVNET_ID, JBConstants.NATIVE_TOKEN, borrow1, false, "", "");
648
-
649
- // Step 3: Pay again to get new tokens
650
- vm.deal(USER, payAmount);
651
- vm.prank(USER);
652
- uint256 tokens2 =
653
- jbMultiTerminal().pay{value: payAmount}(REVNET_ID, JBConstants.NATIVE_TOKEN, payAmount, USER, 0, "", "");
654
-
655
- // Step 4: Try to borrow again
656
- LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens2, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
657
-
658
- // The totalBorrowed from loan1 is added to surplus in borrowableAmountFrom,
659
- // so the second borrow should not amplify beyond what the real surplus supports.
660
- // The sum of all borrows should not exceed the actual terminal balance.
661
- uint256 totalBorrowed = LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
662
- assertTrue(totalBorrowed > 0, "Should have outstanding borrows");
663
- }
664
-
665
- /// @notice Stage transition cash-out gaming: buy at stage 0 tax, cash out at stage 1 tax.
666
- /// @dev Verifies economics match across tax rate changes.
667
- function test_econ_stageTransitionCashOutGaming() public {
668
- // Buy tokens during stage 0 (cashOutTaxRate = 6000 = 60%)
669
- uint256 payAmount = 5e18;
670
- vm.prank(USER);
671
- uint256 tokens =
672
- jbMultiTerminal().pay{value: payAmount}(REVNET_ID, JBConstants.NATIVE_TOKEN, payAmount, USER, 0, "", "");
673
-
674
- assertTrue(tokens > 0, "Should receive tokens");
675
-
676
- // Warp to stage 1 (cashOutTaxRate = 1000 = 10%)
677
- vm.warp(block.timestamp + 366 days);
678
-
679
- // Trigger ruleset cycling with a small payment
680
- address payor = makeAddr("payor");
681
- vm.deal(payor, 0.01e18);
682
- vm.prank(payor);
683
- jbMultiTerminal().pay{value: 0.01e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 0.01e18, payor, 0, "", "");
684
-
685
- // Get current ruleset to verify we're in stage 1
686
- jbRulesets().currentOf(REVNET_ID);
687
-
688
- // Cash out at the new (lower) tax rate
689
- // Note: there's a 30-day cash out delay, so we advance more
690
- vm.warp(block.timestamp + 31 days);
691
-
692
- vm.prank(USER);
693
- try jbMultiTerminal()
694
- .cashOutTokensOf({
695
- holder: USER,
696
- projectId: REVNET_ID,
697
- cashOutCount: tokens,
698
- tokenToReclaim: JBConstants.NATIVE_TOKEN,
699
- minTokensReclaimed: 0,
700
- beneficiary: payable(USER),
701
- metadata: ""
702
- }) returns (
703
- uint256 reclaimAmount
704
- ) {
705
- // The reclaim amount should be bounded by the bonding curve
706
- // at the CURRENT tax rate (lower), giving more back
707
- assertTrue(reclaimAmount > 0, "Should reclaim some ETH");
708
- // But bounded — can't get more than the surplus
709
- assertTrue(reclaimAmount <= payAmount, "Cannot extract more than was paid in");
710
- } catch {
711
- // Cash out may fail due to various conditions; that's acceptable
712
- }
713
- }
714
-
715
- /// @notice Reserved token dilution: split operator accumulates and cashes out.
716
- /// @dev Cash-out should be proportional to token share, no excess extraction.
717
- function test_econ_reservedTokenDilution() public {
718
- // Pay to create surplus + mint tokens (some go to reserved)
719
- vm.prank(USER);
720
- jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, USER, 0, "", "");
721
-
722
- // Send reserved tokens to splits
723
- try jbController().sendReservedTokensToSplitsOf(REVNET_ID) {} catch {}
724
-
725
- // Check multisig (split beneficiary) token balance
726
- IJBToken projectToken = jbTokens().tokenOf(REVNET_ID);
727
- uint256 multisigTokens = projectToken.balanceOf(multisig());
728
-
729
- // Total supply
730
- uint256 totalSupply = jbController().totalTokenSupplyWithReservedTokensOf(REVNET_ID);
731
-
732
- if (multisigTokens > 0 && totalSupply > 0) {
733
- // The split operator's share should be proportional
734
- // They should not be able to extract more than their proportional surplus
735
- uint256 operatorShare = mulDiv(multisigTokens, 1e18, totalSupply);
736
- assertTrue(operatorShare <= 1e18, "Operator share cannot exceed 100%");
737
- }
738
- }
739
-
740
- /// @notice Flash loan surplus inflation: addToBalance → borrow at inflated rate.
741
- /// @dev Surplus is read live, so an addToBalance before borrow inflates it.
742
- function test_econ_flashLoanSurplusInflation() public {
743
- // Step 1: Pay to get tokens
744
- uint256 payAmount = 5e18;
745
- vm.prank(USER);
746
- uint256 tokens =
747
- jbMultiTerminal().pay{value: payAmount}(REVNET_ID, JBConstants.NATIVE_TOKEN, payAmount, USER, 0, "", "");
748
-
749
- // Record borrowable BEFORE inflation
750
- uint256 borrowableBefore =
751
- LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
752
-
753
- // Step 2: Add 100 ETH to balance (inflates surplus without minting tokens)
754
- vm.deal(address(this), 100e18);
755
- jbMultiTerminal().addToBalanceOf{value: 100e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 100e18, false, "", "");
756
-
757
- // Record borrowable AFTER inflation
758
- uint256 borrowableAfter =
759
- LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
760
-
761
- // The borrowable amount increases because surplus grew but totalSupply didn't
762
- assertTrue(borrowableAfter > borrowableBefore, "surplus inflation increases borrowable amount");
763
-
764
- // Quantify the inflation factor
765
- if (borrowableBefore > 0) {
766
- uint256 inflationFactor = mulDiv(borrowableAfter, 1e18, borrowableBefore);
767
- assertTrue(inflationFactor > 1e18, "inflation factor > 1x");
768
- emit log_named_uint("inflation factor (1e18=1x)", inflationFactor);
769
- }
770
- }
771
-
772
- /// @notice Borrow 50%, cash out remaining 50% — totalSupply+totalCollateral neutralizes.
773
- /// @dev The denominator uses totalSupply + totalCollateral so collateral-holders
774
- /// don't dilute remaining holders' cash-out value.
775
- function test_econ_loanThenCashOutAmplification() public {
776
- // Two users pay equal amounts
777
- address userA = makeAddr("userA");
778
- address userB = makeAddr("userB");
779
- vm.deal(userA, 100e18);
780
- vm.deal(userB, 100e18);
781
-
782
- vm.prank(userA);
783
- uint256 tokensA =
784
- jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, userA, 0, "", "");
785
-
786
- vm.prank(userB);
787
- jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, userB, 0, "", "");
788
-
789
- // UserA borrows (tokens locked as collateral)
790
- mockExpect(
791
- address(jbPermissions()),
792
- abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), userA, REVNET_ID, 11, true, true)),
793
- abi.encode(true)
794
- );
795
-
796
- REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
797
- uint256 borrowableA =
798
- LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokensA, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
799
-
800
- if (borrowableA > 0) {
801
- vm.prank(userA);
802
- LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokensA, payable(userA), 25, userA);
803
- }
804
-
805
- // UserB's tokens should still have proportional cash-out value
806
- // The totalCollateral is added to the denominator (totalSupply + totalCollateral)
807
- // and totalBorrowed is added to the numerator (surplus + totalBorrowed)
808
- uint256 totalCollateral = LOANS_CONTRACT.totalCollateralOf(REVNET_ID);
809
- uint256 totalBorrowed = LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
810
-
811
- // Verify accounting consistency
812
- if (borrowableA > 0) {
813
- assertEq(totalCollateral, tokensA, "Collateral should equal locked tokens");
814
- assertTrue(totalBorrowed > 0, "Should have outstanding borrows");
815
- }
816
- }
817
-
818
- /// @notice Collateral rotation: refinance after surplus increase.
819
- /// @dev Extraction should be bounded by the bonding curve.
820
- function test_econ_collateralRotation() public {
821
- // Setup initial loan
822
- (uint256 loanId,, uint256 borrowAmount) = _setupLoan(USER, 5e18, 25);
823
- if (borrowAmount == 0) return;
824
-
825
- // Surplus increases (someone else pays in)
826
- address donor = makeAddr("donor");
827
- vm.deal(donor, 50e18);
828
- vm.prank(donor);
829
- jbMultiTerminal().pay{value: 50e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 50e18, donor, 0, "", "");
830
-
831
- // After surplus increase, the same collateral could borrow more
832
- REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
833
- uint256 newBorrowable = LOANS_CONTRACT.borrowableAmountFrom(
834
- REVNET_ID, loan.collateral, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
835
- );
836
-
837
- // With a large surplus increase and the same collateral, borrowable should increase.
838
- // However, the bonding curve shape (with cashOutTaxRate) means the increase is sub-linear.
839
- // The key economic property: extraction is bounded by the bonding curve.
840
- emit log_named_uint("Original borrow amount", loan.amount);
841
- emit log_named_uint("New borrowable after surplus increase", newBorrowable);
842
-
843
- // The bonding curve ensures that even with a 10x surplus increase,
844
- // the borrowable amount doesn't increase 10x (it's dampened by the tax rate)
845
- assertTrue(newBorrowable > 0, "Should have non-zero borrowable amount after surplus increase");
846
- }
847
-
848
- /// @notice Zero surplus + loan default: system still works for new payments.
849
- /// @dev Borrow all available surplus → new payments and repayment still functional.
850
- function test_econ_zeroSurplusLoanDefault() public {
851
- // Pay and borrow maximum
852
- (,, uint256 borrowAmount) = _setupLoan(USER, 10e18, 25);
853
- if (borrowAmount == 0) return;
854
-
855
- // New user can still pay into the system
856
- address newUser = makeAddr("newUser");
857
- vm.deal(newUser, 5e18);
858
- vm.prank(newUser);
859
- uint256 newTokens =
860
- jbMultiTerminal().pay{value: 5e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 5e18, newUser, 0, "", "");
861
- assertTrue(newTokens > 0, "New payments should still work");
862
- }
863
-
864
- /// @notice Loans across stage boundary: loans stay healthy when tax rate decreases.
865
- function test_econ_stageTransitionWithLoans() public {
866
- // Create loan in stage 0
867
- (uint256 loanId,, uint256 borrowAmount) = _setupLoan(USER, 10e18, 25);
868
- if (borrowAmount == 0) return;
869
-
870
- REVLoan memory loanBefore = LOANS_CONTRACT.loanOf(loanId);
871
-
872
- // Warp to stage 1 (different tax rate)
873
- vm.warp(block.timestamp + 366 days);
874
-
875
- // Trigger ruleset cycling
876
- address payor = makeAddr("payor");
877
- vm.deal(payor, 0.01e18);
878
- vm.prank(payor);
879
- jbMultiTerminal().pay{value: 0.01e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 0.01e18, payor, 0, "", "");
880
-
881
- // Loan should still exist with same values
882
- REVLoan memory loanAfter = LOANS_CONTRACT.loanOf(loanId);
883
- assertEq(loanAfter.amount, loanBefore.amount, "Loan amount unchanged across stages");
884
- assertEq(loanAfter.collateral, loanBefore.collateral, "Loan collateral unchanged across stages");
885
-
886
- // Borrowable amount may have changed (different tax rate)
887
- LOANS_CONTRACT.borrowableAmountFrom(
888
- REVNET_ID, loanAfter.collateral, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
889
- );
890
- // With lower tax rate in stage 1, borrowable should increase
891
- // (more surplus is reclaimable per token)
892
- }
893
-
894
- /// @notice Split operator rug: redirect splits + cash out 90% reserved tokens.
895
- /// @dev Quantifies max split operator extraction.
896
- function test_econ_splitOperatorRug() public {
897
- // Pay to build up surplus and reserved tokens
898
- vm.prank(USER);
899
- jbMultiTerminal().pay{value: 50e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 50e18, USER, 0, "", "");
900
-
901
- // Send reserved tokens to splits (multisig = split beneficiary)
902
- try jbController().sendReservedTokensToSplitsOf(REVNET_ID) {} catch {}
903
-
904
- // Check how many tokens the split operator got
905
- IJBToken projectToken = jbTokens().tokenOf(REVNET_ID);
906
- uint256 operatorTokens = projectToken.balanceOf(multisig());
907
- uint256 totalSupply = jbController().totalTokenSupplyWithReservedTokensOf(REVNET_ID);
908
-
909
- if (operatorTokens > 0) {
910
- // Calculate operator's theoretical max extraction
911
- uint256 operatorPercent = mulDiv(operatorTokens, 10_000, totalSupply);
912
- // With 20% splitPercent and 60% cashOutTaxRate, the operator's extraction
913
- // is bounded by the bonding curve
914
- emit log_named_uint("Operator token share (bps)", operatorPercent);
915
- emit log_named_uint("Operator tokens", operatorTokens);
916
- emit log_named_uint("Total supply", totalSupply);
917
-
918
- // Operator can only cash out their proportional share
919
- assertTrue(operatorPercent <= 5000, "Operator should have <=50% of tokens");
920
- }
921
- }
922
-
923
- /// @notice Double fee — REVDeployer not registered as feeless.
924
- /// @dev Cash-out fee goes to REVDeployer (afterCashOutRecordedWith) which pays fee terminal.
925
- /// But the JBMultiTerminal's useAllowanceOf already took a protocol fee,
926
- /// so the fee payment to the fee terminal is a second fee on the same funds.
927
- function test_econ_doubleFee() public {
928
- // Pay into revnet
929
- vm.prank(USER);
930
- uint256 tokens =
931
- jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, USER, 0, "", "");
932
-
933
- // Advance past cash-out delay
934
- vm.warp(block.timestamp + 31 days);
935
-
936
- // Record fee project balance before cash-out
937
- uint256 feeBalanceBefore;
938
- {
939
- feeBalanceBefore = jbMultiTerminal()
940
- .currentSurplusOf(FEE_PROJECT_ID, new address[](0), 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
941
- }
942
-
943
- // Cash out
944
- vm.prank(USER);
945
- try jbMultiTerminal()
946
- .cashOutTokensOf({
947
- holder: USER,
948
- projectId: REVNET_ID,
949
- cashOutCount: tokens / 2,
950
- tokenToReclaim: JBConstants.NATIVE_TOKEN,
951
- minTokensReclaimed: 0,
952
- beneficiary: payable(USER),
953
- metadata: ""
954
- }) returns (
955
- uint256 reclaimAmount
956
- ) {
957
- // The double fee means the fee project gets more than expected
958
- // because both the terminal fee AND the revnet fee route to it
959
- uint256 feeBalanceAfter;
960
- {
961
- feeBalanceAfter = jbMultiTerminal()
962
- .currentSurplusOf(FEE_PROJECT_ID, new address[](0), 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
963
- }
964
-
965
- // Fee project should have received fees from the cash-out
966
- emit log_named_uint("Fee project balance before", feeBalanceBefore);
967
- emit log_named_uint("Fee project balance after", feeBalanceAfter);
968
- emit log_named_uint("Reclaim amount", reclaimAmount);
969
- } catch {
970
- // Cash out may fail (e.g., if fee terminal isn't set up) — document the failure
971
- emit log("Cash-out reverted (may be due to fee terminal setup)");
972
- }
973
- }
974
- }
975
-
976
- // =========================================================================
977
- // Section C: Invariant Properties (6 invariants)
978
- // =========================================================================
979
- contract REVInvincibility_Invariants is StdInvariant, TestBaseWorkflow {
980
- using JBRulesetMetadataResolver for JBRuleset;
981
-
982
- // forge-lint: disable-next-line(mixed-case-variable)
983
- bytes32 REV_DEPLOYER_SALT = "REVDeployer_INV";
984
-
985
- // forge-lint: disable-next-line(mixed-case-variable)
986
- REVDeployer REV_DEPLOYER;
987
- // forge-lint: disable-next-line(mixed-case-variable)
988
- REVOwner REV_OWNER;
989
- // forge-lint: disable-next-line(mixed-case-variable)
990
- JB721TiersHook EXAMPLE_HOOK;
991
- // forge-lint: disable-next-line(mixed-case-variable)
992
- IJB721TiersHookDeployer HOOK_DEPLOYER;
993
- // forge-lint: disable-next-line(mixed-case-variable)
994
- IJB721TiersHookStore HOOK_STORE;
995
- // forge-lint: disable-next-line(mixed-case-variable)
996
- IJBAddressRegistry ADDRESS_REGISTRY;
997
- // forge-lint: disable-next-line(mixed-case-variable)
998
- IREVLoans LOANS_CONTRACT;
999
- // forge-lint: disable-next-line(mixed-case-variable)
1000
- IJBSuckerRegistry SUCKER_REGISTRY;
1001
- // forge-lint: disable-next-line(mixed-case-variable)
1002
- CTPublisher PUBLISHER;
1003
- // forge-lint: disable-next-line(mixed-case-variable)
1004
- MockBuybackDataHook MOCK_BUYBACK;
1005
-
1006
- // forge-lint: disable-next-line(mixed-case-variable)
1007
- REVInvincibilityHandler HANDLER;
1008
-
1009
- // forge-lint: disable-next-line(mixed-case-variable)
1010
- uint256 FEE_PROJECT_ID;
1011
- // forge-lint: disable-next-line(mixed-case-variable)
1012
- uint256 REVNET_ID;
1013
- // forge-lint: disable-next-line(mixed-case-variable)
1014
- uint256 INITIAL_TIMESTAMP;
1015
- // forge-lint: disable-next-line(mixed-case-variable)
1016
- uint256 STAGE_1_START;
1017
- // forge-lint: disable-next-line(mixed-case-variable)
1018
- uint256 STAGE_2_START;
1019
-
1020
- // forge-lint: disable-next-line(mixed-case-variable)
1021
- address USER = makeAddr("invUser");
1022
-
1023
- address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
1024
-
1025
- function setUp() public override {
1026
- super.setUp();
1027
-
1028
- FEE_PROJECT_ID = jbProjects().createFor(multisig());
1029
-
1030
- SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
1031
- HOOK_STORE = new JB721TiersHookStore();
1032
- EXAMPLE_HOOK = new JB721TiersHook(
1033
- jbDirectory(),
1034
- jbPermissions(),
1035
- jbPrices(),
1036
- jbRulesets(),
1037
- HOOK_STORE,
1038
- jbSplits(),
1039
- IJB721CheckpointsDeployer(address(new JB721CheckpointsDeployer())),
1040
- multisig()
1041
- );
1042
- ADDRESS_REGISTRY = new JBAddressRegistry();
1043
- HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
1044
- PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
1045
- MOCK_BUYBACK = new MockBuybackDataHook();
1046
-
1047
- LOANS_CONTRACT = new REVLoans({
1048
- controller: jbController(),
1049
- suckerRegistry: IJBSuckerRegistry(address(new MockSuckerRegistry())),
1050
- revId: FEE_PROJECT_ID,
1051
- owner: address(this),
1052
- permit2: permit2(),
1053
- trustedForwarder: TRUSTED_FORWARDER
1054
- });
1055
-
1056
- REV_OWNER = new REVOwner(
1057
- IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
1058
- jbDirectory(),
1059
- FEE_PROJECT_ID,
1060
- SUCKER_REGISTRY,
1061
- address(LOANS_CONTRACT),
1062
- address(0)
1063
- );
1064
-
1065
- REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
1066
- jbController(),
1067
- SUCKER_REGISTRY,
1068
- FEE_PROJECT_ID,
1069
- HOOK_DEPLOYER,
1070
- PUBLISHER,
1071
- IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
1072
- address(LOANS_CONTRACT),
1073
- TRUSTED_FORWARDER,
1074
- address(REV_OWNER)
1075
- );
1076
- REV_OWNER.setDeployer(REV_DEPLOYER);
1077
-
1078
- // Deploy fee project
1079
- {
1080
- JBAccountingContext[] memory ctx = new JBAccountingContext[](1);
1081
- ctx[0] = JBAccountingContext({
1082
- token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
1083
- });
1084
-
1085
- JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
1086
- tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: ctx});
1087
-
1088
- JBSplit[] memory splits = new JBSplit[](1);
1089
- splits[0].beneficiary = payable(multisig());
1090
- splits[0].percent = 10_000;
1091
-
1092
- REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
1093
- issuanceConfs[0] =
1094
- REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(70_000e18), beneficiary: multisig()});
1095
-
1096
- REVStageConfig[] memory stages = new REVStageConfig[](1);
1097
- stages[0] = REVStageConfig({
1098
- startsAtOrAfter: uint40(block.timestamp),
1099
- autoIssuances: issuanceConfs,
1100
- splitPercent: 2000,
1101
- splits: splits,
1102
- initialIssuance: uint112(1000e18),
1103
- issuanceCutFrequency: 90 days,
1104
- issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
1105
- cashOutTaxRate: 6000,
1106
- extraMetadata: 0
1107
- });
1108
-
1109
- vm.prank(multisig());
1110
- jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
1111
-
1112
- vm.prank(multisig());
1113
- REV_DEPLOYER.deployFor({
1114
- revnetId: FEE_PROJECT_ID,
1115
- configuration: REVConfig({
1116
- // forge-lint: disable-next-line(named-struct-fields)
1117
- description: REVDescription("Revnet", "$REV", "ipfs://rev", "REV_TOKEN_INV"),
1118
- baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
1119
- splitOperator: multisig(),
1120
- stageConfigurations: stages
1121
- }),
1122
- terminalConfigurations: tc,
1123
- suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
1124
- deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("REV_INV")
1125
- }),
1126
- tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
1127
- allowedPosts: REVEmpty721Config.emptyAllowedPosts()
1128
- });
1129
- }
1130
-
1131
- // Deploy main revnet with loans and multi-stage config
1132
- STAGE_1_START = block.timestamp + 365 days;
1133
- STAGE_2_START = STAGE_1_START + (20 * 365 days);
1134
- {
1135
- JBAccountingContext[] memory ctx = new JBAccountingContext[](1);
1136
- ctx[0] = JBAccountingContext({
1137
- token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
1138
- });
1139
-
1140
- JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
1141
- tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: ctx});
1142
-
1143
- JBSplit[] memory splits = new JBSplit[](1);
1144
- splits[0].beneficiary = payable(multisig());
1145
- splits[0].percent = 10_000;
1146
-
1147
- REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
1148
- issuanceConfs[0] =
1149
- REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(70_000e18), beneficiary: multisig()});
1150
-
1151
- REVStageConfig[] memory stages = new REVStageConfig[](3);
1152
- stages[0] = REVStageConfig({
1153
- startsAtOrAfter: uint40(block.timestamp),
1154
- autoIssuances: issuanceConfs,
1155
- splitPercent: 2000,
1156
- splits: splits,
1157
- initialIssuance: uint112(1000e18),
1158
- issuanceCutFrequency: 90 days,
1159
- issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
1160
- cashOutTaxRate: 6000,
1161
- extraMetadata: 0
1162
- });
1163
-
1164
- stages[1] = REVStageConfig({
1165
- // forge-lint: disable-next-line(unsafe-typecast)
1166
- startsAtOrAfter: uint40(STAGE_1_START),
1167
- autoIssuances: new REVAutoIssuance[](0),
1168
- splitPercent: 2000,
1169
- splits: splits,
1170
- initialIssuance: 0,
1171
- issuanceCutFrequency: 180 days,
1172
- issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
1173
- cashOutTaxRate: 1000,
1174
- extraMetadata: 0
1175
- });
1176
-
1177
- stages[2] = REVStageConfig({
1178
- // forge-lint: disable-next-line(unsafe-typecast)
1179
- startsAtOrAfter: uint40(STAGE_2_START),
1180
- autoIssuances: new REVAutoIssuance[](0),
1181
- splitPercent: 0,
1182
- splits: splits,
1183
- initialIssuance: 1,
1184
- issuanceCutFrequency: 0,
1185
- issuanceCutPercent: 0,
1186
- cashOutTaxRate: 500,
1187
- extraMetadata: 0
1188
- });
1189
-
1190
- (REVNET_ID,) = REV_DEPLOYER.deployFor({
1191
- revnetId: 0,
1192
- configuration: REVConfig({
1193
- // forge-lint: disable-next-line(named-struct-fields)
1194
- description: REVDescription("NANA", "$NANA", "ipfs://nana", "NANA_TOKEN_INV"),
1195
- baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
1196
- splitOperator: multisig(),
1197
- stageConfigurations: stages
1198
- }),
1199
- terminalConfigurations: tc,
1200
- suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
1201
- deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("NANA_INV")
1202
- }),
1203
- tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
1204
- allowedPosts: REVEmpty721Config.emptyAllowedPosts()
1205
- });
1206
- }
1207
-
1208
- INITIAL_TIMESTAMP = block.timestamp;
1209
-
1210
- // Deploy handler
1211
- HANDLER = new REVInvincibilityHandler(
1212
- jbMultiTerminal(),
1213
- LOANS_CONTRACT,
1214
- jbPermissions(),
1215
- jbTokens(),
1216
- jbController(),
1217
- REVNET_ID,
1218
- FEE_PROJECT_ID,
1219
- USER,
1220
- STAGE_1_START,
1221
- STAGE_2_START
1222
- );
1223
-
1224
- // Configure target
1225
- bytes4[] memory selectors = new bytes4[](10);
1226
- selectors[0] = REVInvincibilityHandler.payAndBorrow.selector;
1227
- selectors[1] = REVInvincibilityHandler.repayLoan.selector;
1228
- selectors[2] = REVInvincibilityHandler.reallocateCollateral.selector;
1229
- selectors[3] = REVInvincibilityHandler.liquidateLoans.selector;
1230
- selectors[4] = REVInvincibilityHandler.advanceTime.selector;
1231
- selectors[5] = REVInvincibilityHandler.payInto.selector;
1232
- selectors[6] = REVInvincibilityHandler.cashOut.selector;
1233
- selectors[7] = REVInvincibilityHandler.addToBalance.selector;
1234
- selectors[8] = REVInvincibilityHandler.sendReservedTokens.selector;
1235
- selectors[9] = REVInvincibilityHandler.changeStage.selector;
1236
-
1237
- targetContract(address(HANDLER));
1238
- targetSelector(FuzzSelector({addr: address(HANDLER), selectors: selectors}));
1239
- }
1240
-
1241
- // =====================================================================
1242
- // INV-REV-1: Surplus covers outstanding loans
1243
- // =====================================================================
1244
- /// @notice The terminal balance must always cover net outstanding borrowed amounts.
1245
- function invariant_REV_1_surplusCoversLoans() public {
1246
- uint256 totalBorrowed = LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
1247
-
1248
- uint256 storeBalance = jbMultiTerminal()
1249
- .currentSurplusOf(REVNET_ID, new address[](0), 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
1250
-
1251
- // Note: storeBalance is surplus (after payout limits), but the terminal holds at least this much
1252
- // The total borrowed should not exceed what the terminal can cover
1253
- // This may not hold strictly due to fees, but should be directionally correct
1254
- if (HANDLER.callCount_payAndBorrow() > 0) {
1255
- // Log for analysis
1256
- emit log_named_uint("INV-REV-1: totalBorrowed", totalBorrowed);
1257
- emit log_named_uint("INV-REV-1: storeBalance", storeBalance);
1258
- }
1259
- }
1260
-
1261
- // =====================================================================
1262
- // INV-REV-2: Collateral accounting exact
1263
- // =====================================================================
1264
- /// @notice Ghost collateral sum must match contract's totalCollateralOf.
1265
- function invariant_REV_2_collateralAccountingExact() public view {
1266
- assertEq(
1267
- HANDLER.COLLATERAL_SUM(),
1268
- LOANS_CONTRACT.totalCollateralOf(REVNET_ID),
1269
- "INV-REV-2: handler COLLATERAL_SUM must match totalCollateralOf"
1270
- );
1271
- }
1272
-
1273
- // =====================================================================
1274
- // INV-REV-3: Borrow accounting exact
1275
- // =====================================================================
1276
- /// @notice Ghost borrowed sum must match contract's totalBorrowedFrom.
1277
- function invariant_REV_3_borrowAccountingExact() public view {
1278
- uint256 actualTotalBorrowed =
1279
- LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
1280
-
1281
- assertEq(
1282
- actualTotalBorrowed, HANDLER.BORROWED_SUM(), "INV-REV-3: handler BORROWED_SUM must match totalBorrowedFrom"
1283
- );
1284
- }
1285
-
1286
- // =====================================================================
1287
- // INV-REV-4: No undercollateralized loans
1288
- // =====================================================================
1289
- /// @notice For each active loan: verify loan health tracking works.
1290
- /// @dev Loans CAN become undercollateralized when new payments increase totalSupply
1291
- /// faster than surplus grows (bonding curve dilution). This is expected behavior.
1292
- /// We verify that the loan struct itself is internally consistent.
1293
- function invariant_REV_4_noUndercollateralizedLoans() public view {
1294
- if (HANDLER.callCount_payAndBorrow() == 0) return;
1295
-
1296
- for (uint256 i = 1; i <= HANDLER.callCount_payAndBorrow(); i++) {
1297
- uint256 loanId = (REVNET_ID * 1_000_000_000_000) + i;
1298
-
1299
- try IERC721(address(LOANS_CONTRACT)).ownerOf(loanId) {}
1300
- catch {
1301
- continue;
1302
- }
1303
-
1304
- REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
1305
- if (loan.amount == 0) continue;
1306
-
1307
- // Internal consistency: active loans must have non-zero collateral
1308
- assertGt(uint256(loan.collateral), 0, "INV-REV-4: active loan must have collateral > 0");
1309
-
1310
- // Amount and collateral fit in uint112
1311
- assertLe(uint256(loan.amount), uint256(type(uint112).max), "INV-REV-4: amount fits uint112");
1312
- assertLe(uint256(loan.collateral), uint256(type(uint112).max), "INV-REV-4: collateral fits uint112");
1313
-
1314
- // createdAt must be in the past
1315
- assertLe(loan.createdAt, block.timestamp, "INV-REV-4: loan createdAt in the past");
1316
- }
1317
- }
1318
-
1319
- // =====================================================================
1320
- // INV-REV-5: Supply + collateral consistency
1321
- // =====================================================================
1322
- /// @notice totalSupply + totalCollateral should be coherent with token tracking.
1323
- function invariant_REV_5_supplyCollateralConsistency() public view {
1324
- uint256 totalSupply = jbController().totalTokenSupplyWithReservedTokensOf(REVNET_ID);
1325
- uint256 totalCollateral = LOANS_CONTRACT.totalCollateralOf(REVNET_ID);
1326
-
1327
- // The effective total (used in cash-out calculations) is totalSupply + totalCollateral
1328
- // This should always be >= the raw token supply
1329
- // (Collateral tokens were burned from supply and tracked separately)
1330
- uint256 effectiveTotal = totalSupply + totalCollateral;
1331
-
1332
- // If there have been any borrows, total collateral should be > 0
1333
- if (HANDLER.callCount_payAndBorrow() > 0 && HANDLER.COLLATERAL_SUM() > 0) {
1334
- assertGt(totalCollateral, 0, "INV-REV-5: collateral should be tracked after borrows");
1335
- }
1336
-
1337
- // Effective total should be > 0 if anyone has borrowed (which requires tokens)
1338
- // Note: payInto with very low issuance weight can mint 0 tokens, so we only
1339
- // check this when borrows have occurred (which requires non-zero tokens)
1340
- if (HANDLER.callCount_payAndBorrow() > 0 && HANDLER.COLLATERAL_SUM() > 0) {
1341
- assertGt(effectiveTotal, 0, "INV-REV-5: effective total must be > 0 after borrows");
1342
- }
1343
- }
1344
-
1345
- // =====================================================================
1346
- // INV-REV-6: Fee project balance monotonic
1347
- // =====================================================================
1348
- /// @notice Fee project balance should only increase (fees are one-directional).
1349
- /// @dev In practice, fee project balance can decrease if someone cashes out fee tokens.
1350
- /// We track the fee project's PAID_IN amount instead.
1351
- function invariant_REV_6_feeProjectBalanceMonotonic() public {
1352
- // The fee project accumulates fees from both:
1353
- // 1. Protocol fees on useAllowanceOf (JBMultiTerminal)
1354
- // 2. Revnet fees from afterCashOutRecordedWith (REVDeployer)
1355
- // 3. Loan fees from _addTo (REVLoans)
1356
- //
1357
- // These are all additive operations. The fee project surplus should
1358
- // only decrease via explicit cash-outs of fee project tokens.
1359
- //
1360
- // We verify the fee project has tokens issued (non-zero activity)
1361
- // after any operations that should generate fees.
1362
- if (HANDLER.callCount_payAndBorrow() > 0) {
1363
- // At minimum, loan fees should have been generated
1364
- // (REV_PREPAID_FEE_PERCENT = 10 = 1%)
1365
- uint256 feeProjectTokenSupply = jbController().totalTokenSupplyWithReservedTokensOf(FEE_PROJECT_ID);
1366
- // Fee tokens should have been minted from the fee payments
1367
- // This may be 0 if fee terminal is not properly configured
1368
- emit log_named_uint("INV-REV-6: fee project token supply", feeProjectTokenSupply);
1369
- }
1370
- }
1371
- }