@rev-net/core-v6 0.0.37 → 0.0.40

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 (112) 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 +69 -67
  9. package/src/REVHiddenTokens.sol +2 -2
  10. package/src/REVLoans.sol +26 -22
  11. package/src/REVOwner.sol +147 -29
  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/src/structs/REVAutoIssuance.sol +4 -2
  16. package/src/structs/REVConfig.sol +8 -5
  17. package/src/structs/REVDescription.sol +6 -5
  18. package/src/structs/REVLoan.sol +8 -5
  19. package/src/structs/REVStageConfig.sol +14 -16
  20. package/ADMINISTRATION.md +0 -73
  21. package/ARCHITECTURE.md +0 -116
  22. package/AUDIT_INSTRUCTIONS.md +0 -90
  23. package/RISKS.md +0 -107
  24. package/SKILLS.md +0 -46
  25. package/STYLE_GUIDE.md +0 -610
  26. package/USER_JOURNEYS.md +0 -195
  27. package/foundry.lock +0 -11
  28. package/slither-ci.config.json +0 -10
  29. package/sphinx.lock +0 -507
  30. package/test/REV.integrations.t.sol +0 -573
  31. package/test/REVAutoIssuanceFuzz.t.sol +0 -328
  32. package/test/REVDeployerRegressions.t.sol +0 -396
  33. package/test/REVInvincibility.t.sol +0 -1371
  34. package/test/REVInvincibilityHandler.sol +0 -387
  35. package/test/REVLifecycle.t.sol +0 -420
  36. package/test/REVLoans.invariants.t.sol +0 -724
  37. package/test/REVLoansAttacks.t.sol +0 -816
  38. package/test/REVLoansFeeRecovery.t.sol +0 -783
  39. package/test/REVLoansFindings.t.sol +0 -711
  40. package/test/REVLoansRegressions.t.sol +0 -364
  41. package/test/REVLoansSourceFeeRecovery.t.sol +0 -517
  42. package/test/REVLoansSourced.t.sol +0 -1839
  43. package/test/REVLoansUnSourced.t.sol +0 -409
  44. package/test/TestAuditFixVerification.t.sol +0 -675
  45. package/test/TestBurnHeldTokens.t.sol +0 -394
  46. package/test/TestCEIPattern.t.sol +0 -508
  47. package/test/TestCashOutCallerValidation.t.sol +0 -452
  48. package/test/TestConversionDocumentation.t.sol +0 -365
  49. package/test/TestCrossCurrencyReclaim.t.sol +0 -610
  50. package/test/TestCrossSourceReallocation.t.sol +0 -361
  51. package/test/TestERC2771MetaTx.t.sol +0 -585
  52. package/test/TestEmptyBuybackSpecs.t.sol +0 -300
  53. package/test/TestFlashLoanSurplus.t.sol +0 -365
  54. package/test/TestHiddenTokens.t.sol +0 -474
  55. package/test/TestHookArrayOOB.t.sol +0 -278
  56. package/test/TestLiquidationBehavior.t.sol +0 -398
  57. package/test/TestLoanSourceRotation.t.sol +0 -553
  58. package/test/TestLoansCashOutDelay.t.sol +0 -493
  59. package/test/TestLongTailEconomics.t.sol +0 -677
  60. package/test/TestLowFindings.t.sol +0 -677
  61. package/test/TestMixedFixes.t.sol +0 -593
  62. package/test/TestPermit2Signatures.t.sol +0 -683
  63. package/test/TestReallocationSandwich.t.sol +0 -412
  64. package/test/TestRevnetRegressions.t.sol +0 -350
  65. package/test/TestSplitWeightAdjustment.t.sol +0 -527
  66. package/test/TestSplitWeightE2E.t.sol +0 -605
  67. package/test/TestSplitWeightFork.t.sol +0 -855
  68. package/test/TestStageTransitionBorrowable.t.sol +0 -301
  69. package/test/TestSwapTerminalPermission.t.sol +0 -262
  70. package/test/TestTerminalEncodingInHash.t.sol +0 -326
  71. package/test/TestUint112Overflow.t.sol +0 -311
  72. package/test/TestZeroAmountLoanGuard.t.sol +0 -378
  73. package/test/TestZeroRepayment.t.sol +0 -354
  74. package/test/audit/CrossChainBuybackRouteMismatch.t.sol +0 -184
  75. package/test/audit/HiddenSupplyCashout.t.sol +0 -61
  76. package/test/audit/LoanIdOverflowGuard.t.sol +0 -523
  77. package/test/audit/NemesisVerification.t.sol +0 -97
  78. package/test/audit/OperatorDelegation.t.sol +0 -356
  79. package/test/audit/PhantomSurplusTerminal.t.sol +0 -367
  80. package/test/audit/REVOwnerCurrencyMismatch.t.sol +0 -188
  81. package/test/audit/REVOwnerRemoteSurplusCurrencyMismatch.t.sol +0 -140
  82. package/test/audit/ReallocatePermission.t.sol +0 -363
  83. package/test/audit/RemoteLoanAccountingGap.t.sol +0 -74
  84. package/test/audit/SupportsInterfaceTest.t.sol +0 -51
  85. package/test/audit/TestFeeAllowanceLeak.t.sol +0 -197
  86. package/test/audit/TestLoansAndDeployerFixes.t.sol +0 -576
  87. package/test/fork/ForkTestBase.sol +0 -727
  88. package/test/fork/TestAutoIssuanceFork.t.sol +0 -148
  89. package/test/fork/TestCashOutFork.t.sol +0 -253
  90. package/test/fork/TestIssuanceDecayFork.t.sol +0 -158
  91. package/test/fork/TestLoanAdversarialFork.t.sol +0 -744
  92. package/test/fork/TestLoanBorrowFork.t.sol +0 -163
  93. package/test/fork/TestLoanCrossRulesetFork.t.sol +0 -308
  94. package/test/fork/TestLoanERC20Fork.t.sol +0 -459
  95. package/test/fork/TestLoanLiquidationFork.t.sol +0 -135
  96. package/test/fork/TestLoanReallocateFork.t.sol +0 -113
  97. package/test/fork/TestLoanRepayFork.t.sol +0 -188
  98. package/test/fork/TestLoanTransferFork.t.sol +0 -143
  99. package/test/fork/TestPermit2PaymentFork.t.sol +0 -300
  100. package/test/fork/TestSplitWeightFork.t.sol +0 -189
  101. package/test/helpers/MaliciousContracts.sol +0 -247
  102. package/test/helpers/REVEmpty721Config.sol +0 -45
  103. package/test/mock/MockBuybackCashOutRecorder.sol +0 -84
  104. package/test/mock/MockBuybackDataHook.sol +0 -112
  105. package/test/mock/MockBuybackDataHookMintPath.sol +0 -68
  106. package/test/mock/MockSuckerRegistry.sol +0 -17
  107. package/test/regression/TestBurnPermissionRequired.t.sol +0 -294
  108. package/test/regression/TestCashOutBuybackFeeLeak.t.sol +0 -232
  109. package/test/regression/TestCrossRevnetLiquidation.t.sol +0 -255
  110. package/test/regression/TestCumulativeLoanCounter.t.sol +0 -361
  111. package/test/regression/TestLiquidateGapHandling.t.sol +0 -394
  112. package/test/regression/TestZeroPriceFeed.t.sol +0 -422
@@ -1,163 +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 "./ForkTestBase.sol";
6
- import {JBFees} from "@bananapus/core-v6/src/libraries/JBFees.sol";
7
-
8
- /// @notice Fork tests for REVLoans.borrowFrom() with real Uniswap V4 buyback hook.
9
- ///
10
- /// Covers: basic borrow, fee distribution, and borrow after tier splits.
11
- ///
12
- /// Run with: FOUNDRY_PROFILE=fork forge test --match-contract TestLoanBorrowFork -vvv
13
- contract TestLoanBorrowFork is ForkTestBase {
14
- uint256 revnetId;
15
-
16
- function setUp() public override {
17
- super.setUp();
18
-
19
- // Deploy fee project + revnet with 50% cashOutTaxRate.
20
- _deployFeeProject(5000);
21
- revnetId = _deployRevnet(5000);
22
-
23
- // Set up pool at 1:1 (mint path wins).
24
- _setupPool(revnetId, 10_000 ether);
25
-
26
- // Pay to create surplus. PAYER gets tokens and BORROWER gets tokens.
27
- _payRevnet(revnetId, PAYER, 10 ether);
28
- _payRevnet(revnetId, BORROWER, 5 ether);
29
- }
30
-
31
- /// @notice Basic borrow: collateralize all borrower tokens, verify loan state.
32
- function test_fork_borrow_basic() public {
33
- uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
34
-
35
- uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
36
- revnetId, borrowerTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
37
- );
38
- assertGt(borrowable, 0, "should have borrowable amount");
39
-
40
- uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(revnetId);
41
- uint256 totalBorrowedBefore =
42
- LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
43
-
44
- uint256 borrowerEthBefore = BORROWER.balance;
45
-
46
- // Create the loan.
47
- (uint256 loanId, REVLoan memory loan) =
48
- _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
49
-
50
- // Verify loan state.
51
- assertEq(loan.collateral, borrowerTokens, "loan collateral should match");
52
- assertEq(loan.createdAt, block.timestamp, "loan createdAt should be now");
53
-
54
- // Borrower's original tokens are burned as collateral, but the source fee payment back to the revnet mints
55
- // some tokens to the borrower.
56
- uint256 feeTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
57
- assertGt(feeTokens, 0, "borrower should have tokens from source fee payment");
58
- assertLt(feeTokens, borrowerTokens, "fee tokens should be less than original collateral");
59
-
60
- // Borrower received ETH (net of fees).
61
- assertGt(BORROWER.balance, borrowerEthBefore, "borrower should receive ETH");
62
-
63
- // Tracking updated.
64
- assertEq(
65
- LOANS_CONTRACT.totalCollateralOf(revnetId),
66
- totalCollateralBefore + borrowerTokens,
67
- "totalCollateralOf should increase"
68
- );
69
- assertGt(
70
- LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), JBConstants.NATIVE_TOKEN),
71
- totalBorrowedBefore,
72
- "totalBorrowedFrom should increase"
73
- );
74
-
75
- // Loan NFT owned by borrower.
76
- assertEq(_loanOwnerOf(loanId), BORROWER, "loan NFT should be owned by borrower");
77
- }
78
-
79
- /// @notice Verify fee distribution: source fee (2.5%) + REV fee (1%) deducted correctly.
80
- function test_fork_borrow_feeDistribution() public {
81
- uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
82
- uint256 prepaidFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT(); // 25 = 2.5%
83
-
84
- uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
85
- revnetId, borrowerTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
86
- );
87
-
88
- // Record balances before.
89
- uint256 borrowerEthBefore = BORROWER.balance;
90
- _grantBurnPermission(BORROWER, revnetId);
91
-
92
- REVLoanSource memory source = _nativeLoanSource();
93
- vm.prank(BORROWER);
94
- LOANS_CONTRACT.borrowFrom({
95
- revnetId: revnetId,
96
- source: source,
97
- minBorrowAmount: 0,
98
- collateralCount: borrowerTokens,
99
- beneficiary: payable(BORROWER),
100
- prepaidFeePercent: prepaidFeePercent,
101
- holder: BORROWER
102
- });
103
-
104
- uint256 borrowerReceived = BORROWER.balance - borrowerEthBefore;
105
-
106
- // Calculate expected fees.
107
- // The allowance fee is taken by the terminal's useAllowanceOf (2.5% JB protocol fee).
108
- uint256 allowanceFee = JBFees.feeAmountFrom({amountBeforeFee: borrowable, feePercent: jbMultiTerminal().FEE()});
109
- // REV fee (1%).
110
- uint256 revFee =
111
- JBFees.feeAmountFrom({amountBeforeFee: borrowable, feePercent: LOANS_CONTRACT.REV_PREPAID_FEE_PERCENT()});
112
- // Source fee (prepaid).
113
- uint256 sourceFee = JBFees.feeAmountFrom({amountBeforeFee: borrowable, feePercent: prepaidFeePercent});
114
-
115
- uint256 totalFees = allowanceFee + revFee + sourceFee;
116
-
117
- // Borrower should receive borrowable - totalFees.
118
- assertApproxEqAbs(borrowerReceived, borrowable - totalFees, 10, "borrower net should match expected");
119
-
120
- // Loans contract should not hold any ETH.
121
- assertEq(address(LOANS_CONTRACT).balance, 0, "loans contract should not hold ETH");
122
- }
123
-
124
- /// @notice Borrow after a payment with 30% tier splits.
125
- function test_fork_borrow_afterTierSplits() public {
126
- // Deploy revnet with 721 hook.
127
- (uint256 splitRevnetId, IJB721TiersHook hook) = _deployRevnetWith721(5000);
128
- _setupPool(splitRevnetId, 10_000 ether);
129
-
130
- // Pay with tier metadata (30% split).
131
- address metadataTarget = hook.METADATA_ID_TARGET();
132
- bytes memory metadata = _buildPayMetadataNoQuote(metadataTarget);
133
-
134
- vm.prank(BORROWER);
135
- uint256 borrowerTokens = jbMultiTerminal().pay{value: 5 ether}({
136
- projectId: splitRevnetId,
137
- token: JBConstants.NATIVE_TOKEN,
138
- amount: 5 ether,
139
- beneficiary: BORROWER,
140
- minReturnedTokens: 0,
141
- memo: "",
142
- metadata: metadata
143
- });
144
-
145
- // Tier 1 costs 1 ETH with 30% split → 0.3 ETH to splits, 4.7 ETH minted at 1000 tokens/ETH = 4700 tokens.
146
- assertEq(borrowerTokens, 4700e18, "should get 4700 tokens after tier split");
147
-
148
- // Surplus should reflect actual terminal balance.
149
- uint256 surplus = _terminalBalance(splitRevnetId, JBConstants.NATIVE_TOKEN);
150
- assertGt(surplus, 0, "should have surplus");
151
-
152
- // Borrowable amount should be based on actual surplus, not full payment.
153
- uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
154
- splitRevnetId, borrowerTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
155
- );
156
-
157
- if (borrowable > 0) {
158
- (uint256 loanId,) =
159
- _createLoan(splitRevnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
160
- assertGt(loanId, 0, "loan should be created");
161
- }
162
- }
163
- }
@@ -1,308 +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 "./ForkTestBase.sol";
6
- import {REVEmpty721Config} from "../helpers/REVEmpty721Config.sol";
7
-
8
- /// @notice Fork tests for loan lifecycle spanning multiple revnet stages (rulesets).
9
- ///
10
- /// Verifies that loans created in one stage (high cashOutTaxRate) can be correctly repaid
11
- /// or liquidated after transitioning to a different stage (low cashOutTaxRate). This is
12
- /// critical because the bonding curve parameters change between stages, affecting borrowable
13
- /// amounts, collateral value, and fee calculations.
14
- ///
15
- /// Run with: FOUNDRY_PROFILE=fork forge test --match-contract TestLoanCrossRulesetFork -vvv
16
- contract TestLoanCrossRulesetFork is ForkTestBase {
17
- uint256 revnetId;
18
- uint256 constant STAGE_DURATION = 30 days;
19
-
20
- /// @notice Build a two-stage config: stage 1 (high tax), stage 2 (low tax).
21
- function _buildTwoStageConfig(
22
- uint16 stage1TaxRate,
23
- uint16 stage2TaxRate
24
- )
25
- internal
26
- view
27
- returns (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc)
28
- {
29
- JBAccountingContext[] memory acc = new JBAccountingContext[](1);
30
- acc[0] = JBAccountingContext({
31
- token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
32
- });
33
- tc = new JBTerminalConfig[](1);
34
- tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
35
-
36
- REVStageConfig[] memory stages = new REVStageConfig[](2);
37
- JBSplit[] memory splits = new JBSplit[](1);
38
- splits[0].beneficiary = payable(multisig());
39
- splits[0].percent = 10_000;
40
-
41
- // Stage 1: high tax — starts immediately.
42
- stages[0] = REVStageConfig({
43
- startsAtOrAfter: uint40(block.timestamp),
44
- autoIssuances: new REVAutoIssuance[](0),
45
- splitPercent: 0,
46
- splits: splits,
47
- initialIssuance: INITIAL_ISSUANCE,
48
- issuanceCutFrequency: 0,
49
- issuanceCutPercent: 0,
50
- cashOutTaxRate: stage1TaxRate,
51
- extraMetadata: 0
52
- });
53
-
54
- // Stage 2: low tax — starts after STAGE_DURATION.
55
- stages[1] = REVStageConfig({
56
- // forge-lint: disable-next-line(unsafe-typecast)
57
- startsAtOrAfter: uint40(block.timestamp + STAGE_DURATION),
58
- autoIssuances: new REVAutoIssuance[](0),
59
- splitPercent: 0,
60
- splits: splits,
61
- initialIssuance: INITIAL_ISSUANCE,
62
- issuanceCutFrequency: 0,
63
- issuanceCutPercent: 0,
64
- cashOutTaxRate: stage2TaxRate,
65
- extraMetadata: 0
66
- });
67
-
68
- cfg = REVConfig({
69
- // forge-lint: disable-next-line(named-struct-fields)
70
- description: REVDescription("CrossStage", "XSTG", "ipfs://xstage", "XSTG_SALT"),
71
- baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
72
- splitOperator: multisig(),
73
- stageConfigurations: stages
74
- });
75
-
76
- sdc = REVSuckerDeploymentConfig({
77
- deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("XSTG"))
78
- });
79
- }
80
-
81
- function setUp() public override {
82
- super.setUp();
83
-
84
- // Deploy fee project with 50% tax.
85
- _deployFeeProject(5000);
86
-
87
- // Deploy two-stage revnet: 70% tax → 20% tax after 30 days.
88
- (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) =
89
- _buildTwoStageConfig(7000, 2000);
90
-
91
- (revnetId,) = REV_DEPLOYER.deployFor({
92
- revnetId: 0,
93
- configuration: cfg,
94
- terminalConfigurations: tc,
95
- suckerDeploymentConfiguration: sdc,
96
- tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
97
- allowedPosts: REVEmpty721Config.emptyAllowedPosts()
98
- });
99
-
100
- // Set up pool at 1:1 (mint path wins).
101
- _setupPool(revnetId, 10_000 ether);
102
-
103
- // Create surplus with multiple payers so bonding curve tax has visible effect.
104
- _payRevnet(revnetId, PAYER, 10 ether);
105
- _payRevnet(revnetId, BORROWER, 5 ether);
106
-
107
- address otherPayer = makeAddr("otherPayer");
108
- vm.deal(otherPayer, 10 ether);
109
- _payRevnet(revnetId, otherPayer, 5 ether);
110
- }
111
-
112
- /// @notice Borrow in stage 1 (70% tax), repay in stage 2 (20% tax). Repayment should succeed
113
- /// and return full collateral regardless of the tax rate change.
114
- function test_fork_crossStage_borrowStage1_repayStage2() public {
115
- uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
116
- assertGt(borrowerTokens, 0, "borrower should have tokens");
117
-
118
- // Record borrowable in stage 1.
119
- uint256 borrowableStage1 = LOANS_CONTRACT.borrowableAmountFrom(
120
- revnetId, borrowerTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
121
- );
122
- assertGt(borrowableStage1, 0, "should have borrowable amount in stage 1");
123
-
124
- // Create loan in stage 1.
125
- (uint256 loanId, REVLoan memory loan) =
126
- _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
127
- assertGt(loanId, 0, "loan should be created");
128
- assertEq(loan.collateral, borrowerTokens, "collateral should match");
129
-
130
- // Record fee tokens minted to borrower from source fee payment back to revnet.
131
- uint256 feeTokensFromLoan = jbTokens().totalBalanceOf(BORROWER, revnetId);
132
-
133
- // Warp past stage 1 into stage 2.
134
- vm.warp(block.timestamp + STAGE_DURATION + 1);
135
-
136
- // Verify borrowable amount changed (should be higher with lower tax).
137
- uint256 borrowableStage2 = LOANS_CONTRACT.borrowableAmountFrom(
138
- revnetId, borrowerTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
139
- );
140
- assertGt(borrowableStage2, borrowableStage1, "borrowable should increase with lower tax");
141
-
142
- // Repay the loan in stage 2: return all collateral.
143
- vm.deal(BORROWER, 100 ether);
144
- JBSingleAllowance memory allowance;
145
-
146
- vm.prank(BORROWER);
147
- LOANS_CONTRACT.repayLoan{value: loan.amount * 2}({
148
- loanId: loanId,
149
- maxRepayBorrowAmount: loan.amount * 2,
150
- collateralCountToReturn: loan.collateral,
151
- beneficiary: payable(BORROWER),
152
- allowance: allowance
153
- });
154
-
155
- // After repayment, borrower gets collateral back (plus fee tokens from loan creation).
156
- uint256 borrowerTokensAfter = jbTokens().totalBalanceOf(BORROWER, revnetId);
157
- assertEq(borrowerTokensAfter, borrowerTokens + feeTokensFromLoan, "borrower should recover full collateral");
158
-
159
- // Loan NFT should be burned.
160
- vm.expectRevert();
161
- _loanOwnerOf(loanId);
162
- }
163
-
164
- /// @notice Borrow in stage 1, attempt to liquidate in stage 2 before expiry. Should skip.
165
- function test_fork_crossStage_borrowStage1_liquidateStage2_notExpired() public {
166
- uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
167
-
168
- // Create loan in stage 1.
169
- (uint256 loanId,) = _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
170
-
171
- // Warp to stage 2 (but NOT past 10-year expiry).
172
- vm.warp(block.timestamp + STAGE_DURATION + 1);
173
-
174
- // Attempt liquidation — should skip this loan since it's not expired.
175
- // Loan number is 1 (first loan for this revnet), count = 1.
176
- LOANS_CONTRACT.liquidateExpiredLoansFrom(revnetId, 1, 1);
177
-
178
- // Loan should still exist.
179
- assertEq(_loanOwnerOf(loanId), BORROWER, "loan should not be liquidated");
180
- }
181
-
182
- /// @notice Borrow in stage 1, liquidate after 10-year expiry (spans far beyond both stages).
183
- function test_fork_crossStage_borrowStage1_liquidateAfterExpiry() public {
184
- uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
185
-
186
- // Create loan in stage 1.
187
- (uint256 loanId,) = _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
188
-
189
- uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(revnetId);
190
-
191
- // Warp past 10-year expiry (well beyond both stages).
192
- vm.warp(block.timestamp + LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION() + 1);
193
-
194
- // Liquidate starting from loan number 1, count = 1.
195
- LOANS_CONTRACT.liquidateExpiredLoansFrom(revnetId, 1, 1);
196
-
197
- // Loan NFT should be burned.
198
- vm.expectRevert();
199
- _loanOwnerOf(loanId);
200
-
201
- // Collateral is permanently lost (burned during borrow, not returned on liquidation).
202
- uint256 totalCollateralAfter = LOANS_CONTRACT.totalCollateralOf(revnetId);
203
- assertEq(totalCollateralAfter, totalCollateralBefore - borrowerTokens, "total collateral should decrease");
204
- }
205
-
206
- /// @notice Partial repay in stage 1, complete repay in stage 2.
207
- function test_fork_crossStage_partialRepayStage1_completeRepayStage2() public {
208
- uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
209
-
210
- // Create loan in stage 1.
211
- (uint256 loanId, REVLoan memory loan) =
212
- _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
213
-
214
- // Record fee tokens minted to borrower from source fee payment back to revnet.
215
- uint256 feeTokensFromLoan = jbTokens().totalBalanceOf(BORROWER, revnetId);
216
-
217
- // Partial repay in stage 1: return half the collateral.
218
- uint256 halfCollateral = loan.collateral / 2;
219
-
220
- vm.deal(BORROWER, 100 ether);
221
- JBSingleAllowance memory allowance;
222
-
223
- vm.prank(BORROWER);
224
- (uint256 newLoanId,) = LOANS_CONTRACT.repayLoan{value: loan.amount * 2}({
225
- loanId: loanId,
226
- maxRepayBorrowAmount: loan.amount * 2,
227
- collateralCountToReturn: halfCollateral,
228
- beneficiary: payable(BORROWER),
229
- allowance: allowance
230
- });
231
-
232
- // Old loan should be replaced, new loan created for remainder.
233
- assertGt(newLoanId, 0, "new loan should be created for remainder");
234
-
235
- uint256 borrowerTokensMid = jbTokens().totalBalanceOf(BORROWER, revnetId);
236
- assertGt(borrowerTokensMid, 0, "borrower should get partial collateral back");
237
-
238
- // Warp to stage 2.
239
- vm.warp(block.timestamp + STAGE_DURATION + 1);
240
-
241
- // Complete repay in stage 2.
242
- REVLoan memory remainingLoan = LOANS_CONTRACT.loanOf(newLoanId);
243
-
244
- vm.prank(BORROWER);
245
- LOANS_CONTRACT.repayLoan{value: remainingLoan.amount * 2}({
246
- loanId: newLoanId,
247
- maxRepayBorrowAmount: remainingLoan.amount * 2,
248
- collateralCountToReturn: remainingLoan.collateral,
249
- beneficiary: payable(BORROWER),
250
- allowance: allowance
251
- });
252
-
253
- // All collateral should be recovered (plus fee tokens from loan creation).
254
- uint256 borrowerTokensFinal = jbTokens().totalBalanceOf(BORROWER, revnetId);
255
- assertEq(
256
- borrowerTokensFinal,
257
- borrowerTokens + feeTokensFromLoan,
258
- "should recover full collateral after two repayments"
259
- );
260
- }
261
-
262
- /// @notice Reallocate a loan created in stage 1 while in stage 2.
263
- function test_fork_crossStage_reallocateInStage2() public {
264
- uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
265
-
266
- // Create loan in stage 1.
267
- (uint256 loanId, REVLoan memory loan) =
268
- _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
269
-
270
- // Warp to stage 2.
271
- vm.warp(block.timestamp + STAGE_DURATION + 1);
272
-
273
- // Reallocate a small fraction (5%) to a new loan. Using a small fraction ensures the remaining
274
- // collateral still supports the existing borrow amount (bonding curve non-linearity).
275
- REVLoanSource memory source = _nativeLoanSource();
276
- uint256 transferAmount = loan.collateral / 20;
277
-
278
- // Grant burn permission for the new loan.
279
- _grantBurnPermission(BORROWER, revnetId);
280
-
281
- // Cache before prank to avoid consuming the prank with a static call.
282
- uint256 minFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT();
283
-
284
- vm.prank(BORROWER);
285
- (uint256 reallocatedLoanId, uint256 newLoanId, REVLoan memory reallocatedLoan,) = LOANS_CONTRACT.reallocateCollateralFromLoan({
286
- loanId: loanId,
287
- collateralCountToTransfer: transferAmount,
288
- source: source,
289
- minBorrowAmount: 0,
290
- collateralCountToAdd: 0,
291
- beneficiary: payable(BORROWER),
292
- prepaidFeePercent: minFeePercent
293
- });
294
-
295
- // Original loan burned, reallocated loan created.
296
- vm.expectRevert();
297
- _loanOwnerOf(loanId);
298
-
299
- // Both new loans should exist.
300
- assertEq(_loanOwnerOf(reallocatedLoanId), BORROWER, "reallocated loan should exist");
301
- assertEq(_loanOwnerOf(newLoanId), BORROWER, "new loan should exist");
302
-
303
- // Reallocated loan should have reduced collateral.
304
- assertEq(
305
- reallocatedLoan.collateral, loan.collateral - transferAmount, "reallocated collateral should be reduced"
306
- );
307
- }
308
- }