@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.
Files changed (59) hide show
  1. package/ADMINISTRATION.md +186 -0
  2. package/ARCHITECTURE.md +87 -0
  3. package/README.md +4 -2
  4. package/RISKS.md +49 -0
  5. package/SKILLS.md +22 -2
  6. package/STYLE_GUIDE.md +482 -0
  7. package/foundry.toml +6 -6
  8. package/package.json +13 -10
  9. package/script/Deploy.s.sol +3 -2
  10. package/src/REVDeployer.sol +129 -72
  11. package/src/REVLoans.sol +174 -165
  12. package/src/interfaces/IREVDeployer.sol +111 -72
  13. package/src/interfaces/IREVLoans.sol +116 -76
  14. package/src/structs/REV721TiersHookFlags.sol +14 -0
  15. package/src/structs/REVBaseline721HookConfig.sol +27 -0
  16. package/src/structs/REVDeploy721TiersHookConfig.sol +2 -2
  17. package/test/REV.integrations.t.sol +4 -3
  18. package/test/REVAutoIssuanceFuzz.t.sol +12 -8
  19. package/test/REVDeployerAuditRegressions.t.sol +4 -3
  20. package/test/REVInvincibility.t.sol +8 -6
  21. package/test/REVInvincibilityHandler.sol +1 -0
  22. package/test/REVLifecycle.t.sol +4 -3
  23. package/test/REVLoans.invariants.t.sol +5 -3
  24. package/test/REVLoansAttacks.t.sol +4 -3
  25. package/test/REVLoansAuditRegressions.t.sol +13 -24
  26. package/test/REVLoansFeeRecovery.t.sol +4 -3
  27. package/test/REVLoansSourced.t.sol +4 -3
  28. package/test/REVLoansUnSourced.t.sol +4 -3
  29. package/test/REVLoans_AuditFindings.t.sol +644 -0
  30. package/test/TestEmptyBuybackSpecs.t.sol +4 -3
  31. package/test/TestPR09_ConversionDocumentation.t.sol +4 -3
  32. package/test/TestPR10_LiquidationBehavior.t.sol +4 -3
  33. package/test/TestPR11_LowFindings.t.sol +4 -3
  34. package/test/TestPR12_FlashLoanSurplus.t.sol +4 -3
  35. package/test/TestPR13_CrossSourceReallocation.t.sol +4 -3
  36. package/test/TestPR15_CashOutCallerValidation.t.sol +4 -3
  37. package/test/TestPR16_ZeroRepayment.t.sol +4 -3
  38. package/test/TestPR21_Uint112Overflow.t.sol +4 -3
  39. package/test/TestPR22_HookArrayOOB.t.sol +4 -3
  40. package/test/TestPR26_BurnHeldTokens.t.sol +4 -3
  41. package/test/TestPR27_CEIPattern.t.sol +4 -3
  42. package/test/TestPR29_SwapTerminalPermission.t.sol +4 -3
  43. package/test/TestPR32_MixedFixes.t.sol +4 -3
  44. package/test/TestSplitWeightAdjustment.t.sol +445 -0
  45. package/test/TestSplitWeightE2E.t.sol +528 -0
  46. package/test/TestSplitWeightFork.t.sol +821 -0
  47. package/test/TestStageTransitionBorrowable.t.sol +4 -3
  48. package/test/fork/ForkTestBase.sol +617 -0
  49. package/test/fork/TestCashOutFork.t.sol +245 -0
  50. package/test/fork/TestLoanBorrowFork.t.sol +163 -0
  51. package/test/fork/TestLoanLiquidationFork.t.sol +129 -0
  52. package/test/fork/TestLoanReallocateFork.t.sol +103 -0
  53. package/test/fork/TestLoanRepayFork.t.sol +184 -0
  54. package/test/fork/TestSplitWeightFork.t.sol +186 -0
  55. package/test/mock/MockBuybackDataHook.sol +11 -4
  56. package/test/mock/MockBuybackDataHookMintPath.sol +11 -3
  57. package/test/regression/TestI20_CumulativeLoanCounter.t.sol +6 -5
  58. package/test/regression/TestL27_LiquidateGapHandling.t.sol +7 -6
  59. package/SECURITY.md +0 -68
@@ -0,0 +1,644 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import "forge-std/Test.sol";
5
+ import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
6
+ import /* {*} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
7
+ import /* {*} from */ "./../src/REVDeployer.sol";
8
+ import "@croptop/core-v6/src/CTPublisher.sol";
9
+ import {MockBuybackDataHook} from "./mock/MockBuybackDataHook.sol";
10
+
11
+ import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
12
+ import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
13
+ import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
14
+ import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
15
+ import "@bananapus/router-terminal-v6/script/helpers/RouterTerminalDeploymentLib.sol";
16
+
17
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
18
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
19
+ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
20
+ import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol";
21
+ import {JBSingleAllowance} from "@bananapus/core-v6/src/structs/JBSingleAllowance.sol";
22
+ import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
23
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
24
+ import {REVLoans} from "../src/REVLoans.sol";
25
+ import {REVLoan} from "../src/structs/REVLoan.sol";
26
+ import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
27
+ import {REVLoanSource} from "../src/structs/REVLoanSource.sol";
28
+ import {REVDescription} from "../src/structs/REVDescription.sol";
29
+ import {IREVLoans} from "./../src/interfaces/IREVLoans.sol";
30
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
31
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
32
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
33
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
34
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
35
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
36
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
37
+
38
+ /// @notice A fake terminal that returns garbage accounting contexts.
39
+ /// Used to test unvalidated loan source terminal rejection.
40
+ contract GarbageTerminal is ERC165, IJBPayoutTerminal {
41
+ function useAllowanceOf(
42
+ uint256,
43
+ address,
44
+ uint256,
45
+ uint256,
46
+ uint256,
47
+ address payable,
48
+ address payable,
49
+ string calldata
50
+ )
51
+ external
52
+ pure
53
+ override
54
+ returns (uint256)
55
+ {
56
+ return 0;
57
+ }
58
+
59
+ function accountingContextForTokenOf(uint256, address) external pure override returns (JBAccountingContext memory) {
60
+ // Return garbage values to demonstrate the danger.
61
+ return JBAccountingContext({token: address(0xdead), decimals: 42, currency: 999_999});
62
+ }
63
+
64
+ function accountingContextsOf(uint256) external pure override returns (JBAccountingContext[] memory) {
65
+ JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
66
+ contexts[0] = JBAccountingContext({token: address(0xdead), decimals: 42, currency: 999_999});
67
+ return contexts;
68
+ }
69
+
70
+ function addAccountingContextsFor(uint256, JBAccountingContext[] calldata) external override {}
71
+
72
+ function addToBalanceOf(
73
+ uint256,
74
+ address,
75
+ uint256,
76
+ bool,
77
+ string calldata,
78
+ bytes calldata
79
+ )
80
+ external
81
+ payable
82
+ override
83
+ {}
84
+
85
+ function currentSurplusOf(
86
+ uint256,
87
+ JBAccountingContext[] memory,
88
+ uint256,
89
+ uint256
90
+ )
91
+ external
92
+ pure
93
+ override
94
+ returns (uint256)
95
+ {
96
+ return 0;
97
+ }
98
+
99
+ function migrateBalanceOf(uint256, address, IJBTerminal) external pure override returns (uint256) {
100
+ return 0;
101
+ }
102
+
103
+ function pay(
104
+ uint256,
105
+ address,
106
+ uint256,
107
+ address,
108
+ uint256,
109
+ string calldata,
110
+ bytes calldata
111
+ )
112
+ external
113
+ payable
114
+ override
115
+ returns (uint256)
116
+ {
117
+ return 0;
118
+ }
119
+
120
+ function sendPayoutsOf(uint256, address, uint256, uint256, uint256) external pure override returns (uint256) {
121
+ return 0;
122
+ }
123
+
124
+ function supportsInterface(bytes4 interfaceId) public view override(ERC165, IERC165) returns (bool) {
125
+ return interfaceId == type(IJBTerminal).interfaceId || interfaceId == type(IJBPayoutTerminal).interfaceId
126
+ || super.supportsInterface(interfaceId);
127
+ }
128
+
129
+ receive() external payable {}
130
+ }
131
+
132
+ /// @notice Regression tests for nemesis audit findings.
133
+ /// Unvalidated loan source terminal
134
+ /// RepayLoan event emits zeroed values
135
+ /// Auto-issuance timing guard bypass (false positive)
136
+ /// repayLoan revert on excess collateral (false positive)
137
+ contract REVLoans_AuditFindings is TestBaseWorkflow {
138
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
139
+ bytes32 ERC20_SALT = "REV_TOKEN";
140
+
141
+ REVDeployer REV_DEPLOYER;
142
+ JB721TiersHook EXAMPLE_HOOK;
143
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
144
+ IJB721TiersHookStore HOOK_STORE;
145
+ IJBAddressRegistry ADDRESS_REGISTRY;
146
+ IREVLoans LOANS_CONTRACT;
147
+ IJBSuckerRegistry SUCKER_REGISTRY;
148
+ CTPublisher PUBLISHER;
149
+ MockBuybackDataHook MOCK_BUYBACK;
150
+
151
+ uint256 FEE_PROJECT_ID;
152
+ uint256 REVNET_ID;
153
+
154
+ address USER = makeAddr("user");
155
+
156
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
157
+
158
+ function setUp() public override {
159
+ super.setUp();
160
+
161
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
162
+
163
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
164
+ HOOK_STORE = new JB721TiersHookStore();
165
+ EXAMPLE_HOOK =
166
+ new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, jbSplits(), multisig());
167
+ ADDRESS_REGISTRY = new JBAddressRegistry();
168
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
169
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
170
+ MOCK_BUYBACK = new MockBuybackDataHook();
171
+
172
+ LOANS_CONTRACT = new REVLoans({
173
+ controller: jbController(),
174
+ projects: jbProjects(),
175
+ revId: FEE_PROJECT_ID,
176
+ owner: address(this),
177
+ permit2: permit2(),
178
+ trustedForwarder: TRUSTED_FORWARDER
179
+ });
180
+
181
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
182
+ jbController(),
183
+ SUCKER_REGISTRY,
184
+ FEE_PROJECT_ID,
185
+ HOOK_DEPLOYER,
186
+ PUBLISHER,
187
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
188
+ address(LOANS_CONTRACT),
189
+ TRUSTED_FORWARDER
190
+ );
191
+
192
+ vm.prank(multisig());
193
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
194
+
195
+ // Deploy the fee revnet (project ID 1).
196
+ _deployFeeRevnet();
197
+
198
+ // Deploy a second revnet to borrow from.
199
+ _deployBorrowableRevnet();
200
+
201
+ // Give user ETH.
202
+ vm.deal(USER, 100e18);
203
+ }
204
+
205
+ function _deployFeeRevnet() internal {
206
+ JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
207
+ accountingContextsToAccept[0] = JBAccountingContext({
208
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
209
+ });
210
+
211
+ JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
212
+ terminalConfigurations[0] =
213
+ JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
214
+
215
+ REVStageConfig[] memory stageConfigurations = new REVStageConfig[](1);
216
+ JBSplit[] memory splits = new JBSplit[](1);
217
+ splits[0].beneficiary = payable(multisig());
218
+ splits[0].percent = 10_000;
219
+
220
+ REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
221
+ issuanceConfs[0] =
222
+ REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(70_000e18), beneficiary: multisig()});
223
+
224
+ stageConfigurations[0] = REVStageConfig({
225
+ startsAtOrAfter: uint40(block.timestamp),
226
+ autoIssuances: issuanceConfs,
227
+ splitPercent: 2000,
228
+ splits: splits,
229
+ initialIssuance: uint112(1000e18),
230
+ issuanceCutFrequency: 90 days,
231
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
232
+ cashOutTaxRate: 6000,
233
+ extraMetadata: 0
234
+ });
235
+
236
+ REVConfig memory revnetConfiguration = REVConfig({
237
+ description: REVDescription("Revnet", "$REV", "ipfs://test", ERC20_SALT),
238
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
239
+ splitOperator: multisig(),
240
+ stageConfigurations: stageConfigurations
241
+ });
242
+
243
+ vm.prank(multisig());
244
+ REV_DEPLOYER.deployFor({
245
+ revnetId: FEE_PROJECT_ID,
246
+ configuration: revnetConfiguration,
247
+ terminalConfigurations: terminalConfigurations,
248
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
249
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("REV"))
250
+ })
251
+ });
252
+ }
253
+
254
+ function _deployBorrowableRevnet() internal {
255
+ JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
256
+ accountingContextsToAccept[0] = JBAccountingContext({
257
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
258
+ });
259
+
260
+ JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
261
+ terminalConfigurations[0] =
262
+ JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
263
+
264
+ REVStageConfig[] memory stageConfigurations = new REVStageConfig[](1);
265
+ JBSplit[] memory splits = new JBSplit[](1);
266
+ splits[0].beneficiary = payable(multisig());
267
+ splits[0].percent = 10_000;
268
+
269
+ stageConfigurations[0] = REVStageConfig({
270
+ startsAtOrAfter: uint40(block.timestamp),
271
+ autoIssuances: new REVAutoIssuance[](0),
272
+ splitPercent: 0,
273
+ splits: splits,
274
+ initialIssuance: uint112(1000e18),
275
+ issuanceCutFrequency: 0,
276
+ issuanceCutPercent: 0,
277
+ cashOutTaxRate: 5000,
278
+ extraMetadata: 0
279
+ });
280
+
281
+ REVConfig memory revnetConfiguration = REVConfig({
282
+ description: REVDescription("Borrowable", "BRW", "ipfs://brw", "BRW_TOKEN"),
283
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
284
+ splitOperator: multisig(),
285
+ stageConfigurations: stageConfigurations
286
+ });
287
+
288
+ REVNET_ID = REV_DEPLOYER.deployFor({
289
+ revnetId: 0,
290
+ configuration: revnetConfiguration,
291
+ terminalConfigurations: terminalConfigurations,
292
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
293
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("BRW"))
294
+ })
295
+ });
296
+ }
297
+
298
+ /// @notice Helper: pay into the revnet and get tokens.
299
+ function _payAndGetTokens(uint256 amount) internal returns (uint256 tokens) {
300
+ vm.prank(USER);
301
+ tokens = jbMultiTerminal().pay{value: amount}(REVNET_ID, JBConstants.NATIVE_TOKEN, amount, USER, 0, "", "");
302
+ }
303
+
304
+ /// @notice Helper: mock permission for LOANS_CONTRACT to burn user tokens.
305
+ function _mockBurnPermission() internal {
306
+ mockExpect(
307
+ address(jbPermissions()),
308
+ abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), USER, REVNET_ID, 11, true, true)),
309
+ abi.encode(true)
310
+ );
311
+ }
312
+
313
+ /// @notice Helper: borrow against tokens.
314
+ function _borrow(uint256 tokens) internal returns (uint256 loanId, REVLoan memory loan, uint256 loanable) {
315
+ loanable = LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
316
+
317
+ _mockBurnPermission();
318
+
319
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
320
+
321
+ vm.prank(USER);
322
+ (loanId, loan) = LOANS_CONTRACT.borrowFrom(REVNET_ID, source, loanable, tokens, payable(USER), 25);
323
+ }
324
+
325
+ //*********************************************************************//
326
+ // --------- Unvalidated Loan Source Terminal ------------------- //
327
+ //*********************************************************************//
328
+
329
+ /// @notice borrowFrom rejects a fake terminal not registered in the directory.
330
+ function test_H1_borrowFromRejectsUnregisteredTerminal() public {
331
+ // Step 1: User pays into the revnet to get tokens.
332
+ uint256 tokens = _payAndGetTokens(1e18);
333
+ assertGt(tokens, 0, "user should receive tokens");
334
+
335
+ // Step 2: Create a fake terminal that returns garbage accounting contexts.
336
+ GarbageTerminal fakeTerminal = new GarbageTerminal();
337
+
338
+ // Step 3: Verify the fake terminal is NOT in the directory.
339
+ assertFalse(
340
+ jbDirectory().isTerminalOf(REVNET_ID, IJBTerminal(address(fakeTerminal))),
341
+ "fake terminal should NOT be registered"
342
+ );
343
+
344
+ // Step 4: Attempt to borrow using the fake terminal.
345
+ uint256 loanable =
346
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
347
+ assertGt(loanable, 0, "should have borrowable amount");
348
+
349
+ // NOTE: Do NOT mock burn permission here. The call should revert
350
+ // at the terminal validation check before it ever reaches the burn step.
351
+
352
+ REVLoanSource memory fakeSource =
353
+ REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: IJBPayoutTerminal(address(fakeTerminal))});
354
+
355
+ // Step 5: Expect revert with the new REVLoans_InvalidTerminal error.
356
+ vm.expectRevert(
357
+ abi.encodeWithSelector(REVLoans.REVLoans_InvalidTerminal.selector, address(fakeTerminal), REVNET_ID)
358
+ );
359
+
360
+ vm.prank(USER);
361
+ LOANS_CONTRACT.borrowFrom(REVNET_ID, fakeSource, loanable, tokens, payable(USER), 25);
362
+ }
363
+
364
+ //*********************************************************************//
365
+ // ------- RepayLoan Event Emits Zeroed Values ----------------- //
366
+ //*********************************************************************//
367
+
368
+ /// @notice RepayLoan event emits non-zero loan amount and collateral
369
+ /// when fully repaying a loan.
370
+ function test_L1_repayLoanEventEmitsNonZeroValues() public {
371
+ // Step 1: Pay in and borrow.
372
+ uint256 tokens = _payAndGetTokens(1e18);
373
+ (uint256 loanId, REVLoan memory loan, uint256 loanable) = _borrow(tokens);
374
+
375
+ assertGt(loan.amount, 0, "loan amount should be non-zero");
376
+ assertGt(loan.collateral, 0, "loan collateral should be non-zero");
377
+
378
+ // Step 2: Calculate the repay amount (loan amount + source fee).
379
+ uint256 sourceFee = LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
380
+ uint256 totalRepay = loan.amount + sourceFee;
381
+
382
+ // Step 3: Give user enough ETH for repayment.
383
+ vm.deal(USER, totalRepay);
384
+
385
+ // Step 4: Expect the RepayLoan event with non-zero loan values.
386
+ // The `loan` field (4th param) should contain the original pre-repay loan data.
387
+ // We check that the event is emitted by looking for the indexed fields.
388
+ vm.expectEmit(true, true, true, false);
389
+ emit IREVLoans.RepayLoan({
390
+ loanId: loanId,
391
+ revnetId: REVNET_ID,
392
+ paidOffLoanId: loanId,
393
+ // These fields are the ones we care about -- they should be non-zero.
394
+ loan: loan,
395
+ paidOffLoan: loan, // placeholder, we only check the `loan` field
396
+ repayBorrowAmount: totalRepay,
397
+ sourceFeeAmount: sourceFee,
398
+ collateralCountToReturn: loan.collateral,
399
+ beneficiary: payable(USER),
400
+ caller: USER
401
+ });
402
+
403
+ // Step 5: Fully repay the loan (return all collateral).
404
+ JBSingleAllowance memory allowance;
405
+ vm.prank(USER);
406
+ LOANS_CONTRACT.repayLoan{value: totalRepay}(loanId, totalRepay, loan.collateral, payable(USER), allowance);
407
+ }
408
+
409
+ /// @notice Secondary check: verify the original loan data in the emitted event
410
+ /// has the expected non-zero amount and collateral by recording logs.
411
+ function test_L1_repayLoanEventLoanFieldIsNonZero() public {
412
+ // Step 1: Pay in and borrow.
413
+ uint256 tokens = _payAndGetTokens(1e18);
414
+ (uint256 loanId, REVLoan memory loan,) = _borrow(tokens);
415
+
416
+ uint256 originalAmount = loan.amount;
417
+ uint256 originalCollateral = loan.collateral;
418
+
419
+ // Step 2: Calculate repay amount.
420
+ uint256 sourceFee = LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
421
+ uint256 totalRepay = loan.amount + sourceFee;
422
+ vm.deal(USER, totalRepay);
423
+
424
+ // Step 3: Record logs to inspect the event data.
425
+ vm.recordLogs();
426
+
427
+ JBSingleAllowance memory allowance;
428
+ vm.prank(USER);
429
+ LOANS_CONTRACT.repayLoan{value: totalRepay}(loanId, totalRepay, loan.collateral, payable(USER), allowance);
430
+
431
+ // Step 4: Find the RepayLoan event and decode the loan struct.
432
+ Vm.Log[] memory entries = vm.getRecordedLogs();
433
+ bytes32 repayLoanSig = keccak256(
434
+ "RepayLoan(uint256,uint256,uint256,(uint112,uint112,uint48,uint16,uint32,(address,address)),(uint112,uint112,uint48,uint16,uint32,(address,address)),uint256,uint256,uint256,address,address)"
435
+ );
436
+
437
+ bool foundEvent = false;
438
+ for (uint256 i = 0; i < entries.length; i++) {
439
+ if (entries[i].topics[0] == repayLoanSig) {
440
+ foundEvent = true;
441
+ // Decode the non-indexed data.
442
+ // The data contains: loan, paidOffLoan, repayBorrowAmount, sourceFeeAmount,
443
+ // collateralCountToReturn, beneficiary, caller
444
+ (REVLoan memory emittedLoan,,,,,,) =
445
+ abi.decode(entries[i].data, (REVLoan, REVLoan, uint256, uint256, uint256, address, address));
446
+
447
+ // The emitted loan should have the ORIGINAL non-zero values.
448
+ assertEq(emittedLoan.amount, originalAmount, "emitted loan.amount should match original");
449
+ assertEq(emittedLoan.collateral, originalCollateral, "emitted loan.collateral should match original");
450
+ assertGt(emittedLoan.amount, 0, "emitted loan.amount must be non-zero");
451
+ assertGt(emittedLoan.collateral, 0, "emitted loan.collateral must be non-zero");
452
+ break;
453
+ }
454
+ }
455
+
456
+ assertTrue(foundEvent, "RepayLoan event should have been emitted");
457
+ }
458
+
459
+ //*********************************************************************//
460
+ // --- Auto-Issuance Timing Guard Works Correctly ------------- //
461
+ //*********************************************************************//
462
+
463
+ /// @notice Proves that block.timestamp + i matches actual ruleset IDs,
464
+ /// and the timing guard in autoIssueFor correctly prevents premature issuance.
465
+ function test_autoIssueTimingGuardWorksCorrectly() public {
466
+ // Step 1: Deploy a revnet with 2 stages where stage 2 starts far in the future
467
+ // and has auto-issuance.
468
+ JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
469
+ accountingContextsToAccept[0] = JBAccountingContext({
470
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
471
+ });
472
+
473
+ JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
474
+ terminalConfigurations[0] =
475
+ JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
476
+
477
+ REVStageConfig[] memory stages = new REVStageConfig[](2);
478
+ JBSplit[] memory splits = new JBSplit[](1);
479
+ splits[0].beneficiary = payable(multisig());
480
+ splits[0].percent = 10_000;
481
+
482
+ // Stage 1: starts now, no auto-issuance.
483
+ stages[0] = REVStageConfig({
484
+ startsAtOrAfter: uint40(block.timestamp),
485
+ autoIssuances: new REVAutoIssuance[](0),
486
+ splitPercent: 2000,
487
+ splits: splits,
488
+ initialIssuance: uint112(1000e18),
489
+ issuanceCutFrequency: 0,
490
+ issuanceCutPercent: 0,
491
+ cashOutTaxRate: 5000,
492
+ extraMetadata: 0
493
+ });
494
+
495
+ // Stage 2: starts 365 days in the future, HAS auto-issuance.
496
+ REVAutoIssuance[] memory stage2AutoIssuances = new REVAutoIssuance[](1);
497
+ stage2AutoIssuances[0] =
498
+ REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(50_000e18), beneficiary: multisig()});
499
+
500
+ stages[1] = REVStageConfig({
501
+ startsAtOrAfter: uint40(block.timestamp + 365 days),
502
+ autoIssuances: stage2AutoIssuances,
503
+ splitPercent: 1000,
504
+ splits: splits,
505
+ initialIssuance: uint112(500e18),
506
+ issuanceCutFrequency: 0,
507
+ issuanceCutPercent: 0,
508
+ cashOutTaxRate: 3000,
509
+ extraMetadata: 0
510
+ });
511
+
512
+ REVConfig memory config = REVConfig({
513
+ description: REVDescription("FP1Test", "FP1", "ipfs://fp1", "FP1_TOKEN"),
514
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
515
+ splitOperator: multisig(),
516
+ stageConfigurations: stages
517
+ });
518
+
519
+ // Record the deploy timestamp -- this is used for stage ID calculation.
520
+ uint256 deployTimestamp = block.timestamp;
521
+
522
+ uint256 fp1RevnetId = REV_DEPLOYER.deployFor({
523
+ revnetId: 0,
524
+ configuration: config,
525
+ terminalConfigurations: terminalConfigurations,
526
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
527
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("FP1")
528
+ })
529
+ });
530
+
531
+ // Step 2: Verify the second ruleset ID matches deployTimestamp + 1.
532
+ // JBRulesets assigns IDs as: block.timestamp, block.timestamp+1, etc. when queued in one tx.
533
+ uint256 stage2RulesetId = deployTimestamp + 1;
534
+
535
+ (JBRuleset memory ruleset,) = jbController().getRulesetOf({projectId: fp1RevnetId, rulesetId: stage2RulesetId});
536
+
537
+ // The ruleset should exist and have the correct startsAtOrAfter.
538
+ assertGt(ruleset.id, 0, "stage 2 ruleset should exist");
539
+ assertEq(ruleset.start, deployTimestamp + 365 days, "stage 2 should start 365 days from deploy");
540
+
541
+ // Step 3: Verify amountToAutoIssue was stored with the correct stage ID.
542
+ uint256 storedAutoIssue = REV_DEPLOYER.amountToAutoIssue(fp1RevnetId, stage2RulesetId, multisig());
543
+ assertEq(storedAutoIssue, 50_000e18, "auto-issuance amount should be stored at deployTimestamp + 1");
544
+
545
+ // Step 4: Call autoIssueFor now -- it should revert because stage 2 hasn't started yet.
546
+ vm.expectRevert(abi.encodeWithSelector(REVDeployer.REVDeployer_StageNotStarted.selector, stage2RulesetId));
547
+ REV_DEPLOYER.autoIssueFor(fp1RevnetId, stage2RulesetId, multisig());
548
+
549
+ // Step 5: Warp to after stage 2 starts and verify auto-issuance works.
550
+ vm.warp(deployTimestamp + 365 days + 1);
551
+
552
+ REV_DEPLOYER.autoIssueFor(fp1RevnetId, stage2RulesetId, multisig());
553
+
554
+ // Verify the tokens were minted.
555
+ uint256 balance = jbController().TOKENS().totalBalanceOf({holder: multisig(), projectId: fp1RevnetId});
556
+ assertGe(balance, 50_000e18, "multisig should have received the auto-issued tokens");
557
+ }
558
+
559
+ //*********************************************************************//
560
+ // --- repayLoan Correctly Reverts On Excess Collateral ------- //
561
+ //*********************************************************************//
562
+
563
+ /// @notice When collateral value exceeds the loan amount (e.g. from price appreciation
564
+ /// or surplus growth), partial repayment correctly reverts because the remaining
565
+ /// collateral supports more than the loan amount. reallocateCollateralFromLoan
566
+ /// is the correct alternative.
567
+ function test_repayLoanCorrectlyRevertsOnExcessCollateral() public {
568
+ // Step 1: User pays in and borrows.
569
+ uint256 tokens = _payAndGetTokens(1e18);
570
+ (uint256 loanId, REVLoan memory loan,) = _borrow(tokens);
571
+
572
+ uint256 loanAmount = loan.amount;
573
+ uint256 loanCollateral = loan.collateral;
574
+
575
+ assertGt(loanAmount, 0, "loan should have non-zero amount");
576
+ assertGt(loanCollateral, 0, "loan should have non-zero collateral");
577
+
578
+ // Step 2: Simulate surplus growth by adding balance directly (no token minting).
579
+ // Using addToBalanceOf increases surplus without increasing supply, so each token
580
+ // is now backed by more surplus and the collateral value exceeds the loan amount.
581
+ {
582
+ address whale = makeAddr("whale");
583
+ vm.deal(whale, 50e18);
584
+ vm.prank(whale);
585
+ jbMultiTerminal().addToBalanceOf{value: 50e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 50e18, false, "", "");
586
+ }
587
+
588
+ // Step 3: Verify the collateral value has increased.
589
+ {
590
+ uint256 newBorrowable = LOANS_CONTRACT.borrowableAmountFrom(
591
+ REVNET_ID, loanCollateral, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
592
+ );
593
+ assertGt(
594
+ newBorrowable, loanAmount, "collateral value should exceed original loan amount after surplus growth"
595
+ );
596
+ }
597
+
598
+ // Step 4: Try to repay returning only SOME collateral such that remaining collateral
599
+ // supports more than the loan amount. This should revert with
600
+ // REVLoans_NewBorrowAmountGreaterThanLoanAmount.
601
+ {
602
+ uint256 sourceFee = LOANS_CONTRACT.determineSourceFeeAmount(loan, loanAmount);
603
+ uint256 totalRepay = loanAmount + sourceFee;
604
+ vm.deal(USER, totalRepay);
605
+
606
+ JBSingleAllowance memory allowance;
607
+
608
+ vm.prank(USER);
609
+ vm.expectRevert(); // REVLoans_NewBorrowAmountGreaterThanLoanAmount
610
+ LOANS_CONTRACT.repayLoan{value: totalRepay}(loanId, totalRepay, 1, payable(USER), allowance);
611
+ }
612
+
613
+ // Step 5: Show that reallocateCollateralFromLoan is the correct alternative.
614
+ // The user can reallocate excess collateral to a new loan instead.
615
+ _mockBurnPermission();
616
+
617
+ uint256 collateralToTransfer = loanCollateral / 10;
618
+
619
+ uint256 minBorrow = LOANS_CONTRACT.borrowableAmountFrom(
620
+ REVNET_ID, collateralToTransfer, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
621
+ );
622
+
623
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
624
+
625
+ vm.prank(USER);
626
+ (,, REVLoan memory reallocatedLoan, REVLoan memory newLoan) = LOANS_CONTRACT.reallocateCollateralFromLoan({
627
+ loanId: loanId,
628
+ collateralCountToTransfer: collateralToTransfer,
629
+ source: source,
630
+ minBorrowAmount: minBorrow,
631
+ collateralCountToAdd: 0,
632
+ beneficiary: payable(USER),
633
+ prepaidFeePercent: 25
634
+ });
635
+
636
+ // Verify the reallocation succeeded.
637
+ assertEq(
638
+ reallocatedLoan.collateral,
639
+ loanCollateral - collateralToTransfer,
640
+ "reallocated loan should have reduced collateral"
641
+ );
642
+ assertGt(newLoan.collateral, 0, "new loan should have collateral from transfer");
643
+ }
644
+ }
@@ -34,7 +34,7 @@ import {JBTokenAmount} from "@bananapus/core-v6/src/structs/JBTokenAmount.sol";
34
34
  /// When JBBuybackHook determines minting is cheaper than swapping, it returns an empty
35
35
  /// hookSpecifications array. Before the fix, REVDeployer.beforePayRecordedWith would
36
36
  /// Panic(0x32) (array out-of-bounds) when accessing buybackHookSpecifications[0].
37
- contract TestEmptyBuybackSpecs is TestBaseWorkflow, JBTest {
37
+ contract TestEmptyBuybackSpecs is TestBaseWorkflow {
38
38
  bytes32 REV_DEPLOYER_SALT = "REVDeployer";
39
39
 
40
40
  REVDeployer REV_DEPLOYER;
@@ -57,7 +57,8 @@ contract TestEmptyBuybackSpecs is TestBaseWorkflow, JBTest {
57
57
  FEE_PROJECT_ID = jbProjects().createFor(multisig());
58
58
  SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
59
59
  HOOK_STORE = new JB721TiersHookStore();
60
- EXAMPLE_HOOK = new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, multisig());
60
+ EXAMPLE_HOOK =
61
+ new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, jbSplits(), multisig());
61
62
  ADDRESS_REGISTRY = new JBAddressRegistry();
62
63
  HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
63
64
  PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
@@ -76,7 +77,7 @@ contract TestEmptyBuybackSpecs is TestBaseWorkflow, JBTest {
76
77
  FEE_PROJECT_ID,
77
78
  HOOK_DEPLOYER,
78
79
  PUBLISHER,
79
- IJBRulesetDataHook(address(MOCK_BUYBACK_MINT_PATH)),
80
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK_MINT_PATH)),
80
81
  address(LOANS_CONTRACT),
81
82
  TRUSTED_FORWARDER
82
83
  );