@rev-net/core-v6 0.0.7 → 0.0.9
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 +4 -2
- package/RISKS.md +49 -0
- package/SKILLS.md +22 -2
- package/STYLE_GUIDE.md +482 -0
- package/foundry.toml +6 -6
- package/package.json +13 -10
- package/script/Deploy.s.sol +3 -2
- package/src/REVDeployer.sol +129 -72
- package/src/REVLoans.sol +174 -165
- package/src/interfaces/IREVDeployer.sol +111 -72
- package/src/interfaces/IREVLoans.sol +116 -76
- 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 +4 -3
- package/test/REVInvincibility.t.sol +8 -6
- package/test/REVInvincibilityHandler.sol +1 -0
- package/test/REVLifecycle.t.sol +4 -3
- package/test/REVLoans.invariants.t.sol +5 -3
- package/test/REVLoansAttacks.t.sol +4 -3
- package/test/REVLoansAuditRegressions.t.sol +13 -24
- package/test/REVLoansFeeRecovery.t.sol +4 -3
- package/test/REVLoansSourced.t.sol +4 -3
- package/test/REVLoansUnSourced.t.sol +4 -3
- package/test/REVLoans_AuditFindings.t.sol +644 -0
- package/test/TestEmptyBuybackSpecs.t.sol +4 -3
- package/test/TestPR09_ConversionDocumentation.t.sol +4 -3
- package/test/TestPR10_LiquidationBehavior.t.sol +4 -3
- package/test/TestPR11_LowFindings.t.sol +4 -3
- package/test/TestPR12_FlashLoanSurplus.t.sol +4 -3
- package/test/TestPR13_CrossSourceReallocation.t.sol +4 -3
- package/test/TestPR15_CashOutCallerValidation.t.sol +4 -3
- package/test/TestPR16_ZeroRepayment.t.sol +4 -3
- package/test/TestPR21_Uint112Overflow.t.sol +4 -3
- package/test/TestPR22_HookArrayOOB.t.sol +4 -3
- package/test/TestPR26_BurnHeldTokens.t.sol +4 -3
- package/test/TestPR27_CEIPattern.t.sol +4 -3
- package/test/TestPR29_SwapTerminalPermission.t.sol +4 -3
- package/test/TestPR32_MixedFixes.t.sol +4 -3
- package/test/TestSplitWeightAdjustment.t.sol +445 -0
- package/test/TestSplitWeightE2E.t.sol +528 -0
- package/test/TestSplitWeightFork.t.sol +821 -0
- package/test/TestStageTransitionBorrowable.t.sol +4 -3
- package/test/fork/ForkTestBase.sol +617 -0
- package/test/fork/TestCashOutFork.t.sol +245 -0
- package/test/fork/TestLoanBorrowFork.t.sol +163 -0
- package/test/fork/TestLoanLiquidationFork.t.sol +129 -0
- package/test/fork/TestLoanReallocateFork.t.sol +103 -0
- package/test/fork/TestLoanRepayFork.t.sol +184 -0
- package/test/fork/TestSplitWeightFork.t.sol +186 -0
- package/test/mock/MockBuybackDataHook.sol +11 -4
- package/test/mock/MockBuybackDataHookMintPath.sol +11 -3
- package/test/regression/TestI20_CumulativeLoanCounter.t.sol +6 -5
- package/test/regression/TestL27_LiquidateGapHandling.t.sol +7 -6
- package/SECURITY.md +0 -68
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.26;
|
|
3
|
+
|
|
4
|
+
import "./ForkTestBase.sol";
|
|
5
|
+
import {JBCashOuts} from "@bananapus/core-v6/src/libraries/JBCashOuts.sol";
|
|
6
|
+
|
|
7
|
+
/// @notice Fork tests for revnet cash-out scenarios with real Uniswap V4 buyback hook.
|
|
8
|
+
///
|
|
9
|
+
/// Covers: fee deduction, high tax rate, sucker exemption, surplus after tier splits, and delay enforcement.
|
|
10
|
+
///
|
|
11
|
+
/// Run with: FOUNDRY_PROFILE=fork forge test --match-contract TestCashOutFork -vvv
|
|
12
|
+
contract TestCashOutFork is ForkTestBase {
|
|
13
|
+
uint256 revnetId;
|
|
14
|
+
|
|
15
|
+
function setUp() public override {
|
|
16
|
+
super.setUp();
|
|
17
|
+
|
|
18
|
+
// Skip if no fork available.
|
|
19
|
+
string memory rpcUrl = vm.envOr("RPC_ETHEREUM_MAINNET", string(""));
|
|
20
|
+
if (bytes(rpcUrl).length == 0) return;
|
|
21
|
+
|
|
22
|
+
// Deploy fee project + revnet with 50% cashOutTaxRate.
|
|
23
|
+
_deployFeeProject(5000);
|
|
24
|
+
revnetId = _deployRevnet(5000);
|
|
25
|
+
|
|
26
|
+
// Set up pool at 1:1 (mint path wins).
|
|
27
|
+
_setupPool(revnetId, 10_000 ether);
|
|
28
|
+
|
|
29
|
+
// Pay 10 ETH to create surplus and tokens.
|
|
30
|
+
_payRevnet(revnetId, PAYER, 10 ether);
|
|
31
|
+
|
|
32
|
+
// Warp past the 30-day cash-out delay.
|
|
33
|
+
vm.warp(block.timestamp + REV_DEPLOYER.CASH_OUT_DELAY() + 1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// @notice Cash out tokens and verify fee deduction, token burn, and bonding curve reclaim.
|
|
37
|
+
function test_fork_cashOut_normalWithFee() public onlyFork {
|
|
38
|
+
uint256 payerTokens = jbTokens().totalBalanceOf(PAYER, revnetId);
|
|
39
|
+
uint256 cashOutCount = payerTokens / 2; // Cash out half.
|
|
40
|
+
|
|
41
|
+
// Record state before.
|
|
42
|
+
uint256 payerEthBefore = PAYER.balance;
|
|
43
|
+
uint256 feeTerminalBefore = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN);
|
|
44
|
+
uint256 totalSupplyBefore = jbTokens().totalSupplyOf(revnetId);
|
|
45
|
+
|
|
46
|
+
// Cash out.
|
|
47
|
+
vm.prank(PAYER);
|
|
48
|
+
uint256 reclaimedAmount = jbMultiTerminal()
|
|
49
|
+
.cashOutTokensOf({
|
|
50
|
+
holder: PAYER,
|
|
51
|
+
projectId: revnetId,
|
|
52
|
+
cashOutCount: cashOutCount,
|
|
53
|
+
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
54
|
+
minTokensReclaimed: 0,
|
|
55
|
+
beneficiary: payable(PAYER),
|
|
56
|
+
metadata: ""
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Payer received ETH.
|
|
60
|
+
assertGt(PAYER.balance, payerEthBefore, "payer should receive ETH");
|
|
61
|
+
assertEq(PAYER.balance - payerEthBefore, reclaimedAmount, "reclaimed amount should match balance change");
|
|
62
|
+
|
|
63
|
+
// Fee project terminal balance increased (2.5% fee on the cashout portion processed by the hook).
|
|
64
|
+
uint256 feeTerminalAfter = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN);
|
|
65
|
+
assertGt(feeTerminalAfter, feeTerminalBefore, "fee project should receive fee");
|
|
66
|
+
|
|
67
|
+
// Token supply decreased.
|
|
68
|
+
uint256 totalSupplyAfter = jbTokens().totalSupplyOf(revnetId);
|
|
69
|
+
assertEq(totalSupplyAfter, totalSupplyBefore - cashOutCount, "total supply should decrease by cashOutCount");
|
|
70
|
+
|
|
71
|
+
// Reclaim is less than pro-rata share due to 50% tax rate.
|
|
72
|
+
uint256 surplus = _terminalBalance(revnetId, JBConstants.NATIVE_TOKEN) + reclaimedAmount
|
|
73
|
+
+ (feeTerminalAfter - feeTerminalBefore);
|
|
74
|
+
// Pro-rata share = surplus * cashOutCount / totalSupply
|
|
75
|
+
// With 50% tax, reclaim should be roughly 75% of pro-rata (from bonding curve formula).
|
|
76
|
+
uint256 proRataShare = (surplus * cashOutCount) / totalSupplyBefore;
|
|
77
|
+
assertLt(reclaimedAmount, proRataShare, "reclaim should be less than pro-rata due to tax");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// @notice High tax rate (90%) produces small reclaim relative to pro-rata.
|
|
81
|
+
function test_fork_cashOut_highTaxRate() public onlyFork {
|
|
82
|
+
// Deploy a separate revnet with 90% tax rate.
|
|
83
|
+
uint256 highTaxRevnet = _deployRevnet(9000);
|
|
84
|
+
_setupPool(highTaxRevnet, 10_000 ether);
|
|
85
|
+
_payRevnet(highTaxRevnet, PAYER, 10 ether);
|
|
86
|
+
vm.warp(block.timestamp + REV_DEPLOYER.CASH_OUT_DELAY() + 1);
|
|
87
|
+
|
|
88
|
+
uint256 payerTokens = jbTokens().totalBalanceOf(PAYER, highTaxRevnet);
|
|
89
|
+
uint256 cashOutCount = payerTokens / 2;
|
|
90
|
+
|
|
91
|
+
vm.prank(PAYER);
|
|
92
|
+
uint256 reclaimedAmount = jbMultiTerminal()
|
|
93
|
+
.cashOutTokensOf({
|
|
94
|
+
holder: PAYER,
|
|
95
|
+
projectId: highTaxRevnet,
|
|
96
|
+
cashOutCount: cashOutCount,
|
|
97
|
+
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
98
|
+
minTokensReclaimed: 0,
|
|
99
|
+
beneficiary: payable(PAYER),
|
|
100
|
+
metadata: ""
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// With 90% tax rate, reclaim should be very small relative to surplus.
|
|
104
|
+
uint256 terminalBalance = _terminalBalance(highTaxRevnet, JBConstants.NATIVE_TOKEN);
|
|
105
|
+
uint256 totalSurplus = terminalBalance + reclaimedAmount;
|
|
106
|
+
uint256 proRataShare = (totalSurplus * cashOutCount) / payerTokens;
|
|
107
|
+
|
|
108
|
+
// At 90% tax rate with 50% of supply, reclaim ~= proRata * (10% + 90% * 0.5) = proRata * 55%.
|
|
109
|
+
// But also minus 2.5% fee. Should be well under 60% of pro-rata.
|
|
110
|
+
assertLt(reclaimedAmount, (proRataShare * 60) / 100, "high tax: reclaim should be very small");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// @notice Sucker addresses get full pro-rata reclaim with 0% tax and no fee.
|
|
114
|
+
function test_fork_cashOut_suckerExempt() public onlyFork {
|
|
115
|
+
address sucker = makeAddr("sucker");
|
|
116
|
+
vm.deal(sucker, 100 ether);
|
|
117
|
+
|
|
118
|
+
// Pay as sucker to get tokens.
|
|
119
|
+
_payRevnet(revnetId, sucker, 5 ether);
|
|
120
|
+
|
|
121
|
+
uint256 suckerTokens = jbTokens().totalBalanceOf(sucker, revnetId);
|
|
122
|
+
uint256 totalSupply = jbTokens().totalSupplyOf(revnetId);
|
|
123
|
+
uint256 surplus = _terminalBalance(revnetId, JBConstants.NATIVE_TOKEN);
|
|
124
|
+
|
|
125
|
+
// Mock sucker registry to report this address as a sucker.
|
|
126
|
+
vm.mockCall(
|
|
127
|
+
address(SUCKER_REGISTRY),
|
|
128
|
+
abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector, revnetId, sucker),
|
|
129
|
+
abi.encode(true)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
uint256 feeTerminalBefore = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN);
|
|
133
|
+
|
|
134
|
+
vm.prank(sucker);
|
|
135
|
+
uint256 reclaimedAmount = jbMultiTerminal()
|
|
136
|
+
.cashOutTokensOf({
|
|
137
|
+
holder: sucker,
|
|
138
|
+
projectId: revnetId,
|
|
139
|
+
cashOutCount: suckerTokens,
|
|
140
|
+
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
141
|
+
minTokensReclaimed: 0,
|
|
142
|
+
beneficiary: payable(sucker),
|
|
143
|
+
metadata: ""
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Full pro-rata reclaim (0% tax).
|
|
147
|
+
uint256 expectedReclaim = (surplus * suckerTokens) / totalSupply;
|
|
148
|
+
assertEq(reclaimedAmount, expectedReclaim, "sucker should get full pro-rata reclaim");
|
|
149
|
+
|
|
150
|
+
// No fee charged.
|
|
151
|
+
uint256 feeTerminalAfter = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN);
|
|
152
|
+
assertEq(feeTerminalAfter, feeTerminalBefore, "no fee should be charged for sucker");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// @notice After a payment with 30% tier split, surplus accounting reflects actual terminal balance.
|
|
156
|
+
function test_fork_cashOut_afterTierSplitPayment() public onlyFork {
|
|
157
|
+
// Deploy revnet with 721 hook.
|
|
158
|
+
(uint256 splitRevnetId, IJB721TiersHook hook) = _deployRevnetWith721(5000);
|
|
159
|
+
_setupPool(splitRevnetId, 10_000 ether);
|
|
160
|
+
|
|
161
|
+
// Pay 1 ETH with tier metadata (triggers 30% split).
|
|
162
|
+
address metadataTarget = hook.METADATA_ID_TARGET();
|
|
163
|
+
bytes memory metadata = _buildPayMetadataNoQuote(metadataTarget);
|
|
164
|
+
|
|
165
|
+
vm.prank(PAYER);
|
|
166
|
+
jbMultiTerminal().pay{value: 1 ether}({
|
|
167
|
+
projectId: splitRevnetId,
|
|
168
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
169
|
+
amount: 1 ether,
|
|
170
|
+
beneficiary: PAYER,
|
|
171
|
+
minReturnedTokens: 0,
|
|
172
|
+
memo: "",
|
|
173
|
+
metadata: metadata
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Warp past delay.
|
|
177
|
+
vm.warp(block.timestamp + REV_DEPLOYER.CASH_OUT_DELAY() + 1);
|
|
178
|
+
|
|
179
|
+
uint256 payerTokens = jbTokens().totalBalanceOf(PAYER, splitRevnetId);
|
|
180
|
+
uint256 terminalBalance = _terminalBalance(splitRevnetId, JBConstants.NATIVE_TOKEN);
|
|
181
|
+
|
|
182
|
+
// Terminal balance should be ~1 ETH (0.7 ETH project share + 0.3 ETH returned via addToBalance from 721
|
|
183
|
+
// hook).
|
|
184
|
+
assertGt(terminalBalance, 0, "terminal should have balance");
|
|
185
|
+
|
|
186
|
+
// Cash out should succeed using actual terminal balance as surplus.
|
|
187
|
+
if (payerTokens > 0) {
|
|
188
|
+
vm.prank(PAYER);
|
|
189
|
+
uint256 reclaimedAmount = jbMultiTerminal()
|
|
190
|
+
.cashOutTokensOf({
|
|
191
|
+
holder: PAYER,
|
|
192
|
+
projectId: splitRevnetId,
|
|
193
|
+
cashOutCount: payerTokens,
|
|
194
|
+
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
195
|
+
minTokensReclaimed: 0,
|
|
196
|
+
beneficiary: payable(PAYER),
|
|
197
|
+
metadata: ""
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
assertGt(reclaimedAmount, 0, "should reclaim some ETH after tier split payment");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// @notice Cash out before delay expires should revert.
|
|
205
|
+
function test_fork_cashOut_delayEnforcement() public onlyFork {
|
|
206
|
+
// Deploy a fresh revnet (delay starts from deploy time).
|
|
207
|
+
uint256 delayRevnet = _deployRevnet(5000);
|
|
208
|
+
_setupPool(delayRevnet, 10_000 ether);
|
|
209
|
+
_payRevnet(delayRevnet, PAYER, 1 ether);
|
|
210
|
+
|
|
211
|
+
uint256 payerTokens = jbTokens().totalBalanceOf(PAYER, delayRevnet);
|
|
212
|
+
|
|
213
|
+
// Try to cash out immediately (before delay expires) -> should revert.
|
|
214
|
+
vm.prank(PAYER);
|
|
215
|
+
vm.expectRevert();
|
|
216
|
+
jbMultiTerminal()
|
|
217
|
+
.cashOutTokensOf({
|
|
218
|
+
holder: PAYER,
|
|
219
|
+
projectId: delayRevnet,
|
|
220
|
+
cashOutCount: payerTokens,
|
|
221
|
+
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
222
|
+
minTokensReclaimed: 0,
|
|
223
|
+
beneficiary: payable(PAYER),
|
|
224
|
+
metadata: ""
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Warp past delay.
|
|
228
|
+
vm.warp(block.timestamp + REV_DEPLOYER.CASH_OUT_DELAY() + 1);
|
|
229
|
+
|
|
230
|
+
// Now it should succeed.
|
|
231
|
+
vm.prank(PAYER);
|
|
232
|
+
uint256 reclaimedAmount = jbMultiTerminal()
|
|
233
|
+
.cashOutTokensOf({
|
|
234
|
+
holder: PAYER,
|
|
235
|
+
projectId: delayRevnet,
|
|
236
|
+
cashOutCount: payerTokens,
|
|
237
|
+
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
238
|
+
minTokensReclaimed: 0,
|
|
239
|
+
beneficiary: payable(PAYER),
|
|
240
|
+
metadata: ""
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
assertGt(reclaimedAmount, 0, "should succeed after delay expires");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
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 REVLoans.borrowFrom() with real Uniswap V4 buyback hook.
|
|
8
|
+
///
|
|
9
|
+
/// Covers: basic borrow, fee distribution, and borrow after tier splits.
|
|
10
|
+
///
|
|
11
|
+
/// Run with: FOUNDRY_PROFILE=fork forge test --match-contract TestLoanBorrowFork -vvv
|
|
12
|
+
contract TestLoanBorrowFork is ForkTestBase {
|
|
13
|
+
uint256 revnetId;
|
|
14
|
+
|
|
15
|
+
function setUp() public override {
|
|
16
|
+
super.setUp();
|
|
17
|
+
|
|
18
|
+
string memory rpcUrl = vm.envOr("RPC_ETHEREUM_MAINNET", string(""));
|
|
19
|
+
if (bytes(rpcUrl).length == 0) return;
|
|
20
|
+
|
|
21
|
+
// Deploy fee project + revnet with 50% cashOutTaxRate.
|
|
22
|
+
_deployFeeProject(5000);
|
|
23
|
+
revnetId = _deployRevnet(5000);
|
|
24
|
+
|
|
25
|
+
// Set up pool at 1:1 (mint path wins).
|
|
26
|
+
_setupPool(revnetId, 10_000 ether);
|
|
27
|
+
|
|
28
|
+
// Pay to create surplus. PAYER gets tokens and BORROWER gets tokens.
|
|
29
|
+
_payRevnet(revnetId, PAYER, 10 ether);
|
|
30
|
+
_payRevnet(revnetId, BORROWER, 5 ether);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// @notice Basic borrow: collateralize all borrower tokens, verify loan state.
|
|
34
|
+
function test_fork_borrow_basic() public onlyFork {
|
|
35
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
36
|
+
|
|
37
|
+
uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
|
|
38
|
+
revnetId, borrowerTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
39
|
+
);
|
|
40
|
+
assertGt(borrowable, 0, "should have borrowable amount");
|
|
41
|
+
|
|
42
|
+
uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(revnetId);
|
|
43
|
+
uint256 totalBorrowedBefore =
|
|
44
|
+
LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
|
|
45
|
+
|
|
46
|
+
uint256 borrowerEthBefore = BORROWER.balance;
|
|
47
|
+
|
|
48
|
+
// Create the loan.
|
|
49
|
+
(uint256 loanId, REVLoan memory loan) =
|
|
50
|
+
_createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
51
|
+
|
|
52
|
+
// Verify loan state.
|
|
53
|
+
assertEq(loan.collateral, borrowerTokens, "loan collateral should match");
|
|
54
|
+
assertEq(loan.createdAt, block.timestamp, "loan createdAt should be now");
|
|
55
|
+
|
|
56
|
+
// Borrower tokens should be burned (collateral deposited).
|
|
57
|
+
assertEq(jbTokens().totalBalanceOf(BORROWER, revnetId), 0, "borrower tokens should be burned");
|
|
58
|
+
|
|
59
|
+
// Borrower received ETH (net of fees).
|
|
60
|
+
assertGt(BORROWER.balance, borrowerEthBefore, "borrower should receive ETH");
|
|
61
|
+
|
|
62
|
+
// Tracking updated.
|
|
63
|
+
assertEq(
|
|
64
|
+
LOANS_CONTRACT.totalCollateralOf(revnetId),
|
|
65
|
+
totalCollateralBefore + borrowerTokens,
|
|
66
|
+
"totalCollateralOf should increase"
|
|
67
|
+
);
|
|
68
|
+
assertGt(
|
|
69
|
+
LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), JBConstants.NATIVE_TOKEN),
|
|
70
|
+
totalBorrowedBefore,
|
|
71
|
+
"totalBorrowedFrom should increase"
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Loan NFT owned by borrower.
|
|
75
|
+
assertEq(_loanOwnerOf(loanId), BORROWER, "loan NFT should be owned by borrower");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// @notice Verify fee distribution: source fee (2.5%) + REV fee (1%) deducted correctly.
|
|
79
|
+
function test_fork_borrow_feeDistribution() public onlyFork {
|
|
80
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
81
|
+
uint256 prepaidFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT(); // 25 = 2.5%
|
|
82
|
+
|
|
83
|
+
uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
|
|
84
|
+
revnetId, borrowerTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Record balances before.
|
|
88
|
+
uint256 borrowerEthBefore = BORROWER.balance;
|
|
89
|
+
uint256 revnetTerminalBefore = _terminalBalance(revnetId, JBConstants.NATIVE_TOKEN);
|
|
90
|
+
|
|
91
|
+
_grantBurnPermission(BORROWER, revnetId);
|
|
92
|
+
|
|
93
|
+
REVLoanSource memory source = _nativeLoanSource();
|
|
94
|
+
vm.prank(BORROWER);
|
|
95
|
+
LOANS_CONTRACT.borrowFrom({
|
|
96
|
+
revnetId: revnetId,
|
|
97
|
+
source: source,
|
|
98
|
+
minBorrowAmount: 0,
|
|
99
|
+
collateralCount: borrowerTokens,
|
|
100
|
+
beneficiary: payable(BORROWER),
|
|
101
|
+
prepaidFeePercent: prepaidFeePercent
|
|
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 onlyFork {
|
|
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
|
+
// With 30% split and 1000 tokens/ETH issuance, borrower gets 700 tokens/ETH * 5 = 3500 tokens.
|
|
146
|
+
assertEq(borrowerTokens, 3500e18, "should get 3500 tokens after 30% 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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
string memory rpcUrl = vm.envOr("RPC_ETHEREUM_MAINNET", string(""));
|
|
18
|
+
if (bytes(rpcUrl).length == 0) return;
|
|
19
|
+
|
|
20
|
+
// Deploy fee project + revnet.
|
|
21
|
+
_deployFeeProject(5000);
|
|
22
|
+
revnetId = _deployRevnet(5000);
|
|
23
|
+
_setupPool(revnetId, 10_000 ether);
|
|
24
|
+
|
|
25
|
+
// Pay to create surplus.
|
|
26
|
+
_payRevnet(revnetId, PAYER, 10 ether);
|
|
27
|
+
_payRevnet(revnetId, BORROWER, 10 ether);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// @notice Liquidate an expired loan: NFT burned, collateral permanently lost.
|
|
31
|
+
function test_fork_liquidate_expired() public onlyFork {
|
|
32
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
33
|
+
(uint256 loanId,) = _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
34
|
+
|
|
35
|
+
uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(revnetId);
|
|
36
|
+
uint256 totalBorrowedBefore =
|
|
37
|
+
LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
|
|
38
|
+
|
|
39
|
+
// Warp past 10 years + 1 second.
|
|
40
|
+
vm.warp(block.timestamp + LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION() + 1);
|
|
41
|
+
|
|
42
|
+
// The loan number is 1 (first loan for this revnet).
|
|
43
|
+
LOANS_CONTRACT.liquidateExpiredLoansFrom(revnetId, 1, 1);
|
|
44
|
+
|
|
45
|
+
// Loan NFT burned.
|
|
46
|
+
vm.expectRevert();
|
|
47
|
+
_loanOwnerOf(loanId);
|
|
48
|
+
|
|
49
|
+
// Collateral permanently lost (decreased from tracking).
|
|
50
|
+
assertEq(
|
|
51
|
+
LOANS_CONTRACT.totalCollateralOf(revnetId),
|
|
52
|
+
totalCollateralBefore - borrowerTokens,
|
|
53
|
+
"totalCollateralOf should decrease"
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Borrowed amount decreased.
|
|
57
|
+
assertLt(
|
|
58
|
+
LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), JBConstants.NATIVE_TOKEN),
|
|
59
|
+
totalBorrowedBefore,
|
|
60
|
+
"totalBorrowedFrom should decrease"
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// No tokens re-minted to borrower.
|
|
64
|
+
assertEq(jbTokens().totalBalanceOf(BORROWER, revnetId), 0, "no tokens should be re-minted");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// @notice Non-expired loan is skipped during liquidation.
|
|
68
|
+
function test_fork_liquidate_notExpiredSkipped() public onlyFork {
|
|
69
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
70
|
+
(uint256 loanId,) = _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
71
|
+
|
|
72
|
+
// Don't warp — loan is fresh.
|
|
73
|
+
LOANS_CONTRACT.liquidateExpiredLoansFrom(revnetId, 1, 1);
|
|
74
|
+
|
|
75
|
+
// Loan should still exist.
|
|
76
|
+
assertEq(_loanOwnerOf(loanId), BORROWER, "loan should still exist");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// @notice Multiple loans with gaps: create 3, repay #2, warp, liquidate range.
|
|
80
|
+
function test_fork_liquidate_withGaps() public onlyFork {
|
|
81
|
+
// Create 3 loans from different borrowers.
|
|
82
|
+
address borrower2 = makeAddr("borrower2");
|
|
83
|
+
address borrower3 = makeAddr("borrower3");
|
|
84
|
+
vm.deal(borrower2, 100 ether);
|
|
85
|
+
vm.deal(borrower3, 100 ether);
|
|
86
|
+
|
|
87
|
+
// Give each borrower tokens.
|
|
88
|
+
_payRevnet(revnetId, borrower2, 3 ether);
|
|
89
|
+
_payRevnet(revnetId, borrower3, 3 ether);
|
|
90
|
+
|
|
91
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
92
|
+
uint256 b2Tokens = jbTokens().totalBalanceOf(borrower2, revnetId);
|
|
93
|
+
uint256 b3Tokens = jbTokens().totalBalanceOf(borrower3, revnetId);
|
|
94
|
+
|
|
95
|
+
// Create 3 loans.
|
|
96
|
+
(uint256 loanId1,) = _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
97
|
+
(uint256 loanId2, REVLoan memory loan2) =
|
|
98
|
+
_createLoan(revnetId, borrower2, b2Tokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
99
|
+
(uint256 loanId3,) = _createLoan(revnetId, borrower3, b3Tokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
100
|
+
|
|
101
|
+
// Repay loan #2 to create a gap.
|
|
102
|
+
vm.deal(borrower2, 100 ether);
|
|
103
|
+
JBSingleAllowance memory allowance;
|
|
104
|
+
vm.prank(borrower2);
|
|
105
|
+
LOANS_CONTRACT.repayLoan{value: loan2.amount * 2}({
|
|
106
|
+
loanId: loanId2,
|
|
107
|
+
maxRepayBorrowAmount: loan2.amount * 2,
|
|
108
|
+
collateralCountToReturn: loan2.collateral,
|
|
109
|
+
beneficiary: payable(borrower2),
|
|
110
|
+
allowance: allowance
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Warp past 10 years.
|
|
114
|
+
vm.warp(block.timestamp + LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION() + 1);
|
|
115
|
+
|
|
116
|
+
// Liquidate the full range (loans 1-3).
|
|
117
|
+
LOANS_CONTRACT.liquidateExpiredLoansFrom(revnetId, 1, 3);
|
|
118
|
+
|
|
119
|
+
// Loans #1 and #3 should be liquidated. #2 was already repaid (skipped).
|
|
120
|
+
vm.expectRevert();
|
|
121
|
+
_loanOwnerOf(loanId1);
|
|
122
|
+
|
|
123
|
+
vm.expectRevert();
|
|
124
|
+
_loanOwnerOf(loanId3);
|
|
125
|
+
|
|
126
|
+
// Borrower 2 got their collateral back from repayment.
|
|
127
|
+
assertGt(jbTokens().totalBalanceOf(borrower2, revnetId), 0, "borrower2 should have tokens from repayment");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
string memory rpcUrl = vm.envOr("RPC_ETHEREUM_MAINNET", string(""));
|
|
18
|
+
if (bytes(rpcUrl).length == 0) return;
|
|
19
|
+
|
|
20
|
+
// Deploy fee project + revnet.
|
|
21
|
+
_deployFeeProject(5000);
|
|
22
|
+
revnetId = _deployRevnet(5000);
|
|
23
|
+
_setupPool(revnetId, 10_000 ether);
|
|
24
|
+
|
|
25
|
+
// Pay to create surplus.
|
|
26
|
+
_payRevnet(revnetId, PAYER, 10 ether);
|
|
27
|
+
_payRevnet(revnetId, BORROWER, 10 ether);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// @notice Reallocate half collateral to a new loan: original reduced, new loan created.
|
|
31
|
+
function test_fork_reallocate_basic() public onlyFork {
|
|
32
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
33
|
+
(uint256 loanId, REVLoan memory loan) =
|
|
34
|
+
_createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
35
|
+
|
|
36
|
+
uint256 halfCollateral = loan.collateral / 2;
|
|
37
|
+
uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(revnetId);
|
|
38
|
+
|
|
39
|
+
REVLoanSource memory source = _nativeLoanSource();
|
|
40
|
+
|
|
41
|
+
// Grant burn permission again for the new loan's collateral.
|
|
42
|
+
_grantBurnPermission(BORROWER, revnetId);
|
|
43
|
+
|
|
44
|
+
vm.prank(BORROWER);
|
|
45
|
+
(uint256 reallocatedLoanId, uint256 newLoanId, REVLoan memory reallocatedLoan, REVLoan memory newLoan) = LOANS_CONTRACT.reallocateCollateralFromLoan({
|
|
46
|
+
loanId: loanId,
|
|
47
|
+
collateralCountToTransfer: halfCollateral,
|
|
48
|
+
source: source,
|
|
49
|
+
minBorrowAmount: 0,
|
|
50
|
+
collateralCountToAdd: 0,
|
|
51
|
+
beneficiary: payable(BORROWER),
|
|
52
|
+
prepaidFeePercent: LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT()
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Original loan reduced.
|
|
56
|
+
assertEq(
|
|
57
|
+
reallocatedLoan.collateral,
|
|
58
|
+
loan.collateral - halfCollateral,
|
|
59
|
+
"reallocated loan should have reduced collateral"
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// New loan has the transferred collateral.
|
|
63
|
+
assertEq(newLoan.collateral, halfCollateral, "new loan should have transferred collateral");
|
|
64
|
+
|
|
65
|
+
// Original loan burned, reallocated loan created.
|
|
66
|
+
vm.expectRevert();
|
|
67
|
+
_loanOwnerOf(loanId);
|
|
68
|
+
|
|
69
|
+
assertEq(_loanOwnerOf(reallocatedLoanId), BORROWER, "reallocated loan owned by borrower");
|
|
70
|
+
assertEq(_loanOwnerOf(newLoanId), BORROWER, "new loan owned by borrower");
|
|
71
|
+
|
|
72
|
+
// Total collateral should remain approximately the same (moved, not destroyed).
|
|
73
|
+
// It increases by halfCollateral because the new loan's borrowFrom also adds collateral.
|
|
74
|
+
// But reallocateCollateralFromLoan transfers collateral from the existing loan (not burning new tokens),
|
|
75
|
+
// so totalCollateralOf should stay the same (the half was already in the system).
|
|
76
|
+
assertEq(
|
|
77
|
+
LOANS_CONTRACT.totalCollateralOf(revnetId), totalCollateralBefore, "total collateral should be unchanged"
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// @notice Reallocate with a different source terminal should revert.
|
|
82
|
+
function test_fork_reallocate_sourceMismatchReverts() public onlyFork {
|
|
83
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
84
|
+
(uint256 loanId, REVLoan memory loan) =
|
|
85
|
+
_createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
86
|
+
|
|
87
|
+
// Create a source with a different terminal address.
|
|
88
|
+
REVLoanSource memory badSource =
|
|
89
|
+
REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: IJBPayoutTerminal(address(0xdead))});
|
|
90
|
+
|
|
91
|
+
vm.prank(BORROWER);
|
|
92
|
+
vm.expectRevert();
|
|
93
|
+
LOANS_CONTRACT.reallocateCollateralFromLoan({
|
|
94
|
+
loanId: loanId,
|
|
95
|
+
collateralCountToTransfer: loan.collateral / 2,
|
|
96
|
+
source: badSource,
|
|
97
|
+
minBorrowAmount: 0,
|
|
98
|
+
collateralCountToAdd: 0,
|
|
99
|
+
beneficiary: payable(BORROWER),
|
|
100
|
+
prepaidFeePercent: LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT()
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|