@rev-net/core-v6 0.0.14 → 0.0.16
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 +5 -1
- package/ARCHITECTURE.md +69 -11
- package/AUDIT_INSTRUCTIONS.md +90 -7
- package/CHANGE_LOG.md +16 -3
- package/README.md +32 -7
- package/RISKS.md +26 -14
- package/SKILLS.md +168 -46
- package/STYLE_GUIDE.md +1 -1
- package/USER_JOURNEYS.md +20 -6
- package/foundry.toml +7 -0
- package/package.json +9 -10
- package/script/Deploy.s.sol +80 -16
- package/script/helpers/RevnetCoreDeploymentLib.sol +1 -1
- package/src/REVDeployer.sol +73 -21
- package/src/REVLoans.sol +27 -6
- package/test/REV.integrations.t.sol +1 -1
- package/test/REVAutoIssuanceFuzz.t.sol +1 -1
- package/test/REVDeployerRegressions.t.sol +7 -4
- package/test/REVInvincibility.t.sol +7 -19
- package/test/REVInvincibilityHandler.sol +1 -1
- package/test/REVLifecycle.t.sol +1 -1
- package/test/REVLoans.invariants.t.sol +1 -1
- package/test/REVLoansAttacks.t.sol +20 -12
- package/test/REVLoansFeeRecovery.t.sol +20 -12
- package/test/REVLoansFindings.t.sol +20 -12
- package/test/REVLoansRegressions.t.sol +20 -12
- package/test/REVLoansSourceFeeRecovery.t.sol +1 -1
- package/test/REVLoansSourced.t.sol +1 -9
- package/test/REVLoansUnSourced.t.sol +1 -1
- package/test/TestBurnHeldTokens.t.sol +1 -1
- package/test/TestCEIPattern.t.sol +1 -1
- package/test/TestCashOutCallerValidation.t.sol +75 -1
- package/test/TestConversionDocumentation.t.sol +1 -1
- package/test/TestCrossCurrencyReclaim.t.sol +1 -1
- package/test/TestCrossSourceReallocation.t.sol +1 -1
- package/test/TestERC2771MetaTx.t.sol +1 -1
- package/test/TestEmptyBuybackSpecs.t.sol +1 -1
- package/test/TestFlashLoanSurplus.t.sol +1 -1
- package/test/TestHookArrayOOB.t.sol +1 -1
- package/test/TestLiquidationBehavior.t.sol +1 -1
- package/test/TestLoanSourceRotation.t.sol +1 -1
- package/test/TestLongTailEconomics.t.sol +1 -1
- package/test/TestLowFindings.t.sol +4 -2
- package/test/TestMixedFixes.t.sol +7 -5
- package/test/TestPermit2Signatures.t.sol +1 -1
- package/test/TestReallocationSandwich.t.sol +1 -1
- package/test/TestRevnetRegressions.t.sol +1 -1
- package/test/TestSplitWeightAdjustment.t.sol +11 -6
- package/test/TestSplitWeightE2E.t.sol +1 -1
- package/test/TestSplitWeightFork.t.sol +9 -10
- package/test/TestStageTransitionBorrowable.t.sol +1 -1
- package/test/TestSwapTerminalPermission.t.sol +1 -1
- package/test/TestUint112Overflow.t.sol +1 -1
- package/test/TestZeroRepayment.t.sol +1 -1
- package/test/audit/LoanIdOverflowGuard.t.sol +497 -0
- package/test/fork/ForkTestBase.sol +8 -11
- package/test/fork/TestAutoIssuanceFork.t.sol +148 -0
- package/test/fork/TestCashOutFork.t.sol +23 -22
- package/test/fork/TestIssuanceDecayFork.t.sol +158 -0
- package/test/fork/TestLoanBorrowFork.t.sol +1 -1
- package/test/fork/TestLoanCrossRulesetFork.t.sol +1 -1
- package/test/fork/TestLoanERC20Fork.t.sol +463 -0
- package/test/fork/TestLoanLiquidationFork.t.sol +1 -1
- package/test/fork/TestLoanReallocateFork.t.sol +1 -1
- package/test/fork/TestLoanRepayFork.t.sol +3 -3
- package/test/fork/TestLoanTransferFork.t.sol +1 -1
- package/test/fork/TestPermit2PaymentFork.t.sol +299 -0
- package/test/fork/TestSplitWeightFork.t.sol +1 -1
- package/test/helpers/MaliciousContracts.sol +37 -23
- package/test/mock/MockBuybackCashOutRecorder.sol +82 -0
- package/test/mock/MockBuybackDataHook.sol +51 -7
- package/test/mock/MockBuybackDataHookMintPath.sol +1 -1
- package/test/regression/TestBurnPermissionRequired.t.sol +1 -1
- package/test/regression/TestCashOutBuybackFeeLeak.t.sol +205 -0
- package/test/regression/TestCrossRevnetLiquidation.t.sol +1 -1
- package/test/regression/TestCumulativeLoanCounter.t.sol +1 -1
- package/test/regression/TestLiquidateGapHandling.t.sol +1 -1
- package/test/regression/TestZeroPriceFeed.t.sol +1 -1
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.26;
|
|
3
|
+
|
|
4
|
+
// forge-lint: disable-next-line(unaliased-plain-import)
|
|
5
|
+
import "./ForkTestBase.sol";
|
|
6
|
+
import {JBFees} from "@bananapus/core-v6/src/libraries/JBFees.sol";
|
|
7
|
+
import {MockPriceFeed} from "@bananapus/core-v6/test/mock/MockPriceFeed.sol";
|
|
8
|
+
import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";
|
|
9
|
+
|
|
10
|
+
/// @notice Fork tests for REVLoans with an ERC-20 (USDC) loan source on a mainnet fork.
|
|
11
|
+
///
|
|
12
|
+
/// Covers: borrow in USDC (6 decimals), fee distribution in 6-decimal amounts, repay in USDC with collateral return,
|
|
13
|
+
/// and dust/rounding checks for 6-decimal token math.
|
|
14
|
+
///
|
|
15
|
+
/// Run with: FOUNDRY_PROFILE=fork forge test --match-contract TestLoanERC20Fork -vvv
|
|
16
|
+
contract TestLoanERC20Fork is ForkTestBase {
|
|
17
|
+
// Mainnet USDC.
|
|
18
|
+
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
|
|
19
|
+
uint8 constant USDC_DECIMALS = 6;
|
|
20
|
+
|
|
21
|
+
uint256 revnetId;
|
|
22
|
+
|
|
23
|
+
// ───────────────────────── Setup
|
|
24
|
+
// ─────────────────────────
|
|
25
|
+
|
|
26
|
+
function setUp() public override {
|
|
27
|
+
super.setUp();
|
|
28
|
+
|
|
29
|
+
// Set up a price feed: 1 USDC = 0.0005 ETH (i.e. 1 ETH = 2000 USDC).
|
|
30
|
+
// The feed returns the price of 1 unit of pricingCurrency in terms of unitCurrency.
|
|
31
|
+
// pricingCurrency = USDC, unitCurrency = NATIVE_TOKEN -> price per USDC unit in ETH = 0.0005e18 = 5e14.
|
|
32
|
+
MockPriceFeed priceFeed = new MockPriceFeed(5e14, 18);
|
|
33
|
+
vm.prank(multisig());
|
|
34
|
+
jbPrices().addPriceFeedFor(0, uint32(uint160(USDC)), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed);
|
|
35
|
+
|
|
36
|
+
// Deploy fee project with both native and USDC terminals.
|
|
37
|
+
_deployFeeProjectWithUsdc(5000);
|
|
38
|
+
|
|
39
|
+
// Deploy the revnet with both native and USDC accounting contexts.
|
|
40
|
+
revnetId = _deployRevnetWithUsdc(5000);
|
|
41
|
+
|
|
42
|
+
// Set up pool at 1:1 (mint path wins for native payments).
|
|
43
|
+
_setupPool(revnetId, 10_000 ether);
|
|
44
|
+
|
|
45
|
+
// Pay with native token so there is general surplus for price feed conversion.
|
|
46
|
+
_payRevnet(revnetId, PAYER, 10 ether);
|
|
47
|
+
|
|
48
|
+
// Pay with USDC to build USDC-denominated surplus.
|
|
49
|
+
_payRevnetUsdc(revnetId, PAYER, 10_000e6);
|
|
50
|
+
_payRevnetUsdc(revnetId, BORROWER, 5000e6);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ───────────────────────── USDC Config Helpers
|
|
54
|
+
// ─────────────────────────
|
|
55
|
+
|
|
56
|
+
/// @notice Build a config with both native and USDC accounting contexts.
|
|
57
|
+
function _buildUsdcConfig(uint16 cashOutTaxRate)
|
|
58
|
+
internal
|
|
59
|
+
view
|
|
60
|
+
returns (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc)
|
|
61
|
+
{
|
|
62
|
+
JBAccountingContext[] memory acc = new JBAccountingContext[](2);
|
|
63
|
+
acc[0] = JBAccountingContext({
|
|
64
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
65
|
+
});
|
|
66
|
+
acc[1] = JBAccountingContext({token: USDC, decimals: USDC_DECIMALS, currency: uint32(uint160(USDC))});
|
|
67
|
+
|
|
68
|
+
tc = new JBTerminalConfig[](1);
|
|
69
|
+
tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
|
|
70
|
+
|
|
71
|
+
REVStageConfig[] memory stages = new REVStageConfig[](1);
|
|
72
|
+
JBSplit[] memory splits = new JBSplit[](1);
|
|
73
|
+
splits[0].beneficiary = payable(multisig());
|
|
74
|
+
splits[0].percent = 10_000;
|
|
75
|
+
stages[0] = REVStageConfig({
|
|
76
|
+
startsAtOrAfter: uint40(block.timestamp),
|
|
77
|
+
autoIssuances: new REVAutoIssuance[](0),
|
|
78
|
+
splitPercent: 0,
|
|
79
|
+
splits: splits,
|
|
80
|
+
initialIssuance: INITIAL_ISSUANCE,
|
|
81
|
+
issuanceCutFrequency: 0,
|
|
82
|
+
issuanceCutPercent: 0,
|
|
83
|
+
cashOutTaxRate: cashOutTaxRate,
|
|
84
|
+
extraMetadata: 0
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
cfg = REVConfig({
|
|
88
|
+
// forge-lint: disable-next-line(named-struct-fields)
|
|
89
|
+
description: REVDescription("ERC20 Fork Test", "ERC20F", "ipfs://erc20fork", "ERC20F_SALT"),
|
|
90
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
91
|
+
splitOperator: multisig(),
|
|
92
|
+
stageConfigurations: stages
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
sdc = REVSuckerDeploymentConfig({
|
|
96
|
+
deployerConfigurations: new JBSuckerDeployerConfig[](0),
|
|
97
|
+
salt: keccak256(abi.encodePacked("ERC20_FORK_TEST"))
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// @notice Deploy the fee project with both native and USDC terminals.
|
|
102
|
+
function _deployFeeProjectWithUsdc(uint16 cashOutTaxRate) internal {
|
|
103
|
+
(REVConfig memory feeCfg, JBTerminalConfig[] memory feeTc, REVSuckerDeploymentConfig memory feeSdc) =
|
|
104
|
+
_buildUsdcConfig(cashOutTaxRate);
|
|
105
|
+
// forge-lint: disable-next-line(named-struct-fields)
|
|
106
|
+
feeCfg.description = REVDescription("Fee", "FEE", "ipfs://fee", "FEE_USDC_SALT");
|
|
107
|
+
|
|
108
|
+
vm.prank(multisig());
|
|
109
|
+
REV_DEPLOYER.deployFor({
|
|
110
|
+
revnetId: FEE_PROJECT_ID,
|
|
111
|
+
configuration: feeCfg,
|
|
112
|
+
terminalConfigurations: feeTc,
|
|
113
|
+
suckerDeploymentConfiguration: feeSdc,
|
|
114
|
+
tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
|
|
115
|
+
allowedPosts: REVEmpty721Config.emptyAllowedPosts()
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// @notice Deploy a revnet with both native and USDC terminals.
|
|
120
|
+
function _deployRevnetWithUsdc(uint16 cashOutTaxRate) internal returns (uint256 id) {
|
|
121
|
+
(REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) =
|
|
122
|
+
_buildUsdcConfig(cashOutTaxRate);
|
|
123
|
+
|
|
124
|
+
(id,) = REV_DEPLOYER.deployFor({
|
|
125
|
+
revnetId: 0,
|
|
126
|
+
configuration: cfg,
|
|
127
|
+
terminalConfigurations: tc,
|
|
128
|
+
suckerDeploymentConfiguration: sdc,
|
|
129
|
+
tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
|
|
130
|
+
allowedPosts: REVEmpty721Config.emptyAllowedPosts()
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ───────────────────────── USDC Payment Helper
|
|
135
|
+
// ─────────────────────────
|
|
136
|
+
|
|
137
|
+
/// @notice Pay the revnet with USDC.
|
|
138
|
+
function _payRevnetUsdc(uint256 id, address payer, uint256 amount) internal returns (uint256 tokensReceived) {
|
|
139
|
+
deal(USDC, payer, amount);
|
|
140
|
+
vm.prank(payer);
|
|
141
|
+
IERC20(USDC).approve(address(jbMultiTerminal()), amount);
|
|
142
|
+
|
|
143
|
+
vm.prank(payer);
|
|
144
|
+
tokensReceived = jbMultiTerminal()
|
|
145
|
+
.pay({
|
|
146
|
+
projectId: id,
|
|
147
|
+
token: USDC,
|
|
148
|
+
amount: amount,
|
|
149
|
+
beneficiary: payer,
|
|
150
|
+
minReturnedTokens: 0,
|
|
151
|
+
memo: "",
|
|
152
|
+
metadata: ""
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ───────────────────────── USDC Loan Helpers
|
|
157
|
+
// ─────────────────────────
|
|
158
|
+
|
|
159
|
+
/// @notice Build a USDC loan source.
|
|
160
|
+
function _usdcLoanSource() internal view returns (REVLoanSource memory) {
|
|
161
|
+
return REVLoanSource({token: USDC, terminal: jbMultiTerminal()});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// @notice Create a loan using USDC as the source token.
|
|
165
|
+
function _createUsdcLoan(
|
|
166
|
+
uint256 id,
|
|
167
|
+
address borrower,
|
|
168
|
+
uint256 collateral,
|
|
169
|
+
uint256 prepaidFeePercent
|
|
170
|
+
)
|
|
171
|
+
internal
|
|
172
|
+
returns (uint256 loanId, REVLoan memory loan)
|
|
173
|
+
{
|
|
174
|
+
REVLoanSource memory source = _usdcLoanSource();
|
|
175
|
+
uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(id, collateral, USDC_DECIMALS, uint32(uint160(USDC)));
|
|
176
|
+
require(borrowable > 0, "no borrowable amount in USDC");
|
|
177
|
+
|
|
178
|
+
_grantBurnPermission(borrower, id);
|
|
179
|
+
|
|
180
|
+
vm.prank(borrower);
|
|
181
|
+
(loanId, loan) = LOANS_CONTRACT.borrowFrom({
|
|
182
|
+
revnetId: id,
|
|
183
|
+
source: source,
|
|
184
|
+
minBorrowAmount: 0,
|
|
185
|
+
collateralCount: collateral,
|
|
186
|
+
beneficiary: payable(borrower),
|
|
187
|
+
prepaidFeePercent: prepaidFeePercent
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ───────────────────────── Tests
|
|
192
|
+
// ─────────────────────────
|
|
193
|
+
|
|
194
|
+
/// @notice Basic borrow from USDC source: verify USDC disbursed in 6 decimals, loan state correct.
|
|
195
|
+
function test_fork_borrow_usdc_basic() public {
|
|
196
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
197
|
+
|
|
198
|
+
uint256 borrowable =
|
|
199
|
+
LOANS_CONTRACT.borrowableAmountFrom(revnetId, borrowerTokens, USDC_DECIMALS, uint32(uint160(USDC)));
|
|
200
|
+
assertGt(borrowable, 0, "should have borrowable amount in USDC");
|
|
201
|
+
|
|
202
|
+
uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(revnetId);
|
|
203
|
+
uint256 totalBorrowedBefore = LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), USDC);
|
|
204
|
+
|
|
205
|
+
uint256 borrowerUsdcBefore = IERC20(USDC).balanceOf(BORROWER);
|
|
206
|
+
|
|
207
|
+
// Create the loan.
|
|
208
|
+
(uint256 loanId, REVLoan memory loan) =
|
|
209
|
+
_createUsdcLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
210
|
+
|
|
211
|
+
// Verify loan state.
|
|
212
|
+
assertEq(loan.collateral, borrowerTokens, "loan collateral should match");
|
|
213
|
+
assertEq(loan.createdAt, block.timestamp, "loan createdAt should be now");
|
|
214
|
+
|
|
215
|
+
// Loan amount should be in 6-decimal USDC units.
|
|
216
|
+
assertGt(loan.amount, 0, "loan amount should be non-zero");
|
|
217
|
+
// Sanity: a 5000 USDC payment should yield a borrowable amount in the hundreds/thousands USDC range.
|
|
218
|
+
assertLt(loan.amount, 20_000e6, "loan amount should be reasonable for USDC");
|
|
219
|
+
|
|
220
|
+
// Borrower should have received USDC (net of fees).
|
|
221
|
+
uint256 borrowerUsdcReceived = IERC20(USDC).balanceOf(BORROWER) - borrowerUsdcBefore;
|
|
222
|
+
assertGt(borrowerUsdcReceived, 0, "borrower should receive USDC");
|
|
223
|
+
|
|
224
|
+
// Borrower's original tokens are burned as collateral, but the source fee payment back to the revnet mints
|
|
225
|
+
// some tokens to the borrower.
|
|
226
|
+
uint256 feeTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
227
|
+
assertGt(feeTokens, 0, "borrower should have tokens from source fee payment");
|
|
228
|
+
assertLt(feeTokens, borrowerTokens, "fee tokens should be less than original collateral");
|
|
229
|
+
|
|
230
|
+
// Tracking updated.
|
|
231
|
+
assertEq(
|
|
232
|
+
LOANS_CONTRACT.totalCollateralOf(revnetId),
|
|
233
|
+
totalCollateralBefore + borrowerTokens,
|
|
234
|
+
"totalCollateralOf should increase"
|
|
235
|
+
);
|
|
236
|
+
assertGt(
|
|
237
|
+
LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), USDC),
|
|
238
|
+
totalBorrowedBefore,
|
|
239
|
+
"totalBorrowedFrom should increase"
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Loan NFT owned by borrower.
|
|
243
|
+
assertEq(_loanOwnerOf(loanId), BORROWER, "loan NFT should be owned by borrower");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/// @notice Verify fee distribution in 6-decimal USDC: source fee (2.5%), REV fee (1%), allowance fee (2.5%).
|
|
247
|
+
function test_fork_borrow_usdc_feeDistribution() public {
|
|
248
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
249
|
+
uint256 prepaidFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT(); // 25 = 2.5%
|
|
250
|
+
|
|
251
|
+
uint256 borrowable =
|
|
252
|
+
LOANS_CONTRACT.borrowableAmountFrom(revnetId, borrowerTokens, USDC_DECIMALS, uint32(uint160(USDC)));
|
|
253
|
+
|
|
254
|
+
// Record balances before.
|
|
255
|
+
uint256 borrowerUsdcBefore = IERC20(USDC).balanceOf(BORROWER);
|
|
256
|
+
_grantBurnPermission(BORROWER, revnetId);
|
|
257
|
+
|
|
258
|
+
REVLoanSource memory source = _usdcLoanSource();
|
|
259
|
+
vm.prank(BORROWER);
|
|
260
|
+
LOANS_CONTRACT.borrowFrom({
|
|
261
|
+
revnetId: revnetId,
|
|
262
|
+
source: source,
|
|
263
|
+
minBorrowAmount: 0,
|
|
264
|
+
collateralCount: borrowerTokens,
|
|
265
|
+
beneficiary: payable(BORROWER),
|
|
266
|
+
prepaidFeePercent: prepaidFeePercent
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
uint256 borrowerUsdcReceived = IERC20(USDC).balanceOf(BORROWER) - borrowerUsdcBefore;
|
|
270
|
+
|
|
271
|
+
// Calculate expected fees (all in 6-decimal USDC).
|
|
272
|
+
// The allowance fee is taken by the terminal's useAllowanceOf (2.5% JB protocol fee).
|
|
273
|
+
uint256 allowanceFee = JBFees.feeAmountFrom({amountBeforeFee: borrowable, feePercent: jbMultiTerminal().FEE()});
|
|
274
|
+
// REV fee (1%).
|
|
275
|
+
uint256 revFee =
|
|
276
|
+
JBFees.feeAmountFrom({amountBeforeFee: borrowable, feePercent: LOANS_CONTRACT.REV_PREPAID_FEE_PERCENT()});
|
|
277
|
+
// Source fee (prepaid).
|
|
278
|
+
uint256 sourceFee = JBFees.feeAmountFrom({amountBeforeFee: borrowable, feePercent: prepaidFeePercent});
|
|
279
|
+
|
|
280
|
+
uint256 totalFees = allowanceFee + revFee + sourceFee;
|
|
281
|
+
|
|
282
|
+
// Borrower should receive borrowable - totalFees (allow small rounding tolerance for 6-decimal math).
|
|
283
|
+
assertApproxEqAbs(
|
|
284
|
+
borrowerUsdcReceived, borrowable - totalFees, 10, "borrower USDC net should match expected (6 dec)"
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// Loans contract should not hold any USDC.
|
|
288
|
+
assertEq(IERC20(USDC).balanceOf(address(LOANS_CONTRACT)), 0, "loans contract should not hold USDC");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// @notice Verify no dust remains from 6-decimal rounding: total inflows >= total outflows.
|
|
292
|
+
function test_fork_borrow_usdc_noDust() public {
|
|
293
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
294
|
+
|
|
295
|
+
// Record USDC balance of the terminal before the loan.
|
|
296
|
+
uint256 terminalUsdcBefore = IERC20(USDC).balanceOf(address(jbMultiTerminal()));
|
|
297
|
+
|
|
298
|
+
// Create the loan.
|
|
299
|
+
_createUsdcLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
300
|
+
|
|
301
|
+
// The loans contract should hold zero USDC (no dust).
|
|
302
|
+
assertEq(IERC20(USDC).balanceOf(address(LOANS_CONTRACT)), 0, "loans contract should hold zero USDC dust");
|
|
303
|
+
|
|
304
|
+
// Terminal balance should have decreased (funds loaned out), but not be negative.
|
|
305
|
+
uint256 terminalUsdcAfter = IERC20(USDC).balanceOf(address(jbMultiTerminal()));
|
|
306
|
+
assertLt(terminalUsdcAfter, terminalUsdcBefore, "terminal USDC should decrease after loan");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/// @notice Full repay in USDC: return all collateral, burn loan NFT.
|
|
310
|
+
function test_fork_repay_usdc_full() public {
|
|
311
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
312
|
+
|
|
313
|
+
// Create a loan.
|
|
314
|
+
(uint256 loanId, REVLoan memory loan) =
|
|
315
|
+
_createUsdcLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
316
|
+
|
|
317
|
+
// Record fee tokens minted to borrower from source fee payment back to revnet.
|
|
318
|
+
uint256 feeTokensFromLoan = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
319
|
+
|
|
320
|
+
uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(revnetId);
|
|
321
|
+
uint256 totalBorrowedBefore = LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), USDC);
|
|
322
|
+
|
|
323
|
+
// Fund borrower with enough USDC to repay (they need more than the loan amount due to potential fees).
|
|
324
|
+
uint256 repayFunding = loan.amount * 2;
|
|
325
|
+
deal(USDC, BORROWER, repayFunding);
|
|
326
|
+
vm.prank(BORROWER);
|
|
327
|
+
IERC20(USDC).approve(address(LOANS_CONTRACT), repayFunding);
|
|
328
|
+
|
|
329
|
+
JBSingleAllowance memory allowance;
|
|
330
|
+
|
|
331
|
+
vm.prank(BORROWER);
|
|
332
|
+
LOANS_CONTRACT.repayLoan({
|
|
333
|
+
loanId: loanId,
|
|
334
|
+
maxRepayBorrowAmount: repayFunding,
|
|
335
|
+
collateralCountToReturn: loan.collateral,
|
|
336
|
+
beneficiary: payable(BORROWER),
|
|
337
|
+
allowance: allowance
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Collateral re-minted to borrower (plus fee tokens from loan creation).
|
|
341
|
+
assertEq(
|
|
342
|
+
jbTokens().totalBalanceOf(BORROWER, revnetId),
|
|
343
|
+
borrowerTokens + feeTokensFromLoan,
|
|
344
|
+
"collateral should be returned to borrower"
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// Loan NFT burned.
|
|
348
|
+
vm.expectRevert();
|
|
349
|
+
_loanOwnerOf(loanId);
|
|
350
|
+
|
|
351
|
+
// Tracking decreased.
|
|
352
|
+
assertEq(
|
|
353
|
+
LOANS_CONTRACT.totalCollateralOf(revnetId),
|
|
354
|
+
totalCollateralBefore - loan.collateral,
|
|
355
|
+
"totalCollateralOf should decrease"
|
|
356
|
+
);
|
|
357
|
+
assertLt(
|
|
358
|
+
LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), USDC),
|
|
359
|
+
totalBorrowedBefore,
|
|
360
|
+
"totalBorrowedFrom should decrease"
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/// @notice Repay within prepaid duration -> no additional source fee. Verify exact USDC cost.
|
|
365
|
+
function test_fork_repay_usdc_withinPrepaidNoFee() public {
|
|
366
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
367
|
+
|
|
368
|
+
// Create a loan.
|
|
369
|
+
(uint256 loanId, REVLoan memory loan) =
|
|
370
|
+
_createUsdcLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
371
|
+
|
|
372
|
+
// Don't warp -- we're within prepaid duration.
|
|
373
|
+
|
|
374
|
+
// Fund borrower with USDC to repay.
|
|
375
|
+
uint256 repayFunding = loan.amount * 2;
|
|
376
|
+
deal(USDC, BORROWER, repayFunding);
|
|
377
|
+
vm.prank(BORROWER);
|
|
378
|
+
IERC20(USDC).approve(address(LOANS_CONTRACT), repayFunding);
|
|
379
|
+
|
|
380
|
+
uint256 borrowerUsdcBefore = IERC20(USDC).balanceOf(BORROWER);
|
|
381
|
+
|
|
382
|
+
JBSingleAllowance memory allowance;
|
|
383
|
+
|
|
384
|
+
vm.prank(BORROWER);
|
|
385
|
+
LOANS_CONTRACT.repayLoan({
|
|
386
|
+
loanId: loanId,
|
|
387
|
+
maxRepayBorrowAmount: repayFunding,
|
|
388
|
+
collateralCountToReturn: loan.collateral,
|
|
389
|
+
beneficiary: payable(BORROWER),
|
|
390
|
+
allowance: allowance
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
uint256 usdcSpent = borrowerUsdcBefore - IERC20(USDC).balanceOf(BORROWER);
|
|
394
|
+
|
|
395
|
+
// Within prepaid period, cost should be exactly the loan amount (no additional source fee).
|
|
396
|
+
assertEq(usdcSpent, loan.amount, "repay within prepaid should cost exactly loan amount in USDC");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/// @notice After prepaid duration, source fee is charged on USDC repayment.
|
|
400
|
+
function test_fork_repay_usdc_withSourceFee() public {
|
|
401
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
402
|
+
|
|
403
|
+
// Create a loan.
|
|
404
|
+
(uint256 loanId, REVLoan memory loan) =
|
|
405
|
+
_createUsdcLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
|
|
406
|
+
|
|
407
|
+
// Warp well past the prepaid duration to accrue a meaningful source fee.
|
|
408
|
+
vm.warp(block.timestamp + loan.prepaidDuration + 365 days);
|
|
409
|
+
|
|
410
|
+
// Fund borrower with USDC to repay.
|
|
411
|
+
uint256 repayFunding = loan.amount * 3;
|
|
412
|
+
deal(USDC, BORROWER, repayFunding);
|
|
413
|
+
vm.prank(BORROWER);
|
|
414
|
+
IERC20(USDC).approve(address(LOANS_CONTRACT), repayFunding);
|
|
415
|
+
|
|
416
|
+
uint256 borrowerUsdcBefore = IERC20(USDC).balanceOf(BORROWER);
|
|
417
|
+
|
|
418
|
+
JBSingleAllowance memory allowance;
|
|
419
|
+
|
|
420
|
+
vm.prank(BORROWER);
|
|
421
|
+
LOANS_CONTRACT.repayLoan({
|
|
422
|
+
loanId: loanId,
|
|
423
|
+
maxRepayBorrowAmount: repayFunding,
|
|
424
|
+
collateralCountToReturn: loan.collateral,
|
|
425
|
+
beneficiary: payable(BORROWER),
|
|
426
|
+
allowance: allowance
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
uint256 usdcSpent = borrowerUsdcBefore - IERC20(USDC).balanceOf(BORROWER);
|
|
430
|
+
|
|
431
|
+
// Total cost should be more than the loan principal (due to source fee).
|
|
432
|
+
assertGt(usdcSpent, loan.amount, "repay cost should exceed loan amount due to source fee");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/// @notice Fee amounts should match expected values calculated from JBFees in 6-decimal precision.
|
|
436
|
+
function test_fork_borrow_usdc_feeAmountsMatch() public view {
|
|
437
|
+
uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
|
|
438
|
+
uint256 prepaidFeePercent = LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT();
|
|
439
|
+
|
|
440
|
+
uint256 borrowable =
|
|
441
|
+
LOANS_CONTRACT.borrowableAmountFrom(revnetId, borrowerTokens, USDC_DECIMALS, uint32(uint160(USDC)));
|
|
442
|
+
|
|
443
|
+
// Calculate each fee component using JBFees (same as the contract does internally).
|
|
444
|
+
uint256 expectedAllowanceFee =
|
|
445
|
+
JBFees.feeAmountFrom({amountBeforeFee: borrowable, feePercent: jbMultiTerminal().FEE()});
|
|
446
|
+
uint256 expectedRevFee =
|
|
447
|
+
JBFees.feeAmountFrom({amountBeforeFee: borrowable, feePercent: LOANS_CONTRACT.REV_PREPAID_FEE_PERCENT()});
|
|
448
|
+
uint256 expectedSourceFee = JBFees.feeAmountFrom({amountBeforeFee: borrowable, feePercent: prepaidFeePercent});
|
|
449
|
+
|
|
450
|
+
// Each fee should be non-zero for a meaningful USDC borrow amount.
|
|
451
|
+
assertGt(expectedAllowanceFee, 0, "allowance fee should be non-zero");
|
|
452
|
+
assertGt(expectedRevFee, 0, "REV fee should be non-zero");
|
|
453
|
+
assertGt(expectedSourceFee, 0, "source fee should be non-zero");
|
|
454
|
+
|
|
455
|
+
// Fees should be proportional: allowance fee == source fee (both at 2.5%), REV fee < both (at 1%).
|
|
456
|
+
assertEq(expectedAllowanceFee, expectedSourceFee, "allowance and source fees should match (both 2.5%)");
|
|
457
|
+
assertLt(expectedRevFee, expectedAllowanceFee, "REV fee (1%) should be less than allowance fee (2.5%)");
|
|
458
|
+
|
|
459
|
+
// Total fees should be less than the borrowable amount.
|
|
460
|
+
uint256 totalFees = expectedAllowanceFee + expectedRevFee + expectedSourceFee;
|
|
461
|
+
assertLt(totalFees, borrowable, "total fees should be less than borrowable amount");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
pragma solidity 0.8.26;
|
|
2
|
+
pragma solidity ^0.8.26;
|
|
3
3
|
|
|
4
4
|
// forge-lint: disable-next-line(unaliased-plain-import)
|
|
5
5
|
import "./ForkTestBase.sol";
|
|
@@ -168,8 +168,8 @@ contract TestLoanRepayFork is ForkTestBase {
|
|
|
168
168
|
|
|
169
169
|
/// @notice Repay after 10 years should revert (loan expired).
|
|
170
170
|
function test_fork_repay_expiredReverts() public {
|
|
171
|
-
// Warp past the 10-year liquidation duration.
|
|
172
|
-
vm.warp(block.timestamp + LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION());
|
|
171
|
+
// Warp past the 10-year liquidation duration (strict > check, so need +1).
|
|
172
|
+
vm.warp(block.timestamp + LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION() + 1);
|
|
173
173
|
|
|
174
174
|
vm.deal(BORROWER, 100 ether);
|
|
175
175
|
|