@rev-net/core-v6 0.0.6 → 0.0.7

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 (27) hide show
  1. package/SKILLS.md +1 -1
  2. package/docs/book.toml +1 -1
  3. package/docs/src/README.md +151 -54
  4. package/docs/src/SUMMARY.md +0 -2
  5. package/docs/src/src/REVDeployer.sol/contract.REVDeployer.md +148 -117
  6. package/docs/src/src/REVLoans.sol/contract.REVLoans.md +120 -59
  7. package/docs/src/src/interfaces/IREVDeployer.sol/interface.IREVDeployer.md +296 -14
  8. package/docs/src/src/interfaces/IREVLoans.sol/interface.IREVLoans.md +318 -16
  9. package/docs/src/src/structs/README.md +0 -2
  10. package/docs/src/src/structs/REVAutoIssuance.sol/struct.REVAutoIssuance.md +4 -4
  11. package/docs/src/src/structs/REVConfig.sol/struct.REVConfig.md +5 -17
  12. package/docs/src/src/structs/REVCroptopAllowedPost.sol/struct.REVCroptopAllowedPost.md +10 -6
  13. package/docs/src/src/structs/REVDeploy721TiersHookConfig.sol/struct.REVDeploy721TiersHookConfig.md +7 -7
  14. package/docs/src/src/structs/REVDescription.sol/struct.REVDescription.md +5 -5
  15. package/docs/src/src/structs/REVLoan.sol/struct.REVLoan.md +7 -7
  16. package/docs/src/src/structs/REVLoanSource.sol/struct.REVLoanSource.md +3 -3
  17. package/docs/src/src/structs/REVStageConfig.sol/struct.REVStageConfig.md +10 -10
  18. package/docs/src/src/structs/REVSuckerDeploymentConfig.sol/struct.REVSuckerDeploymentConfig.md +3 -3
  19. package/package.json +6 -6
  20. package/slither-ci.config.json +1 -1
  21. package/src/REVLoans.sol +20 -9
  22. package/src/interfaces/IREVDeployer.sol +0 -2
  23. package/src/interfaces/IREVLoans.sol +10 -4
  24. package/test/TestPR27_CEIPattern.t.sol +2 -2
  25. package/test/TestPR32_MixedFixes.t.sol +1 -1
  26. package/test/regression/TestI20_CumulativeLoanCounter.t.sol +303 -0
  27. package/test/regression/TestL27_LiquidateGapHandling.t.sol +334 -0
@@ -1,5 +1,5 @@
1
1
  # REVLoan
2
- [Git Source](https://github.com/rev-net/revnet-core-v5/blob/364afaae78a8f60af2b98252dc96af1c2e4760d3/src/structs/REVLoan.sol)
2
+ [Git Source](https://github.com/rev-net/revnet-core-v6/blob/94c003a3a16de2bd012d63cccedd6bd38d21f6e7/src/structs/REVLoan.sol)
3
3
 
4
4
  **Notes:**
5
5
  - member: borrowedAmount The amount that is being borrowed.
@@ -17,12 +17,12 @@
17
17
 
18
18
  ```solidity
19
19
  struct REVLoan {
20
- uint112 amount;
21
- uint112 collateral;
22
- uint48 createdAt;
23
- uint16 prepaidFeePercent;
24
- uint32 prepaidDuration;
25
- REVLoanSource source;
20
+ uint112 amount;
21
+ uint112 collateral;
22
+ uint48 createdAt;
23
+ uint16 prepaidFeePercent;
24
+ uint32 prepaidDuration;
25
+ REVLoanSource source;
26
26
  }
27
27
  ```
28
28
 
@@ -1,5 +1,5 @@
1
1
  # REVLoanSource
2
- [Git Source](https://github.com/rev-net/revnet-core-v5/blob/364afaae78a8f60af2b98252dc96af1c2e4760d3/src/structs/REVLoanSource.sol)
2
+ [Git Source](https://github.com/rev-net/revnet-core-v6/blob/94c003a3a16de2bd012d63cccedd6bd38d21f6e7/src/structs/REVLoanSource.sol)
3
3
 
4
4
  **Notes:**
5
5
  - member: token The token that is being loaned.
@@ -9,8 +9,8 @@
9
9
 
10
10
  ```solidity
11
11
  struct REVLoanSource {
12
- address token;
13
- IJBPayoutTerminal terminal;
12
+ address token;
13
+ IJBPayoutTerminal terminal;
14
14
  }
15
15
  ```
16
16
 
@@ -1,5 +1,5 @@
1
1
  # REVStageConfig
2
- [Git Source](https://github.com/rev-net/revnet-core-v5/blob/364afaae78a8f60af2b98252dc96af1c2e4760d3/src/structs/REVStageConfig.sol)
2
+ [Git Source](https://github.com/rev-net/revnet-core-v6/blob/94c003a3a16de2bd012d63cccedd6bd38d21f6e7/src/structs/REVStageConfig.sol)
3
3
 
4
4
  **Notes:**
5
5
  - member: startsAtOrAfter The timestamp to start a stage at the given rate at or after.
@@ -30,15 +30,15 @@ out.
30
30
 
31
31
  ```solidity
32
32
  struct REVStageConfig {
33
- uint48 startsAtOrAfter;
34
- REVAutoIssuance[] autoIssuances;
35
- uint16 splitPercent;
36
- JBSplit[] splits;
37
- uint112 initialIssuance;
38
- uint32 issuanceCutFrequency;
39
- uint32 issuanceCutPercent;
40
- uint16 cashOutTaxRate;
41
- uint16 extraMetadata;
33
+ uint48 startsAtOrAfter;
34
+ REVAutoIssuance[] autoIssuances;
35
+ uint16 splitPercent;
36
+ JBSplit[] splits;
37
+ uint112 initialIssuance;
38
+ uint32 issuanceCutFrequency;
39
+ uint32 issuanceCutPercent;
40
+ uint16 cashOutTaxRate;
41
+ uint16 extraMetadata;
42
42
  }
43
43
  ```
44
44
 
@@ -1,5 +1,5 @@
1
1
  # REVSuckerDeploymentConfig
2
- [Git Source](https://github.com/rev-net/revnet-core-v5/blob/364afaae78a8f60af2b98252dc96af1c2e4760d3/src/structs/REVSuckerDeploymentConfig.sol)
2
+ [Git Source](https://github.com/rev-net/revnet-core-v6/blob/94c003a3a16de2bd012d63cccedd6bd38d21f6e7/src/structs/REVSuckerDeploymentConfig.sol)
3
3
 
4
4
  **Notes:**
5
5
  - member: deployerConfigurations The information for how to suck tokens to other chains.
@@ -9,8 +9,8 @@
9
9
 
10
10
  ```solidity
11
11
  struct REVSuckerDeploymentConfig {
12
- JBSuckerDeployerConfig[] deployerConfigurations;
13
- bytes32 salt;
12
+ JBSuckerDeployerConfig[] deployerConfigurations;
13
+ bytes32 salt;
14
14
  }
15
15
  ```
16
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rev-net/core-v6",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,11 +17,11 @@
17
17
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'revnet-core-v6'"
18
18
  },
19
19
  "dependencies": {
20
- "@bananapus/721-hook-v6": "^0.0.7",
21
- "@bananapus/buyback-hook-v6": "^0.0.4",
22
- "@bananapus/core-v6": "^0.0.5",
23
- "@bananapus/permission-ids-v6": "^0.0.3",
24
- "@bananapus/suckers-v6": "^0.0.4",
20
+ "@bananapus/721-hook-v6": "^0.0.9",
21
+ "@bananapus/buyback-hook-v6": "^0.0.7",
22
+ "@bananapus/core-v6": "^0.0.9",
23
+ "@bananapus/permission-ids-v6": "^0.0.4",
24
+ "@bananapus/suckers-v6": "^0.0.6",
25
25
  "@bananapus/router-terminal-v6": "^0.0.6",
26
26
  "@croptop/core-v6": "^0.0.6",
27
27
  "@openzeppelin/contracts": "^5.2.0"
@@ -5,6 +5,6 @@
5
5
  "exclude_medium": false,
6
6
  "exclude_high": false,
7
7
  "disable_color": false,
8
- "filter_paths": "(mocks/|test/|node_modules/)",
8
+ "filter_paths": "(mocks/|test/|node_modules/|lib/)",
9
9
  "legacy_ast": false
10
10
  }
package/src/REVLoans.sol CHANGED
@@ -134,9 +134,12 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
134
134
  public
135
135
  override isLoanSourceOf;
136
136
 
137
- /// @notice The amount of loans that have been created.
138
- /// @custom:param revnetId The ID of the revnet to get the number of loans from.
139
- mapping(uint256 revnetId => uint256) public override numberOfLoansFor;
137
+ /// @notice The cumulative number of loans ever created for a revnet, used as a loan ID sequence counter.
138
+ /// @dev This counter only increments (on borrow, repay-with-new-loan, and reallocation) and never decrements.
139
+ /// It does NOT represent the number of currently active loans. Repaid and liquidated loans leave permanent gaps
140
+ /// in the ID sequence. Integrators should not use this to count active loans.
141
+ /// @custom:param revnetId The ID of the revnet to get the cumulative loan count from.
142
+ mapping(uint256 revnetId => uint256) public override totalLoansBorrowedFor;
140
143
 
141
144
  /// @notice The contract resolving each project ID to its ERC721 URI.
142
145
  IJBTokenUriResolver public override tokenUriResolver;
@@ -158,6 +161,10 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
158
161
  //*********************************************************************//
159
162
 
160
163
  /// @notice The sources of each revnet's loan.
164
+ /// @dev This array grows monotonically -- entries are appended when a new (terminal, token) pair is first used for
165
+ /// borrowing, but are never removed. The `isLoanSourceOf` mapping tracks whether a source has been registered.
166
+ /// Since the number of distinct (terminal, token) pairs per revnet is practically bounded (typically < 10),
167
+ /// the gas cost of iterating this array in `loanSourcesOf` remains manageable.
161
168
  /// @custom:member revnetId The ID of the revnet issuing the loan.
162
169
  mapping(uint256 revnetId => REVLoanSource[]) internal _loanSourcesOf;
163
170
 
@@ -231,6 +238,8 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
231
238
  }
232
239
 
233
240
  /// @notice The sources of each revnet's loan.
241
+ /// @dev This array only grows -- sources are never removed. The number of distinct sources is practically bounded
242
+ /// by the number of unique (terminal, token) pairs used for borrowing, which is typically small.
234
243
  /// @custom:member revnetId The ID of the revnet issuing the loan.
235
244
  function loanSourcesOf(uint256 revnetId) external view override returns (REVLoanSource[] memory) {
236
245
  return _loanSourcesOf[revnetId];
@@ -557,7 +566,7 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
557
566
  }
558
567
 
559
568
  // Get a reference to the loan ID.
560
- loanId = _generateLoanId({revnetId: revnetId, loanNumber: ++numberOfLoansFor[revnetId]});
569
+ loanId = _generateLoanId({revnetId: revnetId, loanNumber: ++totalLoansBorrowedFor[revnetId]});
561
570
 
562
571
  // Get a reference to the loan being created.
563
572
  REVLoan storage loan = _loanOf[loanId];
@@ -627,9 +636,9 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
627
636
  // Get a reference to the loan being iterated on.
628
637
  REVLoan memory loan = _loanOf[loanId];
629
638
 
630
- // If the loan doesn't exist, there's nothing left to liquidate.
639
+ // If the loan doesn't exist (repaid or already liquidated), skip past this gap and continue.
631
640
  // slither-disable-next-line incorrect-equality
632
- if (loan.createdAt == 0) break;
641
+ if (loan.createdAt == 0) continue;
633
642
 
634
643
  // Keep a reference to the loan's owner.
635
644
  address owner = _ownerOf(loanId);
@@ -775,7 +784,7 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
775
784
 
776
785
  // Revert if this repayment would do nothing — no borrow amount repaid and no collateral returned.
777
786
  // Without this check, a zero-amount repayment would burn the old loan NFT and mint a new one,
778
- // incrementing numberOfLoansFor without limit.
787
+ // incrementing totalLoansBorrowedFor without limit.
779
788
  if (repayBorrowAmount == 0 && collateralCountToReturn == 0) revert REVLoans_NothingToRepay();
780
789
 
781
790
  // Keep a reference to the fee that'll be taken.
@@ -862,6 +871,8 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
862
871
  internal
863
872
  {
864
873
  // Register the source if this is the first time its being used for this revnet.
874
+ // Note: Sources are only appended, never removed. This is acceptable because the number of distinct
875
+ // (terminal, token) pairs per revnet is practically bounded.
865
876
  if (!isLoanSourceOf[revnetId][loan.source.terminal][loan.source.token]) {
866
877
  isLoanSourceOf[revnetId][loan.source.terminal][loan.source.token] = true;
867
878
  _loanSourcesOf[revnetId].push(REVLoanSource({token: loan.source.token, terminal: loan.source.terminal}));
@@ -1139,7 +1150,7 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
1139
1150
  } else {
1140
1151
  // Make a new loan with the remaining amount and collateral.
1141
1152
  // Get a reference to the replacement loan ID.
1142
- uint256 paidOffLoanId = _generateLoanId({revnetId: revnetId, loanNumber: ++numberOfLoansFor[revnetId]});
1153
+ uint256 paidOffLoanId = _generateLoanId({revnetId: revnetId, loanNumber: ++totalLoansBorrowedFor[revnetId]});
1143
1154
 
1144
1155
  // Get a reference to the loan being paid off.
1145
1156
  REVLoan storage paidOffLoan = _loanOf[paidOffLoanId];
@@ -1220,7 +1231,7 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
1220
1231
  }
1221
1232
 
1222
1233
  // Get a reference to the replacement loan ID.
1223
- reallocatedLoanId = _generateLoanId({revnetId: revnetId, loanNumber: ++numberOfLoansFor[revnetId]});
1234
+ reallocatedLoanId = _generateLoanId({revnetId: revnetId, loanNumber: ++totalLoansBorrowedFor[revnetId]});
1224
1235
 
1225
1236
  // Get a reference to the loan being created.
1226
1237
  reallocatedLoan = _loanOf[reallocatedLoanId];
@@ -47,8 +47,6 @@ interface IREVDeployer {
47
47
  uint256 indexed revnetId, uint256 indexed stageId, address indexed beneficiary, uint256 count, address caller
48
48
  );
49
49
 
50
- event SetAdditionalOperator(uint256 revnetId, address additionalOperator, uint256[] permissionIds, address caller);
51
-
52
50
  event BurnHeldTokens(uint256 indexed revnetId, uint256 count, address caller);
53
51
 
54
52
  /// @notice The number of seconds until a revnet's participants can cash out after deploying to a new network.
@@ -129,14 +129,20 @@ interface IREVLoans {
129
129
  function loanOf(uint256 loanId) external view returns (REVLoan memory);
130
130
 
131
131
  /// @notice The sources of each revnet's loans.
132
+ /// @dev This array only grows -- sources are appended when a new (terminal, token) pair is first used for
133
+ /// borrowing, but are never removed. Gas cost scales linearly with the number of distinct sources, though this is
134
+ /// practically bounded to a small number of unique (terminal, token) pairs.
132
135
  /// @param revnetId The ID of the revnet to get the loan sources for.
133
136
  /// @return The array of loan sources.
134
137
  function loanSourcesOf(uint256 revnetId) external view returns (REVLoanSource[] memory);
135
138
 
136
- /// @notice The number of loans that have been created for a revnet.
137
- /// @param revnetId The ID of the revnet to get the loan count for.
138
- /// @return The number of loans.
139
- function numberOfLoansFor(uint256 revnetId) external view returns (uint256);
139
+ /// @notice The cumulative number of loans ever created for a revnet, used as a loan ID sequence counter.
140
+ /// @dev This counter only increments and never decrements. It does NOT represent the count of currently active
141
+ /// loans -- repaid and liquidated loans leave permanent gaps in the sequence. Do not use this value to determine
142
+ /// how many loans are currently outstanding.
143
+ /// @param revnetId The ID of the revnet to get the cumulative loan count for.
144
+ /// @return The cumulative number of loans ever created.
145
+ function totalLoansBorrowedFor(uint256 revnetId) external view returns (uint256);
140
146
 
141
147
  /// @notice The revnet ID for the loan with the provided loan ID.
142
148
  /// @param loanId The loan ID to get the revnet ID of.
@@ -335,8 +335,8 @@ contract TestPR27_CEIPattern is TestBaseWorkflow, JBTest {
335
335
  REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
336
336
 
337
337
  // Pre-compute the loanId so the attacker can read it during reentrancy.
338
- // loanId = revnetId * 1_000_000_000_000 + (numberOfLoansFor + 1)
339
- uint256 expectedLoanId = REVNET_ID * 1_000_000_000_000 + (LOANS_CONTRACT.numberOfLoansFor(REVNET_ID) + 1);
338
+ // loanId = revnetId * 1_000_000_000_000 + (totalLoansBorrowedFor + 1)
339
+ uint256 expectedLoanId = REVNET_ID * 1_000_000_000_000 + (LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID) + 1);
340
340
  attacker.setTarget(expectedLoanId);
341
341
 
342
342
  // Borrow with attacker as beneficiary — attacker's receive() will fire when ETH arrives.
@@ -516,7 +516,7 @@ contract TestPR32_MixedFixes is TestBaseWorkflow, JBTest {
516
516
  assertTrue(tokenBorrowed > 0, "TOKEN borrow should be tracked");
517
517
 
518
518
  // The total number of loans should be 2.
519
- assertEq(LOANS_CONTRACT.numberOfLoansFor(MIXED_REVNET_ID), 2, "Should have 2 loans");
519
+ assertEq(LOANS_CONTRACT.totalLoansBorrowedFor(MIXED_REVNET_ID), 2, "Should have 2 loans");
520
520
 
521
521
  // Query borrowable in 18-decimal ETH context. This exercises _totalBorrowedFrom's
522
522
  // cross-decimal normalization: TOKEN borrows (6 dec) must be normalized to 18 dec before
@@ -0,0 +1,303 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import "forge-std/Test.sol";
5
+ import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
6
+ import /* {*} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
7
+ import /* {*} from */ "./../../src/REVDeployer.sol";
8
+ import "@croptop/core-v6/src/CTPublisher.sol";
9
+ import {MockBuybackDataHook} from "./../mock/MockBuybackDataHook.sol";
10
+ import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
11
+ import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
12
+ import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
13
+ import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
14
+ import "@bananapus/router-terminal-v6/script/helpers/RouterTerminalDeploymentLib.sol";
15
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
16
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
17
+ import {JBSingleAllowance} from "@bananapus/core-v6/src/structs/JBSingleAllowance.sol";
18
+ import {MockPriceFeed} from "@bananapus/core-v6/test/mock/MockPriceFeed.sol";
19
+ import {MockERC20} from "@bananapus/core-v6/test/mock/MockERC20.sol";
20
+ import {REVLoans} from "../../src/REVLoans.sol";
21
+ import {REVLoan} from "../../src/structs/REVLoan.sol";
22
+ import {REVStageConfig, REVAutoIssuance} from "../../src/structs/REVStageConfig.sol";
23
+ import {REVLoanSource} from "../../src/structs/REVLoanSource.sol";
24
+ import {REVDescription} from "../../src/structs/REVDescription.sol";
25
+ import {IREVLoans} from "../../src/interfaces/IREVLoans.sol";
26
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
27
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
28
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
29
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
30
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
31
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
32
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
33
+
34
+ /// @notice Regression test for I-20: totalLoansBorrowedFor is a cumulative counter, not an active loan count.
35
+ /// @dev The rename from numberOfLoansFor to totalLoansBorrowedFor clarifies that the counter only increments
36
+ /// and never decrements. Repaying or liquidating a loan does NOT reduce the counter. This test verifies that
37
+ /// the counter remains at its high-water mark after loans are fully repaid and after loans are liquidated.
38
+ contract TestI20_CumulativeLoanCounter is TestBaseWorkflow, JBTest {
39
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
40
+
41
+ REVDeployer REV_DEPLOYER;
42
+ JB721TiersHook EXAMPLE_HOOK;
43
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
44
+ IJB721TiersHookStore HOOK_STORE;
45
+ IJBAddressRegistry ADDRESS_REGISTRY;
46
+ REVLoans LOANS_CONTRACT;
47
+ IJBSuckerRegistry SUCKER_REGISTRY;
48
+ CTPublisher PUBLISHER;
49
+ MockBuybackDataHook MOCK_BUYBACK;
50
+
51
+ uint256 FEE_PROJECT_ID;
52
+ uint256 REVNET_ID;
53
+
54
+ address USER1 = makeAddr("user1");
55
+ address USER2 = makeAddr("user2");
56
+ address USER3 = makeAddr("user3");
57
+
58
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
59
+
60
+ function setUp() public override {
61
+ super.setUp();
62
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
63
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
64
+ HOOK_STORE = new JB721TiersHookStore();
65
+ EXAMPLE_HOOK = new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, multisig());
66
+ ADDRESS_REGISTRY = new JBAddressRegistry();
67
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
68
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
69
+ MOCK_BUYBACK = new MockBuybackDataHook();
70
+ MockPriceFeed priceFeed = new MockPriceFeed(1e18, 18);
71
+ vm.prank(multisig());
72
+ jbPrices()
73
+ .addPriceFeedFor(
74
+ 0, uint32(uint160(JBConstants.NATIVE_TOKEN)), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed
75
+ );
76
+ LOANS_CONTRACT = new REVLoans({
77
+ controller: jbController(),
78
+ projects: jbProjects(),
79
+ revId: FEE_PROJECT_ID,
80
+ owner: address(this),
81
+ permit2: permit2(),
82
+ trustedForwarder: TRUSTED_FORWARDER
83
+ });
84
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
85
+ jbController(),
86
+ SUCKER_REGISTRY,
87
+ FEE_PROJECT_ID,
88
+ HOOK_DEPLOYER,
89
+ PUBLISHER,
90
+ IJBRulesetDataHook(address(MOCK_BUYBACK)),
91
+ address(LOANS_CONTRACT),
92
+ TRUSTED_FORWARDER
93
+ );
94
+ vm.prank(multisig());
95
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
96
+ _deployFeeProject();
97
+ _deployRevnet();
98
+ vm.deal(USER1, 100e18);
99
+ vm.deal(USER2, 100e18);
100
+ vm.deal(USER3, 100e18);
101
+ }
102
+
103
+ function _deployFeeProject() internal {
104
+ JBAccountingContext[] memory acc = new JBAccountingContext[](1);
105
+ acc[0] = JBAccountingContext({
106
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
107
+ });
108
+ JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
109
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
110
+ REVStageConfig[] memory stages = new REVStageConfig[](1);
111
+ JBSplit[] memory splits = new JBSplit[](1);
112
+ splits[0].beneficiary = payable(multisig());
113
+ splits[0].percent = 10_000;
114
+ REVAutoIssuance[] memory ai = new REVAutoIssuance[](1);
115
+ ai[0] = REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(70_000e18), beneficiary: multisig()});
116
+ stages[0] = REVStageConfig({
117
+ startsAtOrAfter: uint40(block.timestamp),
118
+ autoIssuances: ai,
119
+ splitPercent: 2000,
120
+ splits: splits,
121
+ initialIssuance: uint112(1000e18),
122
+ issuanceCutFrequency: 90 days,
123
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
124
+ cashOutTaxRate: 6000,
125
+ extraMetadata: 0
126
+ });
127
+ REVConfig memory cfg = REVConfig({
128
+ description: REVDescription("Revnet", "$REV", "ipfs://test", "REV_TOKEN"),
129
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
130
+ splitOperator: multisig(),
131
+ stageConfigurations: stages
132
+ });
133
+ vm.prank(multisig());
134
+ REV_DEPLOYER.deployFor({
135
+ revnetId: FEE_PROJECT_ID,
136
+ configuration: cfg,
137
+ terminalConfigurations: tc,
138
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
139
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("FEE")
140
+ })
141
+ });
142
+ }
143
+
144
+ function _deployRevnet() internal {
145
+ JBAccountingContext[] memory acc = new JBAccountingContext[](1);
146
+ acc[0] = JBAccountingContext({
147
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
148
+ });
149
+ JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
150
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
151
+ REVStageConfig[] memory stages = new REVStageConfig[](1);
152
+ JBSplit[] memory splits = new JBSplit[](1);
153
+ splits[0].beneficiary = payable(multisig());
154
+ splits[0].percent = 10_000;
155
+ REVAutoIssuance[] memory ai = new REVAutoIssuance[](1);
156
+ ai[0] = REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(70_000e18), beneficiary: multisig()});
157
+ stages[0] = REVStageConfig({
158
+ startsAtOrAfter: uint40(block.timestamp),
159
+ autoIssuances: ai,
160
+ splitPercent: 2000,
161
+ splits: splits,
162
+ initialIssuance: uint112(1000e18),
163
+ issuanceCutFrequency: 90 days,
164
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
165
+ cashOutTaxRate: 6000,
166
+ extraMetadata: 0
167
+ });
168
+ REVConfig memory cfg = REVConfig({
169
+ description: REVDescription("NANA", "$NANA", "ipfs://test2", "NANA_TOKEN"),
170
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
171
+ splitOperator: multisig(),
172
+ stageConfigurations: stages
173
+ });
174
+ REVNET_ID = REV_DEPLOYER.deployFor({
175
+ revnetId: 0,
176
+ configuration: cfg,
177
+ terminalConfigurations: tc,
178
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
179
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("NANA")
180
+ })
181
+ });
182
+ }
183
+
184
+ function _setupLoan(address user, uint256 ethAmount) internal returns (uint256 loanId, uint256 tokenCount) {
185
+ vm.prank(user);
186
+ tokenCount =
187
+ jbMultiTerminal().pay{value: ethAmount}(REVNET_ID, JBConstants.NATIVE_TOKEN, ethAmount, user, 0, "", "");
188
+ uint256 borrowAmount =
189
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
190
+ require(borrowAmount > 0, "Borrow amount should be > 0");
191
+ mockExpect(
192
+ address(jbPermissions()),
193
+ abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), user, REVNET_ID, 11, true, true)),
194
+ abi.encode(true)
195
+ );
196
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
197
+ vm.prank(user);
198
+ (loanId,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(user), 25);
199
+ }
200
+
201
+ /// @notice Verifies totalLoansBorrowedFor never decrements after loan repayment.
202
+ /// @dev Creates 3 loans, fully repays 2, then verifies the counter stays at 3 (not 1).
203
+ /// This confirms the I-20 rename correctly reflects cumulative semantics.
204
+ function test_I20_counterNeverDecrementsAfterRepayment() public {
205
+ // Counter starts at 0
206
+ assertEq(LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID), 0, "Counter should start at 0");
207
+
208
+ // Create 3 loans
209
+ (uint256 loanId1,) = _setupLoan(USER1, 3e18);
210
+ (uint256 loanId2,) = _setupLoan(USER2, 3e18);
211
+ (uint256 loanId3,) = _setupLoan(USER3, 3e18);
212
+
213
+ // Counter should be 3
214
+ assertEq(LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID), 3, "Counter should be 3 after 3 loans");
215
+
216
+ // Fully repay loan 1
217
+ REVLoan memory loan1 = LOANS_CONTRACT.loanOf(loanId1);
218
+ JBSingleAllowance memory allowance;
219
+ vm.prank(USER1);
220
+ LOANS_CONTRACT.repayLoan{value: loan1.amount}(
221
+ loanId1, loan1.amount, loan1.collateral, payable(USER1), allowance
222
+ );
223
+
224
+ // Counter should still be 3 (NOT 2) -- repayment does not decrement
225
+ assertEq(
226
+ LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID),
227
+ 3,
228
+ "Counter should remain 3 after repaying loan 1 -- cumulative, never decrements"
229
+ );
230
+
231
+ // Fully repay loan 2
232
+ REVLoan memory loan2 = LOANS_CONTRACT.loanOf(loanId2);
233
+ vm.prank(USER2);
234
+ LOANS_CONTRACT.repayLoan{value: loan2.amount}(
235
+ loanId2, loan2.amount, loan2.collateral, payable(USER2), allowance
236
+ );
237
+
238
+ // Counter should still be 3 (NOT 1)
239
+ assertEq(
240
+ LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID),
241
+ 3,
242
+ "Counter should remain 3 after repaying loan 2 -- cumulative, never decrements"
243
+ );
244
+
245
+ // Verify the loans are actually deleted (createdAt == 0)
246
+ assertEq(LOANS_CONTRACT.loanOf(loanId1).createdAt, 0, "Loan 1 should be deleted");
247
+ assertEq(LOANS_CONTRACT.loanOf(loanId2).createdAt, 0, "Loan 2 should be deleted");
248
+ assertTrue(LOANS_CONTRACT.loanOf(loanId3).createdAt > 0, "Loan 3 should still exist");
249
+ }
250
+
251
+ /// @notice Verifies totalLoansBorrowedFor never decrements after loan liquidation.
252
+ /// @dev Creates 2 loans, liquidates both, then verifies the counter stays at 2.
253
+ function test_I20_counterNeverDecrementsAfterLiquidation() public {
254
+ // Create 2 loans
255
+ (uint256 loanId1,) = _setupLoan(USER1, 5e18);
256
+ (uint256 loanId2,) = _setupLoan(USER2, 5e18);
257
+
258
+ assertEq(LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID), 2, "Counter should be 2 after 2 loans");
259
+
260
+ // Warp past liquidation duration
261
+ vm.warp(block.timestamp + LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION() + 1);
262
+
263
+ // Liquidate both loans
264
+ LOANS_CONTRACT.liquidateExpiredLoansFrom(REVNET_ID, 1, 2);
265
+
266
+ // Both loans should be deleted
267
+ assertEq(LOANS_CONTRACT.loanOf(loanId1).createdAt, 0, "Loan 1 should be liquidated");
268
+ assertEq(LOANS_CONTRACT.loanOf(loanId2).createdAt, 0, "Loan 2 should be liquidated");
269
+
270
+ // Counter should still be 2 -- liquidation does not decrement
271
+ assertEq(
272
+ LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID),
273
+ 2,
274
+ "Counter should remain 2 after liquidating both loans -- cumulative, never decrements"
275
+ );
276
+ }
277
+
278
+ /// @notice Verifies that partial repayment (which creates a new loan) increments the counter.
279
+ /// @dev When partially repaying, the old loan is burned and a new loan is minted for the remainder.
280
+ /// This should increment the counter by 1 since a new loan ID is generated.
281
+ function test_I20_partialRepaymentIncrementsCounter() public {
282
+ // Create 1 loan
283
+ (uint256 loanId1,) = _setupLoan(USER1, 5e18);
284
+ assertEq(LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID), 1, "Counter should be 1 after 1 loan");
285
+
286
+ // Partially repay (repay half the borrow amount, return no collateral)
287
+ REVLoan memory loan1 = LOANS_CONTRACT.loanOf(loanId1);
288
+ uint256 halfAmount = loan1.amount / 2;
289
+ JBSingleAllowance memory allowance;
290
+ vm.prank(USER1);
291
+ LOANS_CONTRACT.repayLoan{value: halfAmount}(loanId1, halfAmount, 0, payable(USER1), allowance);
292
+
293
+ // Counter should be 2: original loan (burned) + replacement loan (new ID)
294
+ assertEq(
295
+ LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID),
296
+ 2,
297
+ "Counter should be 2 after partial repayment creates a replacement loan"
298
+ );
299
+
300
+ // Original loan should be deleted
301
+ assertEq(LOANS_CONTRACT.loanOf(loanId1).createdAt, 0, "Original loan should be deleted after partial repay");
302
+ }
303
+ }