@rev-net/core-v6 0.0.37 → 0.0.39
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/CHANGELOG.md +2 -2
- package/README.md +6 -7
- package/foundry.toml +1 -1
- package/package.json +23 -16
- package/references/operations.md +1 -1
- package/references/runtime.md +1 -1
- package/script/Deploy.s.sol +12 -9
- package/src/REVDeployer.sol +60 -65
- package/src/REVHiddenTokens.sol +2 -2
- package/src/REVLoans.sol +17 -10
- package/src/REVOwner.sol +121 -14
- package/src/interfaces/IREVDeployer.sol +2 -1
- package/src/interfaces/IREVHiddenTokens.sol +4 -1
- package/src/interfaces/IREVOwner.sol +5 -0
- package/ADMINISTRATION.md +0 -73
- package/ARCHITECTURE.md +0 -116
- package/AUDIT_INSTRUCTIONS.md +0 -90
- package/RISKS.md +0 -107
- package/SKILLS.md +0 -46
- package/STYLE_GUIDE.md +0 -610
- package/USER_JOURNEYS.md +0 -195
- package/foundry.lock +0 -11
- package/slither-ci.config.json +0 -10
- package/sphinx.lock +0 -507
- package/test/REV.integrations.t.sol +0 -573
- package/test/REVAutoIssuanceFuzz.t.sol +0 -328
- package/test/REVDeployerRegressions.t.sol +0 -396
- package/test/REVInvincibility.t.sol +0 -1371
- package/test/REVInvincibilityHandler.sol +0 -387
- package/test/REVLifecycle.t.sol +0 -420
- package/test/REVLoans.invariants.t.sol +0 -724
- package/test/REVLoansAttacks.t.sol +0 -816
- package/test/REVLoansFeeRecovery.t.sol +0 -783
- package/test/REVLoansFindings.t.sol +0 -711
- package/test/REVLoansRegressions.t.sol +0 -364
- package/test/REVLoansSourceFeeRecovery.t.sol +0 -517
- package/test/REVLoansSourced.t.sol +0 -1839
- package/test/REVLoansUnSourced.t.sol +0 -409
- package/test/TestAuditFixVerification.t.sol +0 -675
- package/test/TestBurnHeldTokens.t.sol +0 -394
- package/test/TestCEIPattern.t.sol +0 -508
- package/test/TestCashOutCallerValidation.t.sol +0 -452
- package/test/TestConversionDocumentation.t.sol +0 -365
- package/test/TestCrossCurrencyReclaim.t.sol +0 -610
- package/test/TestCrossSourceReallocation.t.sol +0 -361
- package/test/TestERC2771MetaTx.t.sol +0 -585
- package/test/TestEmptyBuybackSpecs.t.sol +0 -300
- package/test/TestFlashLoanSurplus.t.sol +0 -365
- package/test/TestHiddenTokens.t.sol +0 -474
- package/test/TestHookArrayOOB.t.sol +0 -278
- package/test/TestLiquidationBehavior.t.sol +0 -398
- package/test/TestLoanSourceRotation.t.sol +0 -553
- package/test/TestLoansCashOutDelay.t.sol +0 -493
- package/test/TestLongTailEconomics.t.sol +0 -677
- package/test/TestLowFindings.t.sol +0 -677
- package/test/TestMixedFixes.t.sol +0 -593
- package/test/TestPermit2Signatures.t.sol +0 -683
- package/test/TestReallocationSandwich.t.sol +0 -412
- package/test/TestRevnetRegressions.t.sol +0 -350
- package/test/TestSplitWeightAdjustment.t.sol +0 -527
- package/test/TestSplitWeightE2E.t.sol +0 -605
- package/test/TestSplitWeightFork.t.sol +0 -855
- package/test/TestStageTransitionBorrowable.t.sol +0 -301
- package/test/TestSwapTerminalPermission.t.sol +0 -262
- package/test/TestTerminalEncodingInHash.t.sol +0 -326
- package/test/TestUint112Overflow.t.sol +0 -311
- package/test/TestZeroAmountLoanGuard.t.sol +0 -378
- package/test/TestZeroRepayment.t.sol +0 -354
- package/test/audit/CrossChainBuybackRouteMismatch.t.sol +0 -184
- package/test/audit/HiddenSupplyCashout.t.sol +0 -61
- package/test/audit/LoanIdOverflowGuard.t.sol +0 -523
- package/test/audit/NemesisVerification.t.sol +0 -97
- package/test/audit/OperatorDelegation.t.sol +0 -356
- package/test/audit/PhantomSurplusTerminal.t.sol +0 -367
- package/test/audit/REVOwnerCurrencyMismatch.t.sol +0 -188
- package/test/audit/REVOwnerRemoteSurplusCurrencyMismatch.t.sol +0 -140
- package/test/audit/ReallocatePermission.t.sol +0 -363
- package/test/audit/RemoteLoanAccountingGap.t.sol +0 -74
- package/test/audit/SupportsInterfaceTest.t.sol +0 -51
- package/test/audit/TestFeeAllowanceLeak.t.sol +0 -197
- package/test/audit/TestLoansAndDeployerFixes.t.sol +0 -576
- package/test/fork/ForkTestBase.sol +0 -727
- package/test/fork/TestAutoIssuanceFork.t.sol +0 -148
- package/test/fork/TestCashOutFork.t.sol +0 -253
- package/test/fork/TestIssuanceDecayFork.t.sol +0 -158
- package/test/fork/TestLoanAdversarialFork.t.sol +0 -744
- package/test/fork/TestLoanBorrowFork.t.sol +0 -163
- package/test/fork/TestLoanCrossRulesetFork.t.sol +0 -308
- package/test/fork/TestLoanERC20Fork.t.sol +0 -459
- package/test/fork/TestLoanLiquidationFork.t.sol +0 -135
- package/test/fork/TestLoanReallocateFork.t.sol +0 -113
- package/test/fork/TestLoanRepayFork.t.sol +0 -188
- package/test/fork/TestLoanTransferFork.t.sol +0 -143
- package/test/fork/TestPermit2PaymentFork.t.sol +0 -300
- package/test/fork/TestSplitWeightFork.t.sol +0 -189
- package/test/helpers/MaliciousContracts.sol +0 -247
- package/test/helpers/REVEmpty721Config.sol +0 -45
- package/test/mock/MockBuybackCashOutRecorder.sol +0 -84
- package/test/mock/MockBuybackDataHook.sol +0 -112
- package/test/mock/MockBuybackDataHookMintPath.sol +0 -68
- package/test/mock/MockSuckerRegistry.sol +0 -17
- package/test/regression/TestBurnPermissionRequired.t.sol +0 -294
- package/test/regression/TestCashOutBuybackFeeLeak.t.sol +0 -232
- package/test/regression/TestCrossRevnetLiquidation.t.sol +0 -255
- package/test/regression/TestCumulativeLoanCounter.t.sol +0 -361
- package/test/regression/TestLiquidateGapHandling.t.sol +0 -394
- package/test/regression/TestZeroPriceFeed.t.sol +0 -422
|
@@ -1,744 +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
|
-
import {JBFees} from "@bananapus/core-v6/src/libraries/JBFees.sol";
|
|
8
|
-
|
|
9
|
-
/// @notice Adversarial fork tests for REVLoans — probes edge cases, exploit vectors, and boundary conditions.
|
|
10
|
-
///
|
|
11
|
-
/// Covers:
|
|
12
|
-
/// 1. Borrow-repay-reborrow flash loop profitability (Gap 10)
|
|
13
|
-
/// 2. Liquidation vs repay race at exact boundary (Gap 14)
|
|
14
|
-
/// 3. Cross-stage borrow with tax INCREASE (Gap 18)
|
|
15
|
-
/// 4. Many small loans vs one large loan (Gap 19)
|
|
16
|
-
/// 5. Source fee boundary timestamps
|
|
17
|
-
/// 6. Zero collateral and near-zero surplus edge cases
|
|
18
|
-
///
|
|
19
|
-
/// Run with: FOUNDRY_PROFILE=fork forge test --match-contract TestLoanAdversarialFork -vvv
|
|
20
|
-
contract TestLoanAdversarialFork is ForkTestBase {
|
|
21
|
-
// ───────────────────────── Shared state
|
|
22
|
-
// ─────────────────────────
|
|
23
|
-
|
|
24
|
-
uint256 revnetId;
|
|
25
|
-
uint256 constant STAGE_DURATION = 30 days;
|
|
26
|
-
|
|
27
|
-
function setUp() public override {
|
|
28
|
-
super.setUp();
|
|
29
|
-
|
|
30
|
-
// Deploy fee project + revnet with 50% cashOutTaxRate (non-linear bonding curve).
|
|
31
|
-
_deployFeeProject(5000);
|
|
32
|
-
revnetId = _deployRevnet(5000);
|
|
33
|
-
|
|
34
|
-
// Set up pool at 1:1 (mint path wins).
|
|
35
|
-
_setupPool(revnetId, 10_000 ether);
|
|
36
|
-
|
|
37
|
-
// Pay to create surplus from multiple payers so bonding curve tax has visible effect.
|
|
38
|
-
_payRevnet(revnetId, PAYER, 10 ether);
|
|
39
|
-
_payRevnet(revnetId, BORROWER, 5 ether);
|
|
40
|
-
|
|
41
|
-
address otherPayer = makeAddr("otherPayer");
|
|
42
|
-
vm.deal(otherPayer, 10 ether);
|
|
43
|
-
_payRevnet(revnetId, otherPayer, 5 ether);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/// @notice Deploy a revnet with a custom description salt to avoid CREATE2 collisions.
|
|
47
|
-
function _deployRevnetWithSalt(uint16 cashOutTaxRate, bytes32 salt) internal returns (uint256 revnetId) {
|
|
48
|
-
(REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) =
|
|
49
|
-
_buildMinimalConfig(cashOutTaxRate);
|
|
50
|
-
cfg.description.salt = salt;
|
|
51
|
-
|
|
52
|
-
(revnetId,) = REV_DEPLOYER.deployFor({
|
|
53
|
-
revnetId: 0,
|
|
54
|
-
configuration: cfg,
|
|
55
|
-
terminalConfigurations: tc,
|
|
56
|
-
suckerDeploymentConfiguration: sdc,
|
|
57
|
-
tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
|
|
58
|
-
allowedPosts: REVEmpty721Config.emptyAllowedPosts()
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
63
|
-
// Test 1: Borrow-then-immediately-repay flash loop (Gap 10)
|
|
64
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
65
|
-
|
|
66
|
-
/// @notice Borrow -> immediate repay -> reborrow in the same block. Tests whether the loop is profitable.
|
|
67
|
-
/// Within the prepaid period, repay costs exactly loan.amount (no source fee), so the borrower gets back
|
|
68
|
-
/// their original collateral. But they also received fee tokens from the source fee payment during borrow.
|
|
69
|
-
/// This test checks whether those extra tokens can be used to extract additional ETH on a second borrow.
|
|
70
|
-
function test_fork_adversarial_borrowRepayReborrow_sameBlock() public {
|
|
71
|
-
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
72
|
-
assertGt(borrowerTokens, 0, "borrower should have tokens");
|
|
73
|
-
|
|
74
|
-
uint256 borrowerEthBefore = BORROWER.balance;
|
|
75
|
-
uint256 minFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT();
|
|
76
|
-
|
|
77
|
-
// ── Step 1: First borrow using all tokens as collateral ──
|
|
78
|
-
_grantBurnPermission(BORROWER, revnetId);
|
|
79
|
-
|
|
80
|
-
REVLoanSource memory source = _nativeLoanSource();
|
|
81
|
-
vm.prank(BORROWER);
|
|
82
|
-
(uint256 loanId1, REVLoan memory loan1) = LOANS_CONTRACT.borrowFrom({
|
|
83
|
-
revnetId: revnetId,
|
|
84
|
-
source: source,
|
|
85
|
-
minBorrowAmount: 0,
|
|
86
|
-
collateralCount: borrowerTokens,
|
|
87
|
-
beneficiary: payable(BORROWER),
|
|
88
|
-
prepaidFeePercent: minFeePercent,
|
|
89
|
-
holder: BORROWER
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
uint256 ethAfterBorrow1 = BORROWER.balance;
|
|
93
|
-
uint256 ethReceivedFromBorrow1 = ethAfterBorrow1 - borrowerEthBefore;
|
|
94
|
-
assertGt(ethReceivedFromBorrow1, 0, "should receive ETH from first borrow");
|
|
95
|
-
|
|
96
|
-
// Record fee tokens minted to borrower from the source fee payment back to the revnet.
|
|
97
|
-
uint256 feeTokensFromBorrow1 = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
98
|
-
emit log_named_uint("Fee tokens from borrow 1", feeTokensFromBorrow1);
|
|
99
|
-
assertGt(feeTokensFromBorrow1, 0, "borrower should have fee tokens from source fee payment");
|
|
100
|
-
assertLt(feeTokensFromBorrow1, borrowerTokens, "fee tokens should be less than original collateral");
|
|
101
|
-
|
|
102
|
-
// ── Step 2: Immediately repay the full loan (same block, no vm.warp) ──
|
|
103
|
-
vm.deal(BORROWER, 100 ether);
|
|
104
|
-
uint256 borrowerEthBeforeRepay = BORROWER.balance;
|
|
105
|
-
|
|
106
|
-
JBSingleAllowance memory allowance;
|
|
107
|
-
vm.prank(BORROWER);
|
|
108
|
-
LOANS_CONTRACT.repayLoan{value: loan1.amount * 2}({
|
|
109
|
-
loanId: loanId1,
|
|
110
|
-
maxRepayBorrowAmount: loan1.amount * 2,
|
|
111
|
-
collateralCountToReturn: loan1.collateral,
|
|
112
|
-
beneficiary: payable(BORROWER),
|
|
113
|
-
allowance: allowance
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
uint256 ethSpentOnRepay = borrowerEthBeforeRepay - BORROWER.balance;
|
|
117
|
-
emit log_named_uint("ETH spent on repay", ethSpentOnRepay);
|
|
118
|
-
|
|
119
|
-
// Within prepaid period, repay costs exactly loan.amount (no source fee).
|
|
120
|
-
assertEq(ethSpentOnRepay, loan1.amount, "repay within prepaid should cost exactly loan amount");
|
|
121
|
-
|
|
122
|
-
// Borrower gets back original collateral + keeps fee tokens.
|
|
123
|
-
uint256 tokensAfterRepay = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
124
|
-
assertEq(
|
|
125
|
-
tokensAfterRepay,
|
|
126
|
-
borrowerTokens + feeTokensFromBorrow1,
|
|
127
|
-
"should have original tokens + fee tokens after repay"
|
|
128
|
-
);
|
|
129
|
-
emit log_named_uint("Tokens after repay (original + fee)", tokensAfterRepay);
|
|
130
|
-
|
|
131
|
-
// ── Step 3: Second borrow using original + fee tokens ──
|
|
132
|
-
_grantBurnPermission(BORROWER, revnetId);
|
|
133
|
-
|
|
134
|
-
uint256 borrowerEthBeforeBorrow2 = BORROWER.balance;
|
|
135
|
-
|
|
136
|
-
vm.prank(BORROWER);
|
|
137
|
-
(uint256 loanId2, REVLoan memory loan2) = LOANS_CONTRACT.borrowFrom({
|
|
138
|
-
revnetId: revnetId,
|
|
139
|
-
source: source,
|
|
140
|
-
minBorrowAmount: 0,
|
|
141
|
-
collateralCount: tokensAfterRepay,
|
|
142
|
-
beneficiary: payable(BORROWER),
|
|
143
|
-
prepaidFeePercent: minFeePercent,
|
|
144
|
-
holder: BORROWER
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
uint256 ethReceivedFromBorrow2 = BORROWER.balance - borrowerEthBeforeBorrow2;
|
|
148
|
-
emit log_named_uint("ETH from borrow 1", ethReceivedFromBorrow1);
|
|
149
|
-
emit log_named_uint("ETH from borrow 2", ethReceivedFromBorrow2);
|
|
150
|
-
emit log_named_uint("Loan 1 amount", loan1.amount);
|
|
151
|
-
emit log_named_uint("Loan 2 amount", loan2.amount);
|
|
152
|
-
|
|
153
|
-
// The second borrow should succeed (more collateral = more borrowable).
|
|
154
|
-
assertGt(loanId2, 0, "second borrow should succeed");
|
|
155
|
-
|
|
156
|
-
// Key check: is the borrow-repay-reborrow loop profitable?
|
|
157
|
-
//
|
|
158
|
-
// The correct analysis compares the FULL CLOSE-OUT cost. If the borrower were to repay BOTH
|
|
159
|
-
// loans to completion, the economics are:
|
|
160
|
-
// Total ETH received: ethReceivedFromBorrow1 + ethReceivedFromBorrow2
|
|
161
|
-
// Total repayment cost: loan1.amount + loan2.amount
|
|
162
|
-
//
|
|
163
|
-
// Each borrow incurs a prepaid fee, so total repayment > total received (net loss).
|
|
164
|
-
// The loop should NOT be profitable because:
|
|
165
|
-
// 1. Each borrow deducts a prepaid source fee from the disbursed amount
|
|
166
|
-
// 2. The bonding curve means more tokens != proportionally more ETH
|
|
167
|
-
// 3. The 2.5% prepaid fee is taken on each borrow
|
|
168
|
-
uint256 totalEthReceived = ethReceivedFromBorrow1 + ethReceivedFromBorrow2;
|
|
169
|
-
uint256 totalRepaymentCost = loan1.amount + loan2.amount;
|
|
170
|
-
emit log_named_uint("Total ETH received from both borrows", totalEthReceived);
|
|
171
|
-
emit log_named_uint("Total repayment cost (loan1 + loan2)", totalRepaymentCost);
|
|
172
|
-
|
|
173
|
-
// Should be a loss (total cost > total received) — the fee prevents arbitrage.
|
|
174
|
-
assertGt(totalRepaymentCost, totalEthReceived, "full cycle should be a net loss (fee prevents arbitrage)");
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
178
|
-
// Test 2: Liquidation vs repay race at exact boundary (Gap 14)
|
|
179
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
180
|
-
|
|
181
|
-
/// @notice Tests the exact second at the liquidation boundary.
|
|
182
|
-
/// At `createdAt + LOAN_LIQUIDATION_DURATION` (exactly), repay should SUCCEED (code uses `>` at L468).
|
|
183
|
-
/// At `createdAt + LOAN_LIQUIDATION_DURATION + 1`, repay should REVERT and liquidation should SUCCEED.
|
|
184
|
-
function test_fork_adversarial_liquidationRepayRace_exactBoundary() public {
|
|
185
|
-
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
186
|
-
uint256 minFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT();
|
|
187
|
-
|
|
188
|
-
// Create a loan.
|
|
189
|
-
(uint256 loanId, REVLoan memory loan) = _createLoan(revnetId, BORROWER, borrowerTokens, minFeePercent);
|
|
190
|
-
assertGt(loanId, 0, "loan should be created");
|
|
191
|
-
|
|
192
|
-
uint256 loanLiquidationDuration = LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION();
|
|
193
|
-
uint256 exactBoundary = loan.createdAt + loanLiquidationDuration;
|
|
194
|
-
|
|
195
|
-
// ── Scenario A: Warp to EXACTLY the boundary ──
|
|
196
|
-
uint256 snapshotA = vm.snapshot();
|
|
197
|
-
|
|
198
|
-
vm.warp(exactBoundary);
|
|
199
|
-
|
|
200
|
-
// At exactly the boundary, timeSinceLoanCreated == LOAN_LIQUIDATION_DURATION.
|
|
201
|
-
// The code checks `timeSinceLoanCreated > LOAN_LIQUIDATION_DURATION`, so `==` should NOT revert.
|
|
202
|
-
// Repay should SUCCEED.
|
|
203
|
-
vm.deal(BORROWER, 100 ether);
|
|
204
|
-
|
|
205
|
-
JBSingleAllowance memory allowance;
|
|
206
|
-
|
|
207
|
-
vm.prank(BORROWER);
|
|
208
|
-
LOANS_CONTRACT.repayLoan{value: loan.amount * 3}({
|
|
209
|
-
loanId: loanId,
|
|
210
|
-
maxRepayBorrowAmount: loan.amount * 3,
|
|
211
|
-
collateralCountToReturn: loan.collateral,
|
|
212
|
-
beneficiary: payable(BORROWER),
|
|
213
|
-
allowance: allowance
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
// Verify repay worked: loan NFT should be burned.
|
|
217
|
-
vm.expectRevert();
|
|
218
|
-
_loanOwnerOf(loanId);
|
|
219
|
-
|
|
220
|
-
// Verify collateral returned.
|
|
221
|
-
uint256 tokensAfterRepay = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
222
|
-
assertGt(tokensAfterRepay, 0, "borrower should get collateral back at exact boundary");
|
|
223
|
-
emit log_named_uint("Tokens after repay at exact boundary", tokensAfterRepay);
|
|
224
|
-
|
|
225
|
-
// ── Scenario B: Revert to snapshot, warp to boundary + 1 ──
|
|
226
|
-
vm.revertTo(snapshotA);
|
|
227
|
-
|
|
228
|
-
vm.warp(exactBoundary + 1);
|
|
229
|
-
|
|
230
|
-
// At boundary + 1, timeSinceLoanCreated > LOAN_LIQUIDATION_DURATION, so repay should REVERT.
|
|
231
|
-
vm.deal(BORROWER, 100 ether);
|
|
232
|
-
|
|
233
|
-
vm.prank(BORROWER);
|
|
234
|
-
vm.expectRevert();
|
|
235
|
-
LOANS_CONTRACT.repayLoan{value: loan.amount * 3}({
|
|
236
|
-
loanId: loanId,
|
|
237
|
-
maxRepayBorrowAmount: loan.amount * 3,
|
|
238
|
-
collateralCountToReturn: loan.collateral,
|
|
239
|
-
beneficiary: payable(BORROWER),
|
|
240
|
-
allowance: allowance
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
// Loan should still exist (repay failed).
|
|
244
|
-
assertEq(_loanOwnerOf(loanId), BORROWER, "loan should still exist after failed repay");
|
|
245
|
-
|
|
246
|
-
// ── Liquidation should SUCCEED at boundary + 1 ──
|
|
247
|
-
// The liquidation check is `block.timestamp <= loan.createdAt + LOAN_LIQUIDATION_DURATION`,
|
|
248
|
-
// which at boundary+1 evaluates to false, so liquidation proceeds.
|
|
249
|
-
uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(revnetId);
|
|
250
|
-
|
|
251
|
-
// Loan number is 1 (first loan for this revnet).
|
|
252
|
-
LOANS_CONTRACT.liquidateExpiredLoansFrom(revnetId, 1, 1);
|
|
253
|
-
|
|
254
|
-
// Loan NFT should be burned.
|
|
255
|
-
vm.expectRevert();
|
|
256
|
-
_loanOwnerOf(loanId);
|
|
257
|
-
|
|
258
|
-
// Collateral permanently lost.
|
|
259
|
-
uint256 totalCollateralAfter = LOANS_CONTRACT.totalCollateralOf(revnetId);
|
|
260
|
-
assertEq(
|
|
261
|
-
totalCollateralAfter,
|
|
262
|
-
totalCollateralBefore - borrowerTokens,
|
|
263
|
-
"total collateral should decrease after liquidation"
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
emit log_string("Confirmed: repay succeeds at exact boundary, fails at boundary+1. Liquidation succeeds at boundary+1.");
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
270
|
-
// Test 3: Cross-stage borrow with tax INCREASE (Gap 18)
|
|
271
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
272
|
-
|
|
273
|
-
/// @notice Build a two-stage config: stage 1 (low tax), stage 2 (high tax).
|
|
274
|
-
function _buildTwoStageConfig_taxIncrease(
|
|
275
|
-
uint16 stage1TaxRate,
|
|
276
|
-
uint16 stage2TaxRate
|
|
277
|
-
)
|
|
278
|
-
internal
|
|
279
|
-
view
|
|
280
|
-
returns (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc)
|
|
281
|
-
{
|
|
282
|
-
JBAccountingContext[] memory acc = new JBAccountingContext[](1);
|
|
283
|
-
acc[0] = JBAccountingContext({
|
|
284
|
-
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
285
|
-
});
|
|
286
|
-
tc = new JBTerminalConfig[](1);
|
|
287
|
-
tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
|
|
288
|
-
|
|
289
|
-
REVStageConfig[] memory stages = new REVStageConfig[](2);
|
|
290
|
-
JBSplit[] memory splits = new JBSplit[](1);
|
|
291
|
-
splits[0].beneficiary = payable(multisig());
|
|
292
|
-
splits[0].percent = 10_000;
|
|
293
|
-
|
|
294
|
-
// Stage 1: low tax -- starts immediately.
|
|
295
|
-
stages[0] = REVStageConfig({
|
|
296
|
-
startsAtOrAfter: uint40(block.timestamp),
|
|
297
|
-
autoIssuances: new REVAutoIssuance[](0),
|
|
298
|
-
splitPercent: 0,
|
|
299
|
-
splits: splits,
|
|
300
|
-
initialIssuance: INITIAL_ISSUANCE,
|
|
301
|
-
issuanceCutFrequency: 0,
|
|
302
|
-
issuanceCutPercent: 0,
|
|
303
|
-
cashOutTaxRate: stage1TaxRate,
|
|
304
|
-
extraMetadata: 0
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
// Stage 2: high tax -- starts after STAGE_DURATION.
|
|
308
|
-
stages[1] = REVStageConfig({
|
|
309
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
310
|
-
startsAtOrAfter: uint40(block.timestamp + STAGE_DURATION),
|
|
311
|
-
autoIssuances: new REVAutoIssuance[](0),
|
|
312
|
-
splitPercent: 0,
|
|
313
|
-
splits: splits,
|
|
314
|
-
initialIssuance: INITIAL_ISSUANCE,
|
|
315
|
-
issuanceCutFrequency: 0,
|
|
316
|
-
issuanceCutPercent: 0,
|
|
317
|
-
cashOutTaxRate: stage2TaxRate,
|
|
318
|
-
extraMetadata: 0
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
cfg = REVConfig({
|
|
322
|
-
// forge-lint: disable-next-line(named-struct-fields)
|
|
323
|
-
description: REVDescription("TaxIncrease", "TXUP", "ipfs://txup", "TXUP_SALT"),
|
|
324
|
-
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
325
|
-
splitOperator: multisig(),
|
|
326
|
-
stageConfigurations: stages
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
sdc = REVSuckerDeploymentConfig({
|
|
330
|
-
deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("TXUP"))
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/// @notice Tests that a tax increase between stages reduces borrowable amount for the same collateral,
|
|
335
|
-
/// effectively undercollateralizing existing loans.
|
|
336
|
-
function test_fork_adversarial_crossStage_taxIncrease() public {
|
|
337
|
-
// Deploy a separate two-stage revnet: stage1 tax=2000 (20%), stage2 tax=7000 (70%).
|
|
338
|
-
(REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) =
|
|
339
|
-
_buildTwoStageConfig_taxIncrease(2000, 7000);
|
|
340
|
-
|
|
341
|
-
(uint256 taxRevnetId,) = REV_DEPLOYER.deployFor({
|
|
342
|
-
revnetId: 0,
|
|
343
|
-
configuration: cfg,
|
|
344
|
-
terminalConfigurations: tc,
|
|
345
|
-
suckerDeploymentConfiguration: sdc,
|
|
346
|
-
tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
|
|
347
|
-
allowedPosts: REVEmpty721Config.emptyAllowedPosts()
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
// Set up pool.
|
|
351
|
-
_setupPool(taxRevnetId, 10_000 ether);
|
|
352
|
-
|
|
353
|
-
// Pay 10 ETH to create surplus. Use PAYER first to establish surplus.
|
|
354
|
-
_payRevnet(taxRevnetId, PAYER, 10 ether);
|
|
355
|
-
|
|
356
|
-
// Borrower pays 10 ETH.
|
|
357
|
-
uint256 borrowerTokens = _payRevnet(taxRevnetId, BORROWER, 10 ether);
|
|
358
|
-
assertGt(borrowerTokens, 0, "borrower should have tokens");
|
|
359
|
-
emit log_named_uint("Borrower tokens in stage 1", borrowerTokens);
|
|
360
|
-
|
|
361
|
-
// Record borrowable amount in stage 1 (20% tax).
|
|
362
|
-
uint256 borrowableStage1 = LOANS_CONTRACT.borrowableAmountFrom(
|
|
363
|
-
taxRevnetId, borrowerTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
364
|
-
);
|
|
365
|
-
assertGt(borrowableStage1, 0, "should have borrowable amount in stage 1");
|
|
366
|
-
emit log_named_uint("Borrowable in stage 1 (20% tax)", borrowableStage1);
|
|
367
|
-
|
|
368
|
-
// ── Borrow in stage 1 ──
|
|
369
|
-
(uint256 loanId, REVLoan memory loan) =
|
|
370
|
-
_createLoan(taxRevnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
371
|
-
assertGt(loanId, 0, "loan should be created in stage 1");
|
|
372
|
-
emit log_named_uint("Loan amount (stage 1)", loan.amount);
|
|
373
|
-
|
|
374
|
-
// ── Warp to stage 2 (70% tax) ──
|
|
375
|
-
vm.warp(block.timestamp + STAGE_DURATION + 1);
|
|
376
|
-
|
|
377
|
-
// Check new borrowable amount -- should DECREASE because higher tax means less surplus per token.
|
|
378
|
-
uint256 borrowableStage2 = LOANS_CONTRACT.borrowableAmountFrom(
|
|
379
|
-
taxRevnetId, borrowerTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
380
|
-
);
|
|
381
|
-
emit log_named_uint("Borrowable in stage 2 (70% tax)", borrowableStage2);
|
|
382
|
-
|
|
383
|
-
// Higher tax rate should decrease borrowable amount.
|
|
384
|
-
assertLt(borrowableStage2, borrowableStage1, "borrowable should DECREASE with higher tax");
|
|
385
|
-
|
|
386
|
-
// Check if the loan is effectively undercollateralized.
|
|
387
|
-
// The loan amount was set at stage 1 rates, but now the same collateral is worth less.
|
|
388
|
-
if (borrowableStage2 < loan.amount) {
|
|
389
|
-
emit log_string("Loan is effectively undercollateralized in stage 2");
|
|
390
|
-
emit log_named_uint("Loan amount", loan.amount);
|
|
391
|
-
emit log_named_uint("Current borrowable for same collateral", borrowableStage2);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// ── Can the borrower still repay? Yes, they just pay the original amount ──
|
|
395
|
-
vm.deal(BORROWER, 100 ether);
|
|
396
|
-
JBSingleAllowance memory allowance;
|
|
397
|
-
|
|
398
|
-
vm.prank(BORROWER);
|
|
399
|
-
LOANS_CONTRACT.repayLoan{value: loan.amount * 3}({
|
|
400
|
-
loanId: loanId,
|
|
401
|
-
maxRepayBorrowAmount: loan.amount * 3,
|
|
402
|
-
collateralCountToReturn: loan.collateral,
|
|
403
|
-
beneficiary: payable(BORROWER),
|
|
404
|
-
allowance: allowance
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
// Verify collateral returned.
|
|
408
|
-
uint256 tokensAfterRepay = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
409
|
-
emit log_named_uint("Tokens after repay in stage 2", tokensAfterRepay);
|
|
410
|
-
|
|
411
|
-
// ── Can a new borrower borrow with the same amount of collateral in stage 2? ──
|
|
412
|
-
// They should get less ETH than what was borrowed in stage 1.
|
|
413
|
-
address newBorrower = makeAddr("newBorrower");
|
|
414
|
-
vm.deal(newBorrower, 20 ether);
|
|
415
|
-
uint256 newBorrowerTokens = _payRevnet(taxRevnetId, newBorrower, 10 ether);
|
|
416
|
-
|
|
417
|
-
uint256 newBorrowable = LOANS_CONTRACT.borrowableAmountFrom(
|
|
418
|
-
taxRevnetId, newBorrowerTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
419
|
-
);
|
|
420
|
-
emit log_named_uint("New borrower's borrowable in stage 2", newBorrowable);
|
|
421
|
-
|
|
422
|
-
// Stage 2 borrowable for new tokens should be less than stage 1 borrowable for the same token count,
|
|
423
|
-
// because the higher tax rate reduces the bonding curve output.
|
|
424
|
-
// Note: new borrower may have different token count due to supply changes, so we compare per-token rates.
|
|
425
|
-
if (newBorrowerTokens > 0 && borrowerTokens > 0) {
|
|
426
|
-
uint256 rateStage1 = (borrowableStage1 * 1e18) / borrowerTokens;
|
|
427
|
-
uint256 rateStage2 = (newBorrowable * 1e18) / newBorrowerTokens;
|
|
428
|
-
emit log_named_uint("Per-token borrowable rate stage 1 (wei)", rateStage1);
|
|
429
|
-
emit log_named_uint("Per-token borrowable rate stage 2 (wei)", rateStage2);
|
|
430
|
-
assertLt(rateStage2, rateStage1, "per-token borrowable rate should decrease with higher tax");
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
435
|
-
// Test 4: Multiple small loans vs one large loan (Gap 19)
|
|
436
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
437
|
-
|
|
438
|
-
/// @notice Compare: one large loan vs many small loans. With a non-linear bonding curve (cashOutTaxRate > 0),
|
|
439
|
-
/// splitting collateral into smaller chunks should yield a different total due to the bonding curve.
|
|
440
|
-
function test_fork_adversarial_manySmallLoans_vsOneLarge() public {
|
|
441
|
-
// Borrower pays 10 ETH to get tokens at weight 1000.
|
|
442
|
-
address splitBorrower = makeAddr("splitBorrower");
|
|
443
|
-
vm.deal(splitBorrower, 100 ether);
|
|
444
|
-
uint256 splitBorrowerTokens = _payRevnet(revnetId, splitBorrower, 10 ether);
|
|
445
|
-
assertGt(splitBorrowerTokens, 0, "splitBorrower should have tokens");
|
|
446
|
-
emit log_named_uint("Split borrower tokens", splitBorrowerTokens);
|
|
447
|
-
|
|
448
|
-
uint256 minFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT();
|
|
449
|
-
|
|
450
|
-
// ── Scenario A: One large loan with ALL tokens ──
|
|
451
|
-
uint256 snapshotA = vm.snapshot();
|
|
452
|
-
|
|
453
|
-
_grantBurnPermission(splitBorrower, revnetId);
|
|
454
|
-
|
|
455
|
-
REVLoanSource memory source = _nativeLoanSource();
|
|
456
|
-
uint256 ethBeforeA = splitBorrower.balance;
|
|
457
|
-
|
|
458
|
-
vm.prank(splitBorrower);
|
|
459
|
-
(uint256 loanIdA, REVLoan memory loanA) = LOANS_CONTRACT.borrowFrom({
|
|
460
|
-
revnetId: revnetId,
|
|
461
|
-
source: source,
|
|
462
|
-
minBorrowAmount: 0,
|
|
463
|
-
collateralCount: splitBorrowerTokens,
|
|
464
|
-
beneficiary: payable(splitBorrower),
|
|
465
|
-
prepaidFeePercent: minFeePercent,
|
|
466
|
-
holder: splitBorrower
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
uint256 ethReceivedA = splitBorrower.balance - ethBeforeA;
|
|
470
|
-
emit log_named_uint("Scenario A (one large loan) - ETH received", ethReceivedA);
|
|
471
|
-
emit log_named_uint("Scenario A - Loan amount", loanA.amount);
|
|
472
|
-
assertGt(loanIdA, 0, "large loan should be created");
|
|
473
|
-
|
|
474
|
-
// ── Scenario B: Revert, then take 5 small loans with splitBorrowerTokens / 5 each ──
|
|
475
|
-
vm.revertTo(snapshotA);
|
|
476
|
-
|
|
477
|
-
uint256 chunkSize = splitBorrowerTokens / 5;
|
|
478
|
-
assertGt(chunkSize, 0, "chunk size should be > 0");
|
|
479
|
-
|
|
480
|
-
uint256 totalEthReceivedB;
|
|
481
|
-
uint256 tokensUsedB;
|
|
482
|
-
|
|
483
|
-
for (uint256 i; i < 5; i++) {
|
|
484
|
-
_grantBurnPermission(splitBorrower, revnetId);
|
|
485
|
-
|
|
486
|
-
uint256 ethBeforeChunk = splitBorrower.balance;
|
|
487
|
-
|
|
488
|
-
// Use chunkSize for all 5 chunks (any remainder from integer division is ignored).
|
|
489
|
-
vm.prank(splitBorrower);
|
|
490
|
-
LOANS_CONTRACT.borrowFrom({
|
|
491
|
-
revnetId: revnetId,
|
|
492
|
-
source: source,
|
|
493
|
-
minBorrowAmount: 0,
|
|
494
|
-
collateralCount: chunkSize,
|
|
495
|
-
beneficiary: payable(splitBorrower),
|
|
496
|
-
prepaidFeePercent: minFeePercent,
|
|
497
|
-
holder: splitBorrower
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
uint256 ethFromChunk = splitBorrower.balance - ethBeforeChunk;
|
|
501
|
-
totalEthReceivedB += ethFromChunk;
|
|
502
|
-
tokensUsedB += chunkSize;
|
|
503
|
-
emit log_named_uint(string(abi.encodePacked("Scenario B chunk ", vm.toString(i), " ETH")), ethFromChunk);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
emit log_named_uint("Scenario B (5 small loans) - Total ETH received", totalEthReceivedB);
|
|
507
|
-
|
|
508
|
-
// Compare the two scenarios.
|
|
509
|
-
emit log_named_uint("Tokens used in A", splitBorrowerTokens);
|
|
510
|
-
emit log_named_uint("Tokens used in B", tokensUsedB);
|
|
511
|
-
|
|
512
|
-
// With a non-linear bonding curve (cashOutTaxRate = 50%), splitting should produce a DIFFERENT result.
|
|
513
|
-
// Specifically, the bonding curve formula penalizes larger cashouts relative to total supply,
|
|
514
|
-
// so splitting into smaller chunks should yield MORE total ETH.
|
|
515
|
-
if (totalEthReceivedB > ethReceivedA) {
|
|
516
|
-
uint256 advantage = totalEthReceivedB - ethReceivedA;
|
|
517
|
-
uint256 advantagePercent = (advantage * 10_000) / ethReceivedA;
|
|
518
|
-
emit log_named_uint("Splitting advantage (wei)", advantage);
|
|
519
|
-
emit log_named_uint("Splitting advantage (bps)", advantagePercent);
|
|
520
|
-
emit log_string("Splitting loans yields more ETH -- bonding curve non-linearity confirmed");
|
|
521
|
-
} else if (ethReceivedA > totalEthReceivedB) {
|
|
522
|
-
uint256 disadvantage = ethReceivedA - totalEthReceivedB;
|
|
523
|
-
uint256 disadvantagePercent = (disadvantage * 10_000) / ethReceivedA;
|
|
524
|
-
emit log_named_uint("Splitting disadvantage (wei)", disadvantage);
|
|
525
|
-
emit log_named_uint("Splitting disadvantage (bps)", disadvantagePercent);
|
|
526
|
-
emit log_string("Single large loan yields more ETH");
|
|
527
|
-
} else {
|
|
528
|
-
emit log_string("Both strategies yield identical ETH");
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// The difference should exist but be bounded -- the protocol should not allow unbounded extraction.
|
|
532
|
-
// We verify that neither strategy yields more than 10% advantage over the other.
|
|
533
|
-
if (totalEthReceivedB > ethReceivedA) {
|
|
534
|
-
assertLt(
|
|
535
|
-
totalEthReceivedB - ethReceivedA,
|
|
536
|
-
ethReceivedA / 10,
|
|
537
|
-
"splitting advantage should be < 10% of single loan"
|
|
538
|
-
);
|
|
539
|
-
} else if (ethReceivedA > totalEthReceivedB) {
|
|
540
|
-
assertLt(
|
|
541
|
-
ethReceivedA - totalEthReceivedB,
|
|
542
|
-
totalEthReceivedB / 10,
|
|
543
|
-
"single loan advantage should be < 10% of split loans"
|
|
544
|
-
);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
549
|
-
// Test 5: Source fee boundary timestamps
|
|
550
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
551
|
-
|
|
552
|
-
/// @notice Tests source fee calculation at exact boundary timestamps using the view function.
|
|
553
|
-
function test_fork_adversarial_sourceFee_exactBoundaries() public {
|
|
554
|
-
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
555
|
-
uint256 minFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT();
|
|
556
|
-
|
|
557
|
-
// Create a loan.
|
|
558
|
-
(uint256 loanId, REVLoan memory loan) = _createLoan(revnetId, BORROWER, borrowerTokens, minFeePercent);
|
|
559
|
-
assertGt(loanId, 0, "loan should be created");
|
|
560
|
-
|
|
561
|
-
uint256 loanLiquidationDuration = LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION();
|
|
562
|
-
|
|
563
|
-
// ── Check 1: At exactly createdAt + prepaidDuration -- should be 0 ──
|
|
564
|
-
vm.warp(loan.createdAt + loan.prepaidDuration);
|
|
565
|
-
uint256 feeAtPrepaidEnd = LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
|
|
566
|
-
assertEq(feeAtPrepaidEnd, 0, "fee at exactly prepaidDuration should be 0");
|
|
567
|
-
emit log_named_uint("Fee at prepaidDuration boundary", feeAtPrepaidEnd);
|
|
568
|
-
|
|
569
|
-
// ── Check 2: At createdAt + prepaidDuration + 1 -- fee is still 0 due to integer rounding ──
|
|
570
|
-
// The feePercent = mulDiv(1, MAX_FEE, feeWindow) rounds to 0 when feeWindow >> MAX_FEE.
|
|
571
|
-
// With MIN_PREPAID_FEE_PERCENT=25 and LOAN_LIQUIDATION_DURATION=3650 days, the fee window
|
|
572
|
-
// is ~299,592,000 seconds while MAX_FEE is only 1000, so 1 second yields feePercent=0.
|
|
573
|
-
vm.warp(loan.createdAt + loan.prepaidDuration + 1);
|
|
574
|
-
uint256 feeAtPrepaidPlus1 = LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
|
|
575
|
-
assertEq(feeAtPrepaidPlus1, 0, "fee at prepaidDuration + 1 should be 0 (integer rounding)");
|
|
576
|
-
emit log_named_uint("Fee at prepaidDuration + 1", feeAtPrepaidPlus1);
|
|
577
|
-
|
|
578
|
-
// ── Check 3: At midpoint between prepaid and liquidation ──
|
|
579
|
-
uint256 midpoint = loan.createdAt + loan.prepaidDuration + (loanLiquidationDuration - loan.prepaidDuration) / 2;
|
|
580
|
-
vm.warp(midpoint);
|
|
581
|
-
uint256 feeAtMidpoint = LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
|
|
582
|
-
assertGt(feeAtMidpoint, 0, "fee at midpoint should be non-zero");
|
|
583
|
-
emit log_named_uint("Fee at midpoint", feeAtMidpoint);
|
|
584
|
-
|
|
585
|
-
// ── Check 4: At createdAt + LOAN_LIQUIDATION_DURATION (exact boundary) ──
|
|
586
|
-
vm.warp(loan.createdAt + loanLiquidationDuration);
|
|
587
|
-
uint256 feeAtLiquidation = LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
|
|
588
|
-
assertGt(feeAtLiquidation, feeAtMidpoint, "fee at liquidation boundary should be maximum");
|
|
589
|
-
emit log_named_uint("Fee at liquidation boundary (maximum)", feeAtLiquidation);
|
|
590
|
-
|
|
591
|
-
// ── Check 5: At createdAt + LOAN_LIQUIDATION_DURATION + 1 -- should revert ──
|
|
592
|
-
vm.warp(loan.createdAt + loanLiquidationDuration + 1);
|
|
593
|
-
vm.expectRevert();
|
|
594
|
-
LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
|
|
595
|
-
emit log_string("Fee at liquidation + 1 correctly reverts with REVLoans_LoanExpired");
|
|
596
|
-
|
|
597
|
-
// ── Verify monotonicity: fee should strictly increase from prepaid end to liquidation ──
|
|
598
|
-
// Sample at 10 evenly spaced points.
|
|
599
|
-
uint256 feeWindow = loanLiquidationDuration - loan.prepaidDuration;
|
|
600
|
-
uint256 previousFee;
|
|
601
|
-
for (uint256 i = 1; i <= 10; i++) {
|
|
602
|
-
uint256 timestamp = loan.createdAt + loan.prepaidDuration + (feeWindow * i) / 10;
|
|
603
|
-
vm.warp(timestamp);
|
|
604
|
-
uint256 currentFee = LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
|
|
605
|
-
assertGe(currentFee, previousFee, "fee should be monotonically non-decreasing");
|
|
606
|
-
previousFee = currentFee;
|
|
607
|
-
}
|
|
608
|
-
emit log_string("Source fee monotonicity confirmed across 10 sample points");
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
612
|
-
// Test 6: Zero collateral and near-zero surplus edge cases
|
|
613
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
614
|
-
|
|
615
|
-
/// @notice Tests borrow with zero collateral (should revert), minimal collateral (dust), and
|
|
616
|
-
/// large surplus with minimal collateral (should succeed).
|
|
617
|
-
function test_fork_adversarial_zeroCollateral_reverts() public {
|
|
618
|
-
uint256 minFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT();
|
|
619
|
-
REVLoanSource memory source = _nativeLoanSource();
|
|
620
|
-
|
|
621
|
-
// ── Case 1: Zero collateral -- should revert with REVLoans_ZeroCollateralLoanIsInvalid ──
|
|
622
|
-
// NOTE: We do NOT call _grantBurnPermission here because borrowFrom reverts with
|
|
623
|
-
// REVLoans_ZeroCollateralLoanIsInvalid before reaching the burn/permission check.
|
|
624
|
-
// _grantBurnPermission uses mockExpect which sets vm.expectCall — that would fail
|
|
625
|
-
// since the expected hasPermission call never fires.
|
|
626
|
-
|
|
627
|
-
vm.prank(BORROWER);
|
|
628
|
-
vm.expectRevert(REVLoans.REVLoans_ZeroCollateralLoanIsInvalid.selector);
|
|
629
|
-
LOANS_CONTRACT.borrowFrom({
|
|
630
|
-
revnetId: revnetId,
|
|
631
|
-
source: source,
|
|
632
|
-
minBorrowAmount: 0,
|
|
633
|
-
collateralCount: 0,
|
|
634
|
-
beneficiary: payable(BORROWER),
|
|
635
|
-
prepaidFeePercent: minFeePercent,
|
|
636
|
-
holder: BORROWER
|
|
637
|
-
});
|
|
638
|
-
emit log_string("Case 1 passed: zero collateral correctly reverts");
|
|
639
|
-
|
|
640
|
-
// ── Case 2: Near-zero surplus with minimal collateral ──
|
|
641
|
-
// Deploy a fresh revnet with no surplus to test dust behavior.
|
|
642
|
-
// Use a unique description salt to avoid CREATE2 collision with other revnets in this test contract.
|
|
643
|
-
uint256 dustRevnetId = _deployRevnetWithSalt(5000, bytes32("DUST_SALT"));
|
|
644
|
-
_setupPool(dustRevnetId, 10_000 ether);
|
|
645
|
-
|
|
646
|
-
// Pay 1 wei to create minimal surplus.
|
|
647
|
-
address dustPayer = makeAddr("dustPayer");
|
|
648
|
-
vm.deal(dustPayer, 1 ether);
|
|
649
|
-
|
|
650
|
-
// Pay 1 wei -- this creates minimal surplus and mints minimal tokens.
|
|
651
|
-
vm.prank(dustPayer);
|
|
652
|
-
uint256 dustTokens = jbMultiTerminal().pay{value: 1 wei}({
|
|
653
|
-
projectId: dustRevnetId,
|
|
654
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
655
|
-
amount: 1 wei,
|
|
656
|
-
beneficiary: dustPayer,
|
|
657
|
-
minReturnedTokens: 0,
|
|
658
|
-
memo: "",
|
|
659
|
-
metadata: ""
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
emit log_named_uint("Tokens from 1 wei payment", dustTokens);
|
|
663
|
-
|
|
664
|
-
if (dustTokens > 0) {
|
|
665
|
-
// Check if borrowable amount from 1 dust token is zero.
|
|
666
|
-
uint256 dustBorrowable = LOANS_CONTRACT.borrowableAmountFrom(
|
|
667
|
-
dustRevnetId, dustTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
668
|
-
);
|
|
669
|
-
emit log_named_uint("Borrowable from dust tokens", dustBorrowable);
|
|
670
|
-
|
|
671
|
-
if (dustBorrowable == 0) {
|
|
672
|
-
// Should revert with ZeroBorrowAmount.
|
|
673
|
-
// NOTE: We intentionally do NOT call _grantBurnPermission here because borrowFrom
|
|
674
|
-
// reverts with REVLoans_ZeroBorrowAmount before it ever reaches the burn/permission
|
|
675
|
-
// check. Calling _grantBurnPermission would set up a vm.expectCall (via mockExpect)
|
|
676
|
-
// for hasPermission that never fires, causing the test to fail.
|
|
677
|
-
vm.prank(dustPayer);
|
|
678
|
-
vm.expectRevert(REVLoans.REVLoans_ZeroBorrowAmount.selector);
|
|
679
|
-
LOANS_CONTRACT.borrowFrom({
|
|
680
|
-
revnetId: dustRevnetId,
|
|
681
|
-
source: source,
|
|
682
|
-
minBorrowAmount: 0,
|
|
683
|
-
collateralCount: dustTokens,
|
|
684
|
-
beneficiary: payable(dustPayer),
|
|
685
|
-
prepaidFeePercent: minFeePercent,
|
|
686
|
-
holder: dustPayer
|
|
687
|
-
});
|
|
688
|
-
emit log_string("Case 2 passed: dust collateral with zero borrowable correctly reverts");
|
|
689
|
-
} else {
|
|
690
|
-
emit log_string("Case 2: dust collateral has non-zero borrowable amount (interesting edge case)");
|
|
691
|
-
}
|
|
692
|
-
} else {
|
|
693
|
-
emit log_string("Case 2: 1 wei payment yields 0 tokens (too small for issuance)");
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// ── Case 3: Huge surplus with 1 token -- should succeed ──
|
|
697
|
-
// Deploy a fresh revnet and pay a large amount to create huge surplus.
|
|
698
|
-
// Use a unique description salt to avoid CREATE2 collision with other revnets in this test contract.
|
|
699
|
-
uint256 hugeRevnetId = _deployRevnetWithSalt(5000, bytes32("HUGE_SALT"));
|
|
700
|
-
_setupPool(hugeRevnetId, 10_000 ether);
|
|
701
|
-
|
|
702
|
-
// Create big surplus from another payer.
|
|
703
|
-
address bigPayer = makeAddr("bigPayer");
|
|
704
|
-
vm.deal(bigPayer, 50 ether);
|
|
705
|
-
_payRevnet(hugeRevnetId, bigPayer, 50 ether);
|
|
706
|
-
|
|
707
|
-
// Small payer gets some tokens.
|
|
708
|
-
address smallPayer = makeAddr("smallPayer");
|
|
709
|
-
vm.deal(smallPayer, 1 ether);
|
|
710
|
-
uint256 smallTokens = _payRevnet(hugeRevnetId, smallPayer, 0.001 ether);
|
|
711
|
-
emit log_named_uint("Small payer tokens", smallTokens);
|
|
712
|
-
|
|
713
|
-
if (smallTokens > 0) {
|
|
714
|
-
uint256 smallBorrowable = LOANS_CONTRACT.borrowableAmountFrom(
|
|
715
|
-
hugeRevnetId, smallTokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
716
|
-
);
|
|
717
|
-
emit log_named_uint("Small payer borrowable (huge surplus)", smallBorrowable);
|
|
718
|
-
|
|
719
|
-
if (smallBorrowable > 0) {
|
|
720
|
-
// This should succeed -- huge surplus means even small collateral can borrow something.
|
|
721
|
-
_grantBurnPermission(smallPayer, hugeRevnetId);
|
|
722
|
-
|
|
723
|
-
vm.prank(smallPayer);
|
|
724
|
-
(uint256 smallLoanId, REVLoan memory smallLoan) = LOANS_CONTRACT.borrowFrom({
|
|
725
|
-
revnetId: hugeRevnetId,
|
|
726
|
-
source: source,
|
|
727
|
-
minBorrowAmount: 0,
|
|
728
|
-
collateralCount: smallTokens,
|
|
729
|
-
beneficiary: payable(smallPayer),
|
|
730
|
-
prepaidFeePercent: minFeePercent,
|
|
731
|
-
holder: smallPayer
|
|
732
|
-
});
|
|
733
|
-
assertGt(smallLoanId, 0, "small collateral loan should succeed with huge surplus");
|
|
734
|
-
assertGt(smallLoan.amount, 0, "small loan should have non-zero amount");
|
|
735
|
-
emit log_named_uint("Small loan amount", smallLoan.amount);
|
|
736
|
-
emit log_string("Case 3 passed: small collateral with huge surplus successfully borrows");
|
|
737
|
-
} else {
|
|
738
|
-
emit log_string("Case 3: even with huge surplus, small collateral yields zero borrowable");
|
|
739
|
-
}
|
|
740
|
-
} else {
|
|
741
|
-
emit log_string("Case 3: 0.001 ETH yields 0 tokens");
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
}
|