@rev-net/core-v6 0.0.11 → 0.0.13

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 (81) hide show
  1. package/ADMINISTRATION.md +7 -7
  2. package/ARCHITECTURE.md +11 -11
  3. package/AUDIT_INSTRUCTIONS.md +295 -0
  4. package/CHANGE_LOG.md +316 -0
  5. package/README.md +9 -6
  6. package/RISKS.md +180 -35
  7. package/SKILLS.md +9 -11
  8. package/STYLE_GUIDE.md +14 -1
  9. package/USER_JOURNEYS.md +489 -0
  10. package/package.json +9 -9
  11. package/script/Deploy.s.sol +124 -40
  12. package/script/helpers/RevnetCoreDeploymentLib.sol +19 -6
  13. package/src/REVDeployer.sol +183 -175
  14. package/src/REVLoans.sol +65 -28
  15. package/src/interfaces/IREVDeployer.sol +25 -23
  16. package/src/structs/REV721TiersHookFlags.sol +1 -0
  17. package/src/structs/REVAutoIssuance.sol +1 -0
  18. package/src/structs/REVBaseline721HookConfig.sol +1 -0
  19. package/src/structs/REVConfig.sol +1 -0
  20. package/src/structs/REVCroptopAllowedPost.sol +1 -0
  21. package/src/structs/REVDeploy721TiersHookConfig.sol +13 -14
  22. package/src/structs/REVDescription.sol +1 -0
  23. package/src/structs/REVLoan.sol +1 -0
  24. package/src/structs/REVLoanSource.sol +1 -0
  25. package/src/structs/REVStageConfig.sol +1 -0
  26. package/src/structs/REVSuckerDeploymentConfig.sol +1 -0
  27. package/test/REV.integrations.t.sol +148 -19
  28. package/test/REVAutoIssuanceFuzz.t.sol +31 -6
  29. package/test/REVDeployerRegressions.t.sol +47 -9
  30. package/test/REVInvincibility.t.sol +83 -19
  31. package/test/REVInvincibilityHandler.sol +29 -0
  32. package/test/REVLifecycle.t.sol +36 -6
  33. package/test/REVLoans.invariants.t.sol +64 -10
  34. package/test/REVLoansAttacks.t.sol +54 -9
  35. package/test/REVLoansFeeRecovery.t.sol +61 -15
  36. package/test/REVLoansFindings.t.sol +42 -9
  37. package/test/REVLoansRegressions.t.sol +33 -6
  38. package/test/REVLoansSourceFeeRecovery.t.sol +491 -0
  39. package/test/REVLoansSourced.t.sol +79 -17
  40. package/test/REVLoansUnSourced.t.sol +61 -10
  41. package/test/TestBurnHeldTokens.t.sol +47 -11
  42. package/test/TestCEIPattern.t.sol +37 -6
  43. package/test/TestCashOutCallerValidation.t.sol +41 -8
  44. package/test/TestConversionDocumentation.t.sol +50 -13
  45. package/test/TestCrossCurrencyReclaim.t.sol +584 -0
  46. package/test/TestCrossSourceReallocation.t.sol +37 -6
  47. package/test/TestERC2771MetaTx.t.sol +557 -0
  48. package/test/TestEmptyBuybackSpecs.t.sol +45 -10
  49. package/test/TestFlashLoanSurplus.t.sol +39 -7
  50. package/test/TestHookArrayOOB.t.sol +42 -13
  51. package/test/TestLiquidationBehavior.t.sol +37 -7
  52. package/test/TestLoanSourceRotation.t.sol +525 -0
  53. package/test/TestLongTailEconomics.t.sol +651 -0
  54. package/test/TestLowFindings.t.sol +80 -8
  55. package/test/TestMixedFixes.t.sol +43 -9
  56. package/test/TestPermit2Signatures.t.sol +657 -0
  57. package/test/TestReallocationSandwich.t.sol +384 -0
  58. package/test/TestRevnetRegressions.t.sol +324 -0
  59. package/test/TestSplitWeightAdjustment.t.sol +52 -13
  60. package/test/TestSplitWeightE2E.t.sol +53 -18
  61. package/test/TestSplitWeightFork.t.sol +66 -21
  62. package/test/TestStageTransitionBorrowable.t.sol +38 -6
  63. package/test/TestSwapTerminalPermission.t.sol +37 -7
  64. package/test/TestUint112Overflow.t.sol +39 -6
  65. package/test/TestZeroRepayment.t.sol +37 -6
  66. package/test/fork/ForkTestBase.sol +66 -17
  67. package/test/fork/TestCashOutFork.t.sol +9 -3
  68. package/test/fork/TestLoanBorrowFork.t.sol +1 -0
  69. package/test/fork/TestLoanCrossRulesetFork.t.sol +11 -3
  70. package/test/fork/TestLoanLiquidationFork.t.sol +1 -0
  71. package/test/fork/TestLoanReallocateFork.t.sol +1 -0
  72. package/test/fork/TestLoanRepayFork.t.sol +1 -0
  73. package/test/fork/TestLoanTransferFork.t.sol +133 -0
  74. package/test/fork/TestSplitWeightFork.t.sol +3 -0
  75. package/test/helpers/REVEmpty721Config.sol +46 -0
  76. package/test/mock/MockBuybackDataHook.sol +1 -0
  77. package/test/regression/TestBurnPermissionRequired.t.sol +267 -0
  78. package/test/regression/TestCrossRevnetLiquidation.t.sol +228 -0
  79. package/test/regression/TestCumulativeLoanCounter.t.sol +38 -8
  80. package/test/regression/TestLiquidateGapHandling.t.sol +40 -8
  81. package/test/regression/TestZeroPriceFeed.t.sol +396 -0
@@ -0,0 +1,525 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ // forge-lint: disable-next-line(unaliased-plain-import)
5
+ import "forge-std/Test.sol";
6
+ // forge-lint: disable-next-line(unaliased-plain-import)
7
+ import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
8
+ // forge-lint: disable-next-line(unaliased-plain-import)
9
+ import /* {*} from */ "./../src/REVDeployer.sol";
10
+ // forge-lint: disable-next-line(unaliased-plain-import)
11
+ import "@croptop/core-v6/src/CTPublisher.sol";
12
+ import {MockBuybackDataHook} from "./mock/MockBuybackDataHook.sol";
13
+ // forge-lint: disable-next-line(unaliased-plain-import)
14
+ import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
15
+ // forge-lint: disable-next-line(unaliased-plain-import)
16
+ import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
17
+ // forge-lint: disable-next-line(unaliased-plain-import)
18
+ import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
19
+ // forge-lint: disable-next-line(unaliased-plain-import)
20
+ import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
21
+ // forge-lint: disable-next-line(unaliased-plain-import)
22
+ import "@bananapus/router-terminal-v6/script/helpers/RouterTerminalDeploymentLib.sol";
23
+
24
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
25
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
26
+ import {MockPriceFeed} from "@bananapus/core-v6/test/mock/MockPriceFeed.sol";
27
+ import {MockERC20} from "@bananapus/core-v6/test/mock/MockERC20.sol";
28
+ import {REVLoans} from "../src/REVLoans.sol";
29
+ import {REVLoan} from "../src/structs/REVLoan.sol";
30
+ import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
31
+ import {REVLoanSource} from "../src/structs/REVLoanSource.sol";
32
+ import {REVDescription} from "../src/structs/REVDescription.sol";
33
+ import {IREVLoans} from "./../src/interfaces/IREVLoans.sol";
34
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
35
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
36
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
37
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
38
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
39
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
40
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
41
+ import {JBSingleAllowance} from "@bananapus/core-v6/src/structs/JBSingleAllowance.sol";
42
+ import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
43
+
44
+ /// @notice Tests for loan source rotation: verify behavior when loans are taken from different sources (tokens)
45
+ /// and that existing loans remain valid and repayable after new sources are introduced.
46
+ contract TestLoanSourceRotation is TestBaseWorkflow {
47
+ // forge-lint: disable-next-line(mixed-case-variable)
48
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
49
+
50
+ // forge-lint: disable-next-line(mixed-case-variable)
51
+ REVDeployer REV_DEPLOYER;
52
+ // forge-lint: disable-next-line(mixed-case-variable)
53
+ JB721TiersHook EXAMPLE_HOOK;
54
+ // forge-lint: disable-next-line(mixed-case-variable)
55
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
56
+ // forge-lint: disable-next-line(mixed-case-variable)
57
+ IJB721TiersHookStore HOOK_STORE;
58
+ // forge-lint: disable-next-line(mixed-case-variable)
59
+ IJBAddressRegistry ADDRESS_REGISTRY;
60
+ // forge-lint: disable-next-line(mixed-case-variable)
61
+ IREVLoans LOANS_CONTRACT;
62
+ // forge-lint: disable-next-line(mixed-case-variable)
63
+ MockERC20 TOKEN;
64
+ // forge-lint: disable-next-line(mixed-case-variable)
65
+ IJBSuckerRegistry SUCKER_REGISTRY;
66
+ // forge-lint: disable-next-line(mixed-case-variable)
67
+ CTPublisher PUBLISHER;
68
+ // forge-lint: disable-next-line(mixed-case-variable)
69
+ MockBuybackDataHook MOCK_BUYBACK;
70
+
71
+ // forge-lint: disable-next-line(mixed-case-variable)
72
+ uint256 FEE_PROJECT_ID;
73
+ // forge-lint: disable-next-line(mixed-case-variable)
74
+ uint256 REVNET_ID;
75
+
76
+ // forge-lint: disable-next-line(mixed-case-variable)
77
+ address USER = makeAddr("user");
78
+
79
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
80
+
81
+ function setUp() public override {
82
+ super.setUp();
83
+
84
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
85
+
86
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
87
+ HOOK_STORE = new JB721TiersHookStore();
88
+ EXAMPLE_HOOK = new JB721TiersHook(
89
+ jbDirectory(), jbPermissions(), jbPrices(), jbRulesets(), HOOK_STORE, jbSplits(), multisig()
90
+ );
91
+ ADDRESS_REGISTRY = new JBAddressRegistry();
92
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
93
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
94
+ MOCK_BUYBACK = new MockBuybackDataHook();
95
+
96
+ // Deploy a 6-decimal ERC-20 token.
97
+ TOKEN = new MockERC20("Stable Token", "STABLE");
98
+
99
+ // Price feed: TOKEN -> ETH. 1 TOKEN (6 dec) = 0.0005 ETH.
100
+ MockPriceFeed priceFeed = new MockPriceFeed(5e14, 18);
101
+ vm.prank(multisig());
102
+ jbPrices()
103
+ .addPriceFeedFor(0, uint32(uint160(address(TOKEN))), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed);
104
+
105
+ LOANS_CONTRACT = new REVLoans({
106
+ controller: jbController(),
107
+ projects: jbProjects(),
108
+ revId: FEE_PROJECT_ID,
109
+ owner: address(this),
110
+ permit2: permit2(),
111
+ trustedForwarder: TRUSTED_FORWARDER
112
+ });
113
+
114
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
115
+ jbController(),
116
+ SUCKER_REGISTRY,
117
+ FEE_PROJECT_ID,
118
+ HOOK_DEPLOYER,
119
+ PUBLISHER,
120
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
121
+ address(LOANS_CONTRACT),
122
+ TRUSTED_FORWARDER
123
+ );
124
+
125
+ vm.prank(multisig());
126
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
127
+
128
+ _deployFeeProject();
129
+ _deployRevnet();
130
+
131
+ vm.deal(USER, 1000e18);
132
+ }
133
+
134
+ function _deployFeeProject() internal {
135
+ JBAccountingContext[] memory acc = new JBAccountingContext[](2);
136
+ acc[0] = JBAccountingContext({
137
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
138
+ });
139
+ acc[1] = JBAccountingContext({token: address(TOKEN), decimals: 6, currency: uint32(uint160(address(TOKEN)))});
140
+ JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
141
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
142
+
143
+ JBSplit[] memory splits = new JBSplit[](1);
144
+ splits[0].beneficiary = payable(multisig());
145
+ splits[0].percent = 10_000;
146
+
147
+ REVAutoIssuance[] memory ai = new REVAutoIssuance[](1);
148
+ ai[0] = REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(70_000e18), beneficiary: multisig()});
149
+
150
+ REVStageConfig[] memory stages = new REVStageConfig[](1);
151
+ stages[0] = REVStageConfig({
152
+ startsAtOrAfter: uint40(block.timestamp),
153
+ autoIssuances: ai,
154
+ splitPercent: 2000,
155
+ splits: splits,
156
+ initialIssuance: uint112(1000e18),
157
+ issuanceCutFrequency: 90 days,
158
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
159
+ cashOutTaxRate: 6000,
160
+ extraMetadata: 0
161
+ });
162
+
163
+ REVConfig memory cfg = REVConfig({
164
+ // forge-lint: disable-next-line(named-struct-fields)
165
+ description: REVDescription("Revnet", "$REV", "ipfs://test", "REV_TOKEN"),
166
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
167
+ splitOperator: multisig(),
168
+ stageConfigurations: stages
169
+ });
170
+
171
+ vm.prank(multisig());
172
+ REV_DEPLOYER.deployFor({
173
+ revnetId: FEE_PROJECT_ID,
174
+ configuration: cfg,
175
+ terminalConfigurations: tc,
176
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
177
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("FEE")
178
+ }),
179
+ tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
180
+ allowedPosts: REVEmpty721Config.emptyAllowedPosts()
181
+ });
182
+ }
183
+
184
+ function _deployRevnet() internal {
185
+ JBAccountingContext[] memory acc = new JBAccountingContext[](2);
186
+ acc[0] = JBAccountingContext({
187
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
188
+ });
189
+ acc[1] = JBAccountingContext({token: address(TOKEN), decimals: 6, currency: uint32(uint160(address(TOKEN)))});
190
+ JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
191
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
192
+
193
+ JBSplit[] memory splits = new JBSplit[](1);
194
+ splits[0].beneficiary = payable(multisig());
195
+ splits[0].percent = 10_000;
196
+
197
+ REVAutoIssuance[] memory ai = new REVAutoIssuance[](1);
198
+ ai[0] = REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(70_000e18), beneficiary: multisig()});
199
+
200
+ REVStageConfig[] memory stages = new REVStageConfig[](1);
201
+ stages[0] = REVStageConfig({
202
+ startsAtOrAfter: uint40(block.timestamp),
203
+ autoIssuances: ai,
204
+ splitPercent: 2000,
205
+ splits: splits,
206
+ initialIssuance: uint112(1000e18),
207
+ issuanceCutFrequency: 90 days,
208
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
209
+ cashOutTaxRate: 6000,
210
+ extraMetadata: 0
211
+ });
212
+
213
+ REVConfig memory cfg = REVConfig({
214
+ // forge-lint: disable-next-line(named-struct-fields)
215
+ description: REVDescription("NANA", "$NANA", "ipfs://test2", "NANA_TOKEN"),
216
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
217
+ splitOperator: multisig(),
218
+ stageConfigurations: stages
219
+ });
220
+
221
+ (REVNET_ID,) = REV_DEPLOYER.deployFor({
222
+ revnetId: 0,
223
+ configuration: cfg,
224
+ terminalConfigurations: tc,
225
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
226
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("NANA")
227
+ }),
228
+ tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
229
+ allowedPosts: REVEmpty721Config.emptyAllowedPosts()
230
+ });
231
+ }
232
+
233
+ /// @notice Helper: take a loan from a given source (ETH or TOKEN).
234
+ function _borrowWithSource(
235
+ address user,
236
+ uint256 ethAmount,
237
+ REVLoanSource memory source
238
+ )
239
+ internal
240
+ returns (uint256 loanId, uint256 tokenCount, uint256 borrowAmount)
241
+ {
242
+ // Pay with ETH to get revnet tokens.
243
+ vm.prank(user);
244
+ tokenCount =
245
+ jbMultiTerminal().pay{value: ethAmount}(REVNET_ID, JBConstants.NATIVE_TOKEN, ethAmount, user, 0, "", "");
246
+
247
+ // Check borrowable amount for the given source.
248
+ borrowAmount = LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokenCount, 18, uint32(uint160(source.token)));
249
+ if (borrowAmount == 0) return (0, tokenCount, 0);
250
+
251
+ // Mock permission for burn.
252
+ mockExpect(
253
+ address(jbPermissions()),
254
+ abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), user, REVNET_ID, 11, true, true)),
255
+ abi.encode(true)
256
+ );
257
+
258
+ vm.prank(user);
259
+ (loanId,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(user), 25);
260
+ }
261
+
262
+ //*********************************************************************//
263
+ // --- Loan Source Rotation Tests ------------------------------------ //
264
+ //*********************************************************************//
265
+
266
+ /// @notice First loan uses ETH source. A second loan from a different user uses TOKEN source.
267
+ /// Both should coexist and the loan sources array should reflect both.
268
+ function test_loanFromMultipleSources() public {
269
+ address user2 = makeAddr("user2");
270
+ vm.deal(user2, 100e18);
271
+
272
+ // Loan 1: borrow from ETH source.
273
+ REVLoanSource memory ethSource = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
274
+ (uint256 loanId1,,) = _borrowWithSource(USER, 10e18, ethSource);
275
+ require(loanId1 != 0, "ETH loan setup failed");
276
+
277
+ // Verify ETH is now a loan source.
278
+ assertTrue(
279
+ LOANS_CONTRACT.isLoanSourceOf(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN),
280
+ "ETH should be registered as a loan source"
281
+ );
282
+
283
+ // Loan 2: borrow from TOKEN source (need to fund the terminal with TOKEN first).
284
+ // Use addToBalanceOf to properly register TOKEN in the revnet's accounting.
285
+ uint256 tokenFunding = 1_000_000e6;
286
+ TOKEN.mint(address(this), tokenFunding);
287
+ TOKEN.approve(address(jbMultiTerminal()), tokenFunding);
288
+ jbMultiTerminal().addToBalanceOf(REVNET_ID, address(TOKEN), tokenFunding, false, "", "");
289
+
290
+ TOKEN.mint(user2, 100_000e6);
291
+
292
+ // User2 pays ETH to get revnet tokens.
293
+ vm.prank(user2);
294
+ uint256 user2Tokens =
295
+ jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, user2, 0, "", "");
296
+
297
+ REVLoanSource memory tokenSource = REVLoanSource({token: address(TOKEN), terminal: jbMultiTerminal()});
298
+ uint256 tokenBorrowable =
299
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, user2Tokens, 6, uint32(uint160(address(TOKEN))));
300
+
301
+ if (tokenBorrowable > 0) {
302
+ mockExpect(
303
+ address(jbPermissions()),
304
+ abi.encodeCall(
305
+ IJBPermissions.hasPermission, (address(LOANS_CONTRACT), user2, REVNET_ID, 11, true, true)
306
+ ),
307
+ abi.encode(true)
308
+ );
309
+
310
+ vm.prank(user2);
311
+ (uint256 loanId2,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, tokenSource, 0, user2Tokens, payable(user2), 25);
312
+ assertGt(loanId2, 0, "TOKEN loan should be created");
313
+
314
+ // Both sources should now be registered.
315
+ assertTrue(
316
+ LOANS_CONTRACT.isLoanSourceOf(REVNET_ID, jbMultiTerminal(), address(TOKEN)),
317
+ "TOKEN should be registered as a loan source"
318
+ );
319
+
320
+ // Loan sources array should have both entries.
321
+ REVLoanSource[] memory sources = LOANS_CONTRACT.loanSourcesOf(REVNET_ID);
322
+ assertGe(sources.length, 2, "should have at least 2 loan sources");
323
+ }
324
+ }
325
+
326
+ /// @notice After taking a loan from ETH source, repay it fully. The source remains registered
327
+ /// (sources array only grows, never shrinks).
328
+ function test_repayEthLoan_sourcePersistsAfterRepay() public {
329
+ REVLoanSource memory ethSource = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
330
+
331
+ (uint256 loanId,,) = _borrowWithSource(USER, 10e18, ethSource);
332
+ require(loanId != 0, "Loan setup failed");
333
+
334
+ // Verify loan is active.
335
+ REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
336
+ assertGt(loan.amount, 0, "loan should have a positive amount");
337
+ assertGt(loan.collateral, 0, "loan should have collateral");
338
+
339
+ // Record the total collateral before repay.
340
+ uint256 collateralBefore = LOANS_CONTRACT.totalCollateralOf(REVNET_ID);
341
+
342
+ // Calculate source fee for the full repay.
343
+ uint256 sourceFee = LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
344
+ uint256 repayTotal = loan.amount + sourceFee;
345
+
346
+ // Repay the full loan.
347
+ vm.deal(USER, repayTotal + 1e18); // Ensure enough ETH for repay.
348
+ vm.prank(USER);
349
+ LOANS_CONTRACT.repayLoan{value: repayTotal}(
350
+ loanId,
351
+ repayTotal,
352
+ loan.collateral, // Return all collateral.
353
+ payable(USER),
354
+ JBSingleAllowance({sigDeadline: 0, amount: 0, expiration: 0, nonce: 0, signature: ""})
355
+ );
356
+
357
+ // Source should still be registered (sources array only grows).
358
+ assertTrue(
359
+ LOANS_CONTRACT.isLoanSourceOf(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN),
360
+ "ETH source should persist after loan repay"
361
+ );
362
+
363
+ // Collateral should be decreased.
364
+ uint256 collateralAfter = LOANS_CONTRACT.totalCollateralOf(REVNET_ID);
365
+ assertLt(collateralAfter, collateralBefore, "total collateral should decrease after full repay");
366
+ }
367
+
368
+ /// @notice Take two sequential loans from different sources. Verify the second loan does not affect
369
+ /// the first loan's terms (collateral, amount, source).
370
+ function test_secondSourceDoesNotAffectFirstLoan() public {
371
+ REVLoanSource memory ethSource = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
372
+
373
+ // First loan with ETH source.
374
+ (uint256 loanId1,,) = _borrowWithSource(USER, 10e18, ethSource);
375
+ require(loanId1 != 0, "First loan setup failed");
376
+
377
+ // Capture first loan details.
378
+ REVLoan memory loan1Before = LOANS_CONTRACT.loanOf(loanId1);
379
+ uint256 totalBorrowedFromEthBefore =
380
+ LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
381
+
382
+ // Second loan: also from ETH source but by a different user.
383
+ address user2 = makeAddr("user2");
384
+ vm.deal(user2, 100e18);
385
+
386
+ REVLoanSource memory ethSource2 = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
387
+ vm.prank(user2);
388
+ uint256 user2Tokens =
389
+ jbMultiTerminal().pay{value: 5e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 5e18, user2, 0, "", "");
390
+ uint256 user2Borrowable =
391
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, user2Tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
392
+
393
+ if (user2Borrowable > 0) {
394
+ mockExpect(
395
+ address(jbPermissions()),
396
+ abi.encodeCall(
397
+ IJBPermissions.hasPermission, (address(LOANS_CONTRACT), user2, REVNET_ID, 11, true, true)
398
+ ),
399
+ abi.encode(true)
400
+ );
401
+
402
+ vm.prank(user2);
403
+ (uint256 loanId2,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, ethSource2, 0, user2Tokens, payable(user2), 25);
404
+ assertGt(loanId2, 0, "second loan should be created");
405
+
406
+ // First loan should be unaffected.
407
+ REVLoan memory loan1After = LOANS_CONTRACT.loanOf(loanId1);
408
+ assertEq(loan1After.amount, loan1Before.amount, "first loan amount should be unchanged");
409
+ assertEq(loan1After.collateral, loan1Before.collateral, "first loan collateral should be unchanged");
410
+ assertEq(loan1After.source.token, loan1Before.source.token, "first loan source token should be unchanged");
411
+ assertEq(
412
+ address(loan1After.source.terminal),
413
+ address(loan1Before.source.terminal),
414
+ "first loan source terminal should be unchanged"
415
+ );
416
+
417
+ // Total borrowed from ETH source should have increased.
418
+ uint256 totalBorrowedFromEthAfter =
419
+ LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
420
+ assertGt(
421
+ totalBorrowedFromEthAfter,
422
+ totalBorrowedFromEthBefore,
423
+ "total borrowed from ETH should increase with new loan"
424
+ );
425
+ }
426
+ }
427
+
428
+ /// @notice The fee calculation should be consistent regardless of which source is used.
429
+ /// Both ETH and TOKEN sources should use the same prepaid fee percent logic.
430
+ function test_feeCalculationConsistency_acrossSources() public {
431
+ REVLoanSource memory ethSource = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
432
+
433
+ // Take ETH loan.
434
+ (uint256 ethLoanId,,) = _borrowWithSource(USER, 10e18, ethSource);
435
+ require(ethLoanId != 0, "ETH loan failed");
436
+
437
+ REVLoan memory ethLoan = LOANS_CONTRACT.loanOf(ethLoanId);
438
+
439
+ // Verify the prepaid fee percent is what we set (25 = 2.5%, the minimum).
440
+ assertEq(ethLoan.prepaidFeePercent, 25, "ETH loan should have 2.5% prepaid fee");
441
+
442
+ // Verify the prepaid duration is consistent with the fee percent.
443
+ // prepaidDuration = prepaidFeePercent * LOAN_LIQUIDATION_DURATION / MAX_PREPAID_FEE_PERCENT.
444
+ uint256 expectedDuration =
445
+ (25 * LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION()) / LOANS_CONTRACT.MAX_PREPAID_FEE_PERCENT();
446
+ assertEq(ethLoan.prepaidDuration, expectedDuration, "prepaid duration should match formula");
447
+
448
+ // Verify source fee amount is nonzero for a nonzero loan.
449
+ uint256 sourceFee = LOANS_CONTRACT.determineSourceFeeAmount(ethLoan, ethLoan.amount);
450
+ // Within the prepaid window, the source fee should be zero (already prepaid).
451
+ // After the prepaid window, fees accumulate linearly.
452
+ assertEq(sourceFee, 0, "source fee should be 0 within prepaid window");
453
+
454
+ // Warp well past the prepaid duration (halfway through the remaining loan term).
455
+ // With prepaid=25/500, prepaid covers ~182.5 days. Warp an additional 5 years past that
456
+ // so the fee calculation is significant enough to not round to zero.
457
+ vm.warp(block.timestamp + ethLoan.prepaidDuration + 365 days * 5);
458
+
459
+ // Now the source fee should be > 0 because we are well past the prepaid window.
460
+ uint256 sourceFeeAfter = LOANS_CONTRACT.determineSourceFeeAmount(ethLoan, ethLoan.amount);
461
+ assertGt(sourceFeeAfter, 0, "source fee should be nonzero well after prepaid window expires");
462
+ }
463
+
464
+ /// @notice Verify that totalBorrowedFrom is tracked per-source and does not bleed across sources.
465
+ function test_totalBorrowedFrom_isolatedPerSource() public {
466
+ REVLoanSource memory ethSource = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
467
+
468
+ // Take ETH loan.
469
+ (uint256 loanId,,) = _borrowWithSource(USER, 10e18, ethSource);
470
+ require(loanId != 0, "Loan failed");
471
+
472
+ // Check totalBorrowedFrom for ETH source.
473
+ uint256 totalBorrowedEth =
474
+ LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
475
+ assertGt(totalBorrowedEth, 0, "should have nonzero total borrowed from ETH");
476
+
477
+ // TOKEN source should have zero total borrowed.
478
+ uint256 totalBorrowedToken = LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), address(TOKEN));
479
+ assertEq(totalBorrowedToken, 0, "TOKEN source should have zero total borrowed");
480
+ }
481
+
482
+ /// @notice Verify that taking a loan, then time passing, then taking another loan from the same source
483
+ /// correctly increments the loan counter.
484
+ function test_loanCounterIncrements_acrossTimePeriods() public {
485
+ REVLoanSource memory ethSource = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
486
+
487
+ uint256 countBefore = LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID);
488
+
489
+ // First loan.
490
+ (uint256 loanId1,,) = _borrowWithSource(USER, 5e18, ethSource);
491
+ require(loanId1 != 0, "First loan failed");
492
+
493
+ uint256 countAfterFirst = LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID);
494
+ assertEq(countAfterFirst, countBefore + 1, "counter should increment by 1");
495
+
496
+ // Warp 30 days.
497
+ vm.warp(block.timestamp + 30 days);
498
+
499
+ // Second loan.
500
+ address user2 = makeAddr("user2_counter");
501
+ vm.deal(user2, 100e18);
502
+
503
+ vm.prank(user2);
504
+ uint256 tokens = jbMultiTerminal().pay{value: 5e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 5e18, user2, 0, "", "");
505
+ uint256 borrowable =
506
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
507
+
508
+ if (borrowable > 0) {
509
+ mockExpect(
510
+ address(jbPermissions()),
511
+ abi.encodeCall(
512
+ IJBPermissions.hasPermission, (address(LOANS_CONTRACT), user2, REVNET_ID, 11, true, true)
513
+ ),
514
+ abi.encode(true)
515
+ );
516
+
517
+ vm.prank(user2);
518
+ (uint256 loanId2,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, ethSource, 0, tokens, payable(user2), 25);
519
+ assertGt(loanId2, 0, "second loan should succeed");
520
+
521
+ uint256 countAfterSecond = LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID);
522
+ assertEq(countAfterSecond, countBefore + 2, "counter should increment by 2 total");
523
+ }
524
+ }
525
+ }