@rev-net/core-v6 0.0.8 → 0.0.10
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.
- package/ADMINISTRATION.md +186 -0
- package/ARCHITECTURE.md +87 -0
- package/README.md +8 -6
- package/RISKS.md +49 -0
- package/SKILLS.md +24 -10
- package/STYLE_GUIDE.md +558 -0
- package/docs/src/README.md +2 -2
- package/foundry.toml +9 -6
- package/package.json +12 -9
- package/remappings.txt +1 -1
- package/script/Deploy.s.sol +4 -3
- package/script/helpers/RevnetCoreDeploymentLib.sol +1 -1
- package/src/REVDeployer.sol +103 -76
- package/src/REVLoans.sol +14 -4
- package/src/interfaces/IREVDeployer.sol +2 -1
- package/src/structs/REV721TiersHookFlags.sol +14 -0
- package/src/structs/REVBaseline721HookConfig.sol +27 -0
- package/src/structs/REVDeploy721TiersHookConfig.sol +2 -2
- package/test/REV.integrations.t.sol +4 -3
- package/test/REVAutoIssuanceFuzz.t.sol +12 -8
- package/test/{REVDeployerAuditRegressions.t.sol → REVDeployerRegressions.t.sol} +4 -3
- package/test/REVInvincibility.t.sol +23 -25
- package/test/REVInvincibilityHandler.sol +1 -0
- package/test/REVLifecycle.t.sol +4 -4
- package/test/REVLoans.invariants.t.sol +5 -3
- package/test/REVLoansAttacks.t.sol +7 -10
- package/test/REVLoansFeeRecovery.t.sol +4 -5
- package/test/REVLoansFindings.t.sol +644 -0
- package/test/{REVLoansAuditRegressions.t.sol → REVLoansRegressions.t.sol} +14 -25
- package/test/REVLoansSourced.t.sol +7 -4
- package/test/REVLoansUnSourced.t.sol +4 -3
- package/test/{TestPR26_BurnHeldTokens.t.sol → TestBurnHeldTokens.t.sol} +4 -3
- package/test/{TestPR27_CEIPattern.t.sol → TestCEIPattern.t.sol} +6 -5
- package/test/{TestPR15_CashOutCallerValidation.t.sol → TestCashOutCallerValidation.t.sol} +4 -5
- package/test/{TestPR09_ConversionDocumentation.t.sol → TestConversionDocumentation.t.sol} +4 -3
- package/test/{TestPR13_CrossSourceReallocation.t.sol → TestCrossSourceReallocation.t.sol} +4 -3
- package/test/TestEmptyBuybackSpecs.t.sol +4 -3
- package/test/{TestPR12_FlashLoanSurplus.t.sol → TestFlashLoanSurplus.t.sol} +4 -3
- package/test/{TestPR22_HookArrayOOB.t.sol → TestHookArrayOOB.t.sol} +4 -3
- package/test/{TestPR10_LiquidationBehavior.t.sol → TestLiquidationBehavior.t.sol} +7 -6
- package/test/{TestPR11_LowFindings.t.sol → TestLowFindings.t.sol} +4 -3
- package/test/{TestPR32_MixedFixes.t.sol → TestMixedFixes.t.sol} +4 -3
- package/test/TestSplitWeightAdjustment.t.sol +445 -0
- package/test/TestSplitWeightE2E.t.sol +528 -0
- package/test/TestSplitWeightFork.t.sol +780 -0
- package/test/TestStageTransitionBorrowable.t.sol +4 -3
- package/test/{TestPR29_SwapTerminalPermission.t.sol → TestSwapTerminalPermission.t.sol} +4 -3
- package/test/{TestPR21_Uint112Overflow.t.sol → TestUint112Overflow.t.sol} +7 -6
- package/test/{TestPR16_ZeroRepayment.t.sol → TestZeroRepayment.t.sol} +7 -8
- package/test/fork/ForkTestBase.sol +649 -0
- package/test/fork/TestCashOutFork.t.sol +246 -0
- package/test/fork/TestLoanBorrowFork.t.sol +161 -0
- package/test/fork/TestLoanCrossRulesetFork.t.sol +300 -0
- package/test/fork/TestLoanLiquidationFork.t.sol +134 -0
- package/test/fork/TestLoanReallocateFork.t.sol +112 -0
- package/test/fork/TestLoanRepayFork.t.sol +187 -0
- package/test/fork/TestSplitWeightFork.t.sol +186 -0
- package/test/mock/MockBuybackDataHook.sol +9 -9
- package/test/mock/MockBuybackDataHookMintPath.sol +10 -9
- package/test/regression/{TestI20_CumulativeLoanCounter.t.sol → TestCumulativeLoanCounter.t.sol} +9 -8
- package/test/regression/{TestL27_LiquidateGapHandling.t.sol → TestLiquidateGapHandling.t.sol} +9 -8
- package/SECURITY.md +0 -68
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.26;
|
|
3
|
+
|
|
4
|
+
import "./ForkTestBase.sol";
|
|
5
|
+
import {JBFees} from "@bananapus/core-v6/src/libraries/JBFees.sol";
|
|
6
|
+
|
|
7
|
+
/// @notice Fork tests for loan lifecycle spanning multiple revnet stages (rulesets).
|
|
8
|
+
///
|
|
9
|
+
/// Verifies that loans created in one stage (high cashOutTaxRate) can be correctly repaid
|
|
10
|
+
/// or liquidated after transitioning to a different stage (low cashOutTaxRate). This is
|
|
11
|
+
/// critical because the bonding curve parameters change between stages, affecting borrowable
|
|
12
|
+
/// amounts, collateral value, and fee calculations.
|
|
13
|
+
///
|
|
14
|
+
/// Run with: FOUNDRY_PROFILE=fork forge test --match-contract TestLoanCrossRulesetFork -vvv
|
|
15
|
+
contract TestLoanCrossRulesetFork is ForkTestBase {
|
|
16
|
+
uint256 revnetId;
|
|
17
|
+
uint256 constant STAGE_DURATION = 30 days;
|
|
18
|
+
|
|
19
|
+
/// @notice Build a two-stage config: stage 1 (high tax), stage 2 (low tax).
|
|
20
|
+
function _buildTwoStageConfig(
|
|
21
|
+
uint16 stage1TaxRate,
|
|
22
|
+
uint16 stage2TaxRate
|
|
23
|
+
)
|
|
24
|
+
internal
|
|
25
|
+
view
|
|
26
|
+
returns (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc)
|
|
27
|
+
{
|
|
28
|
+
JBAccountingContext[] memory acc = new JBAccountingContext[](1);
|
|
29
|
+
acc[0] = JBAccountingContext({
|
|
30
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
31
|
+
});
|
|
32
|
+
tc = new JBTerminalConfig[](1);
|
|
33
|
+
tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
|
|
34
|
+
|
|
35
|
+
REVStageConfig[] memory stages = new REVStageConfig[](2);
|
|
36
|
+
JBSplit[] memory splits = new JBSplit[](1);
|
|
37
|
+
splits[0].beneficiary = payable(multisig());
|
|
38
|
+
splits[0].percent = 10_000;
|
|
39
|
+
|
|
40
|
+
// Stage 1: high tax — starts immediately.
|
|
41
|
+
stages[0] = REVStageConfig({
|
|
42
|
+
startsAtOrAfter: uint40(block.timestamp),
|
|
43
|
+
autoIssuances: new REVAutoIssuance[](0),
|
|
44
|
+
splitPercent: 0,
|
|
45
|
+
splits: splits,
|
|
46
|
+
initialIssuance: INITIAL_ISSUANCE,
|
|
47
|
+
issuanceCutFrequency: 0,
|
|
48
|
+
issuanceCutPercent: 0,
|
|
49
|
+
cashOutTaxRate: stage1TaxRate,
|
|
50
|
+
extraMetadata: 0
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Stage 2: low tax — starts after STAGE_DURATION.
|
|
54
|
+
stages[1] = REVStageConfig({
|
|
55
|
+
startsAtOrAfter: uint40(block.timestamp + STAGE_DURATION),
|
|
56
|
+
autoIssuances: new REVAutoIssuance[](0),
|
|
57
|
+
splitPercent: 0,
|
|
58
|
+
splits: splits,
|
|
59
|
+
initialIssuance: INITIAL_ISSUANCE,
|
|
60
|
+
issuanceCutFrequency: 0,
|
|
61
|
+
issuanceCutPercent: 0,
|
|
62
|
+
cashOutTaxRate: stage2TaxRate,
|
|
63
|
+
extraMetadata: 0
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
cfg = REVConfig({
|
|
67
|
+
description: REVDescription("CrossStage", "XSTG", "ipfs://xstage", "XSTG_SALT"),
|
|
68
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
69
|
+
splitOperator: multisig(),
|
|
70
|
+
stageConfigurations: stages
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
sdc = REVSuckerDeploymentConfig({
|
|
74
|
+
deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("XSTG"))
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setUp() public override {
|
|
79
|
+
super.setUp();
|
|
80
|
+
|
|
81
|
+
// Deploy fee project with 50% tax.
|
|
82
|
+
_deployFeeProject(5000);
|
|
83
|
+
|
|
84
|
+
// Deploy two-stage revnet: 70% tax → 20% tax after 30 days.
|
|
85
|
+
(REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) =
|
|
86
|
+
_buildTwoStageConfig(7000, 2000);
|
|
87
|
+
|
|
88
|
+
revnetId = REV_DEPLOYER.deployFor({
|
|
89
|
+
revnetId: 0, configuration: cfg, terminalConfigurations: tc, suckerDeploymentConfiguration: sdc
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Set up pool at 1:1 (mint path wins).
|
|
93
|
+
_setupPool(revnetId, 10_000 ether);
|
|
94
|
+
|
|
95
|
+
// Create surplus with multiple payers so bonding curve tax has visible effect.
|
|
96
|
+
_payRevnet(revnetId, PAYER, 10 ether);
|
|
97
|
+
_payRevnet(revnetId, BORROWER, 5 ether);
|
|
98
|
+
|
|
99
|
+
address otherPayer = makeAddr("otherPayer");
|
|
100
|
+
vm.deal(otherPayer, 10 ether);
|
|
101
|
+
_payRevnet(revnetId, otherPayer, 5 ether);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// @notice Borrow in stage 1 (70% tax), repay in stage 2 (20% tax). Repayment should succeed
|
|
105
|
+
/// and return full collateral regardless of the tax rate change.
|
|
106
|
+
function test_fork_crossStage_borrowStage1_repayStage2() public {
|
|
107
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
108
|
+
assertGt(borrowerTokens, 0, "borrower should have tokens");
|
|
109
|
+
|
|
110
|
+
// Record borrowable in stage 1.
|
|
111
|
+
uint256 borrowableStage1 = LOANS_CONTRACT.borrowableAmountFrom(
|
|
112
|
+
revnetId, borrowerTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
113
|
+
);
|
|
114
|
+
assertGt(borrowableStage1, 0, "should have borrowable amount in stage 1");
|
|
115
|
+
|
|
116
|
+
// Create loan in stage 1.
|
|
117
|
+
(uint256 loanId, REVLoan memory loan) =
|
|
118
|
+
_createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
119
|
+
assertGt(loanId, 0, "loan should be created");
|
|
120
|
+
assertEq(loan.collateral, borrowerTokens, "collateral should match");
|
|
121
|
+
|
|
122
|
+
// Record fee tokens minted to borrower from source fee payment back to revnet.
|
|
123
|
+
uint256 feeTokensFromLoan = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
124
|
+
|
|
125
|
+
// Warp past stage 1 into stage 2.
|
|
126
|
+
vm.warp(block.timestamp + STAGE_DURATION + 1);
|
|
127
|
+
|
|
128
|
+
// Verify borrowable amount changed (should be higher with lower tax).
|
|
129
|
+
uint256 borrowableStage2 = LOANS_CONTRACT.borrowableAmountFrom(
|
|
130
|
+
revnetId, borrowerTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
131
|
+
);
|
|
132
|
+
assertGt(borrowableStage2, borrowableStage1, "borrowable should increase with lower tax");
|
|
133
|
+
|
|
134
|
+
// Repay the loan in stage 2: return all collateral.
|
|
135
|
+
vm.deal(BORROWER, 100 ether);
|
|
136
|
+
JBSingleAllowance memory allowance;
|
|
137
|
+
|
|
138
|
+
vm.prank(BORROWER);
|
|
139
|
+
LOANS_CONTRACT.repayLoan{value: loan.amount * 2}({
|
|
140
|
+
loanId: loanId,
|
|
141
|
+
maxRepayBorrowAmount: loan.amount * 2,
|
|
142
|
+
collateralCountToReturn: loan.collateral,
|
|
143
|
+
beneficiary: payable(BORROWER),
|
|
144
|
+
allowance: allowance
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// After repayment, borrower gets collateral back (plus fee tokens from loan creation).
|
|
148
|
+
uint256 borrowerTokensAfter = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
149
|
+
assertEq(borrowerTokensAfter, borrowerTokens + feeTokensFromLoan, "borrower should recover full collateral");
|
|
150
|
+
|
|
151
|
+
// Loan NFT should be burned.
|
|
152
|
+
vm.expectRevert();
|
|
153
|
+
_loanOwnerOf(loanId);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// @notice Borrow in stage 1, attempt to liquidate in stage 2 before expiry. Should skip.
|
|
157
|
+
function test_fork_crossStage_borrowStage1_liquidateStage2_notExpired() public {
|
|
158
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
159
|
+
|
|
160
|
+
// Create loan in stage 1.
|
|
161
|
+
(uint256 loanId,) = _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
162
|
+
|
|
163
|
+
// Warp to stage 2 (but NOT past 10-year expiry).
|
|
164
|
+
vm.warp(block.timestamp + STAGE_DURATION + 1);
|
|
165
|
+
|
|
166
|
+
// Attempt liquidation — should skip this loan since it's not expired.
|
|
167
|
+
// Loan number is 1 (first loan for this revnet), count = 1.
|
|
168
|
+
LOANS_CONTRACT.liquidateExpiredLoansFrom(revnetId, 1, 1);
|
|
169
|
+
|
|
170
|
+
// Loan should still exist.
|
|
171
|
+
assertEq(_loanOwnerOf(loanId), BORROWER, "loan should not be liquidated");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// @notice Borrow in stage 1, liquidate after 10-year expiry (spans far beyond both stages).
|
|
175
|
+
function test_fork_crossStage_borrowStage1_liquidateAfterExpiry() public {
|
|
176
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
177
|
+
|
|
178
|
+
// Create loan in stage 1.
|
|
179
|
+
(uint256 loanId,) = _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
180
|
+
|
|
181
|
+
uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(revnetId);
|
|
182
|
+
|
|
183
|
+
// Warp past 10-year expiry (well beyond both stages).
|
|
184
|
+
vm.warp(block.timestamp + LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION() + 1);
|
|
185
|
+
|
|
186
|
+
// Liquidate starting from loan number 1, count = 1.
|
|
187
|
+
LOANS_CONTRACT.liquidateExpiredLoansFrom(revnetId, 1, 1);
|
|
188
|
+
|
|
189
|
+
// Loan NFT should be burned.
|
|
190
|
+
vm.expectRevert();
|
|
191
|
+
_loanOwnerOf(loanId);
|
|
192
|
+
|
|
193
|
+
// Collateral is permanently lost (burned during borrow, not returned on liquidation).
|
|
194
|
+
uint256 totalCollateralAfter = LOANS_CONTRACT.totalCollateralOf(revnetId);
|
|
195
|
+
assertEq(totalCollateralAfter, totalCollateralBefore - borrowerTokens, "total collateral should decrease");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/// @notice Partial repay in stage 1, complete repay in stage 2.
|
|
199
|
+
function test_fork_crossStage_partialRepayStage1_completeRepayStage2() public {
|
|
200
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
201
|
+
|
|
202
|
+
// Create loan in stage 1.
|
|
203
|
+
(uint256 loanId, REVLoan memory loan) =
|
|
204
|
+
_createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
205
|
+
|
|
206
|
+
// Record fee tokens minted to borrower from source fee payment back to revnet.
|
|
207
|
+
uint256 feeTokensFromLoan = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
208
|
+
|
|
209
|
+
// Partial repay in stage 1: return half the collateral.
|
|
210
|
+
uint256 halfCollateral = loan.collateral / 2;
|
|
211
|
+
|
|
212
|
+
vm.deal(BORROWER, 100 ether);
|
|
213
|
+
JBSingleAllowance memory allowance;
|
|
214
|
+
|
|
215
|
+
vm.prank(BORROWER);
|
|
216
|
+
(uint256 newLoanId,) = LOANS_CONTRACT.repayLoan{value: loan.amount * 2}({
|
|
217
|
+
loanId: loanId,
|
|
218
|
+
maxRepayBorrowAmount: loan.amount * 2,
|
|
219
|
+
collateralCountToReturn: halfCollateral,
|
|
220
|
+
beneficiary: payable(BORROWER),
|
|
221
|
+
allowance: allowance
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Old loan should be replaced, new loan created for remainder.
|
|
225
|
+
assertGt(newLoanId, 0, "new loan should be created for remainder");
|
|
226
|
+
|
|
227
|
+
uint256 borrowerTokensMid = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
228
|
+
assertGt(borrowerTokensMid, 0, "borrower should get partial collateral back");
|
|
229
|
+
|
|
230
|
+
// Warp to stage 2.
|
|
231
|
+
vm.warp(block.timestamp + STAGE_DURATION + 1);
|
|
232
|
+
|
|
233
|
+
// Complete repay in stage 2.
|
|
234
|
+
REVLoan memory remainingLoan = LOANS_CONTRACT.loanOf(newLoanId);
|
|
235
|
+
|
|
236
|
+
vm.prank(BORROWER);
|
|
237
|
+
LOANS_CONTRACT.repayLoan{value: remainingLoan.amount * 2}({
|
|
238
|
+
loanId: newLoanId,
|
|
239
|
+
maxRepayBorrowAmount: remainingLoan.amount * 2,
|
|
240
|
+
collateralCountToReturn: remainingLoan.collateral,
|
|
241
|
+
beneficiary: payable(BORROWER),
|
|
242
|
+
allowance: allowance
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// All collateral should be recovered (plus fee tokens from loan creation).
|
|
246
|
+
uint256 borrowerTokensFinal = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
247
|
+
assertEq(
|
|
248
|
+
borrowerTokensFinal,
|
|
249
|
+
borrowerTokens + feeTokensFromLoan,
|
|
250
|
+
"should recover full collateral after two repayments"
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// @notice Reallocate a loan created in stage 1 while in stage 2.
|
|
255
|
+
function test_fork_crossStage_reallocateInStage2() public {
|
|
256
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
257
|
+
|
|
258
|
+
// Create loan in stage 1.
|
|
259
|
+
(uint256 loanId, REVLoan memory loan) =
|
|
260
|
+
_createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
261
|
+
|
|
262
|
+
// Warp to stage 2.
|
|
263
|
+
vm.warp(block.timestamp + STAGE_DURATION + 1);
|
|
264
|
+
|
|
265
|
+
// Reallocate a small fraction (5%) to a new loan. Using a small fraction ensures the remaining
|
|
266
|
+
// collateral still supports the existing borrow amount (bonding curve non-linearity).
|
|
267
|
+
REVLoanSource memory source = _nativeLoanSource();
|
|
268
|
+
uint256 transferAmount = loan.collateral / 20;
|
|
269
|
+
|
|
270
|
+
// Grant burn permission for the new loan.
|
|
271
|
+
_grantBurnPermission(BORROWER, revnetId);
|
|
272
|
+
|
|
273
|
+
// Cache before prank to avoid consuming the prank with a static call.
|
|
274
|
+
uint256 minFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT();
|
|
275
|
+
|
|
276
|
+
vm.prank(BORROWER);
|
|
277
|
+
(uint256 reallocatedLoanId, uint256 newLoanId, REVLoan memory reallocatedLoan,) = LOANS_CONTRACT.reallocateCollateralFromLoan({
|
|
278
|
+
loanId: loanId,
|
|
279
|
+
collateralCountToTransfer: transferAmount,
|
|
280
|
+
source: source,
|
|
281
|
+
minBorrowAmount: 0,
|
|
282
|
+
collateralCountToAdd: 0,
|
|
283
|
+
beneficiary: payable(BORROWER),
|
|
284
|
+
prepaidFeePercent: minFeePercent
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Original loan burned, reallocated loan created.
|
|
288
|
+
vm.expectRevert();
|
|
289
|
+
_loanOwnerOf(loanId);
|
|
290
|
+
|
|
291
|
+
// Both new loans should exist.
|
|
292
|
+
assertEq(_loanOwnerOf(reallocatedLoanId), BORROWER, "reallocated loan should exist");
|
|
293
|
+
assertEq(_loanOwnerOf(newLoanId), BORROWER, "new loan should exist");
|
|
294
|
+
|
|
295
|
+
// Reallocated loan should have reduced collateral.
|
|
296
|
+
assertEq(
|
|
297
|
+
reallocatedLoan.collateral, loan.collateral - transferAmount, "reallocated collateral should be reduced"
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.26;
|
|
3
|
+
|
|
4
|
+
import "./ForkTestBase.sol";
|
|
5
|
+
|
|
6
|
+
/// @notice Fork tests for REVLoans.liquidateExpiredLoansFrom() with real Uniswap V4 buyback hook.
|
|
7
|
+
///
|
|
8
|
+
/// Covers: expired liquidation, non-expired skipping, and gap handling.
|
|
9
|
+
///
|
|
10
|
+
/// Run with: FOUNDRY_PROFILE=fork forge test --match-contract TestLoanLiquidationFork -vvv
|
|
11
|
+
contract TestLoanLiquidationFork is ForkTestBase {
|
|
12
|
+
uint256 revnetId;
|
|
13
|
+
|
|
14
|
+
function setUp() public override {
|
|
15
|
+
super.setUp();
|
|
16
|
+
|
|
17
|
+
// Deploy fee project + revnet.
|
|
18
|
+
_deployFeeProject(5000);
|
|
19
|
+
revnetId = _deployRevnet(5000);
|
|
20
|
+
_setupPool(revnetId, 10_000 ether);
|
|
21
|
+
|
|
22
|
+
// Pay to create surplus.
|
|
23
|
+
_payRevnet(revnetId, PAYER, 10 ether);
|
|
24
|
+
_payRevnet(revnetId, BORROWER, 10 ether);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// @notice Liquidate an expired loan: NFT burned, collateral permanently lost.
|
|
28
|
+
function test_fork_liquidate_expired() public {
|
|
29
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
30
|
+
(uint256 loanId,) = _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
31
|
+
|
|
32
|
+
// Record balance after loan creation — borrower may have tokens from the source fee payment back to the
|
|
33
|
+
// revnet.
|
|
34
|
+
uint256 borrowerBalanceAfterLoan = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
35
|
+
|
|
36
|
+
uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(revnetId);
|
|
37
|
+
uint256 totalBorrowedBefore =
|
|
38
|
+
LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
|
|
39
|
+
|
|
40
|
+
// Warp past 10 years + 1 second.
|
|
41
|
+
vm.warp(block.timestamp + LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION() + 1);
|
|
42
|
+
|
|
43
|
+
// The loan number is 1 (first loan for this revnet).
|
|
44
|
+
LOANS_CONTRACT.liquidateExpiredLoansFrom(revnetId, 1, 1);
|
|
45
|
+
|
|
46
|
+
// Loan NFT burned.
|
|
47
|
+
vm.expectRevert();
|
|
48
|
+
_loanOwnerOf(loanId);
|
|
49
|
+
|
|
50
|
+
// Collateral permanently lost (decreased from tracking).
|
|
51
|
+
assertEq(
|
|
52
|
+
LOANS_CONTRACT.totalCollateralOf(revnetId),
|
|
53
|
+
totalCollateralBefore - borrowerTokens,
|
|
54
|
+
"totalCollateralOf should decrease"
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Borrowed amount decreased.
|
|
58
|
+
assertLt(
|
|
59
|
+
LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), JBConstants.NATIVE_TOKEN),
|
|
60
|
+
totalBorrowedBefore,
|
|
61
|
+
"totalBorrowedFrom should decrease"
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Liquidation doesn't re-mint tokens — balance unchanged from after loan creation.
|
|
65
|
+
assertEq(
|
|
66
|
+
jbTokens().totalBalanceOf(BORROWER, revnetId),
|
|
67
|
+
borrowerBalanceAfterLoan,
|
|
68
|
+
"liquidation should not change borrower token balance"
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// @notice Non-expired loan is skipped during liquidation.
|
|
73
|
+
function test_fork_liquidate_notExpiredSkipped() public {
|
|
74
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
75
|
+
(uint256 loanId,) = _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
76
|
+
|
|
77
|
+
// Don't warp — loan is fresh.
|
|
78
|
+
LOANS_CONTRACT.liquidateExpiredLoansFrom(revnetId, 1, 1);
|
|
79
|
+
|
|
80
|
+
// Loan should still exist.
|
|
81
|
+
assertEq(_loanOwnerOf(loanId), BORROWER, "loan should still exist");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// @notice Multiple loans with gaps: create 3, repay #2, warp, liquidate range.
|
|
85
|
+
function test_fork_liquidate_withGaps() public {
|
|
86
|
+
// Create 3 loans from different borrowers.
|
|
87
|
+
address borrower2 = makeAddr("borrower2");
|
|
88
|
+
address borrower3 = makeAddr("borrower3");
|
|
89
|
+
vm.deal(borrower2, 100 ether);
|
|
90
|
+
vm.deal(borrower3, 100 ether);
|
|
91
|
+
|
|
92
|
+
// Give each borrower tokens.
|
|
93
|
+
_payRevnet(revnetId, borrower2, 3 ether);
|
|
94
|
+
_payRevnet(revnetId, borrower3, 3 ether);
|
|
95
|
+
|
|
96
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
97
|
+
uint256 b2Tokens = jbTokens().totalBalanceOf(borrower2, revnetId);
|
|
98
|
+
uint256 b3Tokens = jbTokens().totalBalanceOf(borrower3, revnetId);
|
|
99
|
+
|
|
100
|
+
// Create 3 loans.
|
|
101
|
+
(uint256 loanId1,) = _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
102
|
+
(uint256 loanId2, REVLoan memory loan2) =
|
|
103
|
+
_createLoan(revnetId, borrower2, b2Tokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
104
|
+
(uint256 loanId3,) = _createLoan(revnetId, borrower3, b3Tokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
105
|
+
|
|
106
|
+
// Repay loan #2 to create a gap.
|
|
107
|
+
vm.deal(borrower2, 100 ether);
|
|
108
|
+
JBSingleAllowance memory allowance;
|
|
109
|
+
vm.prank(borrower2);
|
|
110
|
+
LOANS_CONTRACT.repayLoan{value: loan2.amount * 2}({
|
|
111
|
+
loanId: loanId2,
|
|
112
|
+
maxRepayBorrowAmount: loan2.amount * 2,
|
|
113
|
+
collateralCountToReturn: loan2.collateral,
|
|
114
|
+
beneficiary: payable(borrower2),
|
|
115
|
+
allowance: allowance
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Warp past 10 years.
|
|
119
|
+
vm.warp(block.timestamp + LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION() + 1);
|
|
120
|
+
|
|
121
|
+
// Liquidate the full range (loans 1-3).
|
|
122
|
+
LOANS_CONTRACT.liquidateExpiredLoansFrom(revnetId, 1, 3);
|
|
123
|
+
|
|
124
|
+
// Loans #1 and #3 should be liquidated. #2 was already repaid (skipped).
|
|
125
|
+
vm.expectRevert();
|
|
126
|
+
_loanOwnerOf(loanId1);
|
|
127
|
+
|
|
128
|
+
vm.expectRevert();
|
|
129
|
+
_loanOwnerOf(loanId3);
|
|
130
|
+
|
|
131
|
+
// Borrower 2 got their collateral back from repayment.
|
|
132
|
+
assertGt(jbTokens().totalBalanceOf(borrower2, revnetId), 0, "borrower2 should have tokens from repayment");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.26;
|
|
3
|
+
|
|
4
|
+
import "./ForkTestBase.sol";
|
|
5
|
+
|
|
6
|
+
/// @notice Fork tests for REVLoans.reallocateCollateralFromLoan() with real Uniswap V4 buyback hook.
|
|
7
|
+
///
|
|
8
|
+
/// Covers: basic reallocation and source mismatch revert.
|
|
9
|
+
///
|
|
10
|
+
/// Run with: FOUNDRY_PROFILE=fork forge test --match-contract TestLoanReallocateFork -vvv
|
|
11
|
+
contract TestLoanReallocateFork is ForkTestBase {
|
|
12
|
+
uint256 revnetId;
|
|
13
|
+
|
|
14
|
+
function setUp() public override {
|
|
15
|
+
super.setUp();
|
|
16
|
+
|
|
17
|
+
// Deploy fee project + revnet.
|
|
18
|
+
_deployFeeProject(5000);
|
|
19
|
+
revnetId = _deployRevnet(5000);
|
|
20
|
+
_setupPool(revnetId, 10_000 ether);
|
|
21
|
+
|
|
22
|
+
// Pay to create surplus.
|
|
23
|
+
_payRevnet(revnetId, PAYER, 10 ether);
|
|
24
|
+
_payRevnet(revnetId, BORROWER, 10 ether);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// @notice Reallocate collateral to a new loan: original reduced, new loan created.
|
|
28
|
+
function test_fork_reallocate_basic() public {
|
|
29
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
30
|
+
(uint256 loanId, REVLoan memory loan) =
|
|
31
|
+
_createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
32
|
+
|
|
33
|
+
// Add more surplus after the loan so the remaining collateral supports the existing borrow amount.
|
|
34
|
+
// Without extra surplus, any collateral removal would make borrowable < loan.amount (bonding curve).
|
|
35
|
+
address extraPayer = makeAddr("extraPayer");
|
|
36
|
+
vm.deal(extraPayer, 20 ether);
|
|
37
|
+
_payRevnet(revnetId, extraPayer, 20 ether);
|
|
38
|
+
|
|
39
|
+
uint256 transferAmount = loan.collateral / 20;
|
|
40
|
+
uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(revnetId);
|
|
41
|
+
|
|
42
|
+
REVLoanSource memory source = _nativeLoanSource();
|
|
43
|
+
|
|
44
|
+
// Grant burn permission again for the new loan's collateral.
|
|
45
|
+
_grantBurnPermission(BORROWER, revnetId);
|
|
46
|
+
|
|
47
|
+
// Cache before prank to avoid consuming the prank with a static call.
|
|
48
|
+
uint256 minFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT();
|
|
49
|
+
|
|
50
|
+
vm.prank(BORROWER);
|
|
51
|
+
(uint256 reallocatedLoanId, uint256 newLoanId, REVLoan memory reallocatedLoan, REVLoan memory newLoan) = LOANS_CONTRACT.reallocateCollateralFromLoan({
|
|
52
|
+
loanId: loanId,
|
|
53
|
+
collateralCountToTransfer: transferAmount,
|
|
54
|
+
source: source,
|
|
55
|
+
minBorrowAmount: 0,
|
|
56
|
+
collateralCountToAdd: 0,
|
|
57
|
+
beneficiary: payable(BORROWER),
|
|
58
|
+
prepaidFeePercent: minFeePercent
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Original loan reduced.
|
|
62
|
+
assertEq(
|
|
63
|
+
reallocatedLoan.collateral,
|
|
64
|
+
loan.collateral - transferAmount,
|
|
65
|
+
"reallocated loan should have reduced collateral"
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// New loan has the transferred collateral.
|
|
69
|
+
assertEq(newLoan.collateral, transferAmount, "new loan should have transferred collateral");
|
|
70
|
+
|
|
71
|
+
// Original loan burned, reallocated loan created.
|
|
72
|
+
vm.expectRevert();
|
|
73
|
+
_loanOwnerOf(loanId);
|
|
74
|
+
|
|
75
|
+
assertEq(_loanOwnerOf(reallocatedLoanId), BORROWER, "reallocated loan owned by borrower");
|
|
76
|
+
assertEq(_loanOwnerOf(newLoanId), BORROWER, "new loan owned by borrower");
|
|
77
|
+
|
|
78
|
+
// Total collateral should remain approximately the same (moved, not destroyed).
|
|
79
|
+
// It increases by halfCollateral because the new loan's borrowFrom also adds collateral.
|
|
80
|
+
// But reallocateCollateralFromLoan transfers collateral from the existing loan (not burning new tokens),
|
|
81
|
+
// so totalCollateralOf should stay the same (the half was already in the system).
|
|
82
|
+
assertEq(
|
|
83
|
+
LOANS_CONTRACT.totalCollateralOf(revnetId), totalCollateralBefore, "total collateral should be unchanged"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// @notice Reallocate with a different source terminal should revert.
|
|
88
|
+
function test_fork_reallocate_sourceMismatchReverts() public {
|
|
89
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
90
|
+
(uint256 loanId, REVLoan memory loan) =
|
|
91
|
+
_createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
92
|
+
|
|
93
|
+
// Create a source with a different terminal address.
|
|
94
|
+
REVLoanSource memory badSource =
|
|
95
|
+
REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: IJBPayoutTerminal(address(0xdead))});
|
|
96
|
+
|
|
97
|
+
// Cache before prank to avoid consuming the prank with a static call.
|
|
98
|
+
uint256 minFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT();
|
|
99
|
+
|
|
100
|
+
vm.prank(BORROWER);
|
|
101
|
+
vm.expectRevert();
|
|
102
|
+
LOANS_CONTRACT.reallocateCollateralFromLoan({
|
|
103
|
+
loanId: loanId,
|
|
104
|
+
collateralCountToTransfer: loan.collateral / 2,
|
|
105
|
+
source: badSource,
|
|
106
|
+
minBorrowAmount: 0,
|
|
107
|
+
collateralCountToAdd: 0,
|
|
108
|
+
beneficiary: payable(BORROWER),
|
|
109
|
+
prepaidFeePercent: minFeePercent
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|