@rev-net/core-v6 0.0.17 → 0.0.19

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 (66) hide show
  1. package/ADMINISTRATION.md +14 -4
  2. package/ARCHITECTURE.md +14 -10
  3. package/AUDIT_INSTRUCTIONS.md +40 -17
  4. package/CHANGE_LOG.md +87 -0
  5. package/README.md +10 -5
  6. package/RISKS.md +15 -10
  7. package/SKILLS.md +31 -15
  8. package/USER_JOURNEYS.md +16 -12
  9. package/foundry.toml +1 -1
  10. package/package.json +8 -8
  11. package/script/Deploy.s.sol +60 -19
  12. package/src/REVDeployer.sol +21 -303
  13. package/src/REVLoans.sol +31 -0
  14. package/src/REVOwner.sol +430 -0
  15. package/src/interfaces/IREVDeployer.sol +4 -10
  16. package/src/interfaces/IREVOwner.sol +10 -0
  17. package/src/structs/REVBaseline721HookConfig.sol +0 -2
  18. package/test/REV.integrations.t.sol +14 -1
  19. package/test/REVAutoIssuanceFuzz.t.sol +14 -1
  20. package/test/REVDeployerRegressions.t.sol +17 -2
  21. package/test/REVInvincibility.t.sol +31 -3
  22. package/test/REVLifecycle.t.sol +16 -1
  23. package/test/REVLoans.invariants.t.sol +16 -1
  24. package/test/REVLoansAttacks.t.sol +16 -1
  25. package/test/REVLoansFeeRecovery.t.sol +16 -1
  26. package/test/REVLoansFindings.t.sol +16 -1
  27. package/test/REVLoansRegressions.t.sol +16 -1
  28. package/test/REVLoansSourceFeeRecovery.t.sol +16 -1
  29. package/test/REVLoansSourced.t.sol +16 -1
  30. package/test/REVLoansUnSourced.t.sol +16 -1
  31. package/test/TestBurnHeldTokens.t.sol +16 -1
  32. package/test/TestCEIPattern.t.sol +16 -1
  33. package/test/TestCashOutCallerValidation.t.sol +19 -4
  34. package/test/TestConversionDocumentation.t.sol +16 -1
  35. package/test/TestCrossCurrencyReclaim.t.sol +16 -1
  36. package/test/TestCrossSourceReallocation.t.sol +16 -1
  37. package/test/TestERC2771MetaTx.t.sol +16 -1
  38. package/test/TestEmptyBuybackSpecs.t.sol +18 -3
  39. package/test/TestFlashLoanSurplus.t.sol +16 -1
  40. package/test/TestHookArrayOOB.t.sol +17 -2
  41. package/test/TestLiquidationBehavior.t.sol +16 -1
  42. package/test/TestLoanSourceRotation.t.sol +16 -1
  43. package/test/TestLoansCashOutDelay.t.sol +482 -0
  44. package/test/TestLongTailEconomics.t.sol +16 -1
  45. package/test/TestLowFindings.t.sol +16 -1
  46. package/test/TestMixedFixes.t.sol +16 -1
  47. package/test/TestPermit2Signatures.t.sol +16 -1
  48. package/test/TestReallocationSandwich.t.sol +16 -1
  49. package/test/TestRevnetRegressions.t.sol +16 -1
  50. package/test/TestSplitWeightAdjustment.t.sol +43 -19
  51. package/test/TestSplitWeightE2E.t.sol +26 -3
  52. package/test/TestSplitWeightFork.t.sol +16 -2
  53. package/test/TestStageTransitionBorrowable.t.sol +16 -1
  54. package/test/TestSwapTerminalPermission.t.sol +16 -1
  55. package/test/TestUint112Overflow.t.sol +16 -1
  56. package/test/TestZeroRepayment.t.sol +16 -1
  57. package/test/audit/LoanIdOverflowGuard.t.sol +16 -1
  58. package/test/fork/ForkTestBase.sol +16 -2
  59. package/test/fork/TestPermit2PaymentFork.t.sol +4 -3
  60. package/test/helpers/REVEmpty721Config.sol +0 -1
  61. package/test/regression/TestBurnPermissionRequired.t.sol +16 -1
  62. package/test/regression/TestCashOutBuybackFeeLeak.t.sol +15 -1
  63. package/test/regression/TestCrossRevnetLiquidation.t.sol +16 -1
  64. package/test/regression/TestCumulativeLoanCounter.t.sol +16 -1
  65. package/test/regression/TestLiquidateGapHandling.t.sol +16 -1
  66. package/test/regression/TestZeroPriceFeed.t.sol +16 -1
@@ -0,0 +1,482 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ // forge-lint: disable-next-line(unaliased-plain-import)
5
+ import "forge-std/Test.sol";
6
+ // forge-lint: disable-next-line(unaliased-plain-import)
7
+ import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
8
+ // forge-lint: disable-next-line(unaliased-plain-import)
9
+ import /* {*} from */ "./../src/REVDeployer.sol";
10
+ // forge-lint: disable-next-line(unaliased-plain-import)
11
+ import "@croptop/core-v6/src/CTPublisher.sol";
12
+ import {MockBuybackDataHook} from "./mock/MockBuybackDataHook.sol";
13
+
14
+ // forge-lint: disable-next-line(unaliased-plain-import)
15
+ import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
16
+ // forge-lint: disable-next-line(unaliased-plain-import)
17
+ import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
18
+ // forge-lint: disable-next-line(unaliased-plain-import)
19
+ import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
20
+ // forge-lint: disable-next-line(unaliased-plain-import)
21
+ import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
22
+ // forge-lint: disable-next-line(unaliased-plain-import)
23
+ import "@bananapus/router-terminal-v6/script/helpers/RouterTerminalDeploymentLib.sol";
24
+
25
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
26
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
27
+ import {REVLoans} from "../src/REVLoans.sol";
28
+ import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
29
+ import {REVLoanSource} from "../src/structs/REVLoanSource.sol";
30
+ import {REVDescription} from "../src/structs/REVDescription.sol";
31
+ import {IREVLoans} from "./../src/interfaces/IREVLoans.sol";
32
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
33
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
34
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
35
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
36
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
37
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
38
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
39
+ import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
40
+ import {REVOwner} from "../src/REVOwner.sol";
41
+ import {IREVDeployer} from "../src/interfaces/IREVDeployer.sol";
42
+
43
+ struct FeeProjectConfig {
44
+ REVConfig configuration;
45
+ JBTerminalConfig[] terminalConfigurations;
46
+ REVSuckerDeploymentConfig suckerDeploymentConfiguration;
47
+ }
48
+
49
+ /// @notice Tests that REVLoans enforces the cash out delay set by REVDeployer for cross-chain deployments.
50
+ contract TestLoansCashOutDelay is TestBaseWorkflow {
51
+ // forge-lint: disable-next-line(mixed-case-variable)
52
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
53
+ // forge-lint: disable-next-line(mixed-case-variable)
54
+ bytes32 ERC20_SALT = "REV_TOKEN";
55
+
56
+ // forge-lint: disable-next-line(mixed-case-variable)
57
+ REVDeployer REV_DEPLOYER;
58
+ // forge-lint: disable-next-line(mixed-case-variable)
59
+ REVOwner REV_OWNER;
60
+ // forge-lint: disable-next-line(mixed-case-variable)
61
+ JB721TiersHook EXAMPLE_HOOK;
62
+ // forge-lint: disable-next-line(mixed-case-variable)
63
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
64
+ // forge-lint: disable-next-line(mixed-case-variable)
65
+ IJB721TiersHookStore HOOK_STORE;
66
+ // forge-lint: disable-next-line(mixed-case-variable)
67
+ IJBAddressRegistry ADDRESS_REGISTRY;
68
+ // forge-lint: disable-next-line(mixed-case-variable)
69
+ IREVLoans LOANS_CONTRACT;
70
+ // forge-lint: disable-next-line(mixed-case-variable)
71
+ IJBSuckerRegistry SUCKER_REGISTRY;
72
+ // forge-lint: disable-next-line(mixed-case-variable)
73
+ CTPublisher PUBLISHER;
74
+ // forge-lint: disable-next-line(mixed-case-variable)
75
+ MockBuybackDataHook MOCK_BUYBACK;
76
+
77
+ // forge-lint: disable-next-line(mixed-case-variable)
78
+ uint256 FEE_PROJECT_ID;
79
+
80
+ /// @notice Revnet deployed with startsAtOrAfter in the past (triggers cash out delay).
81
+ // forge-lint: disable-next-line(mixed-case-variable)
82
+ uint256 DELAYED_REVNET_ID;
83
+
84
+ /// @notice Revnet deployed with startsAtOrAfter == block.timestamp (no delay).
85
+ // forge-lint: disable-next-line(mixed-case-variable)
86
+ uint256 NORMAL_REVNET_ID;
87
+
88
+ // forge-lint: disable-next-line(mixed-case-variable)
89
+ address USER = makeAddr("user");
90
+
91
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
92
+
93
+ function getFeeProjectConfig() internal view returns (FeeProjectConfig memory) {
94
+ JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
95
+ accountingContextsToAccept[0] = JBAccountingContext({
96
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
97
+ });
98
+
99
+ JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
100
+ terminalConfigurations[0] =
101
+ JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
102
+
103
+ REVStageConfig[] memory stageConfigurations = new REVStageConfig[](1);
104
+ JBSplit[] memory splits = new JBSplit[](1);
105
+ splits[0].beneficiary = payable(multisig());
106
+ splits[0].percent = 10_000;
107
+
108
+ stageConfigurations[0] = REVStageConfig({
109
+ startsAtOrAfter: uint40(block.timestamp),
110
+ autoIssuances: new REVAutoIssuance[](0),
111
+ splitPercent: 2000,
112
+ splits: splits,
113
+ // forge-lint: disable-next-line(unsafe-typecast)
114
+ initialIssuance: uint112(1000e18),
115
+ issuanceCutFrequency: 90 days,
116
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
117
+ cashOutTaxRate: 6000,
118
+ extraMetadata: 0
119
+ });
120
+
121
+ return FeeProjectConfig({
122
+ configuration: REVConfig({
123
+ // forge-lint: disable-next-line(named-struct-fields)
124
+ description: REVDescription("Revnet", "$REV", "ipfs://fee", ERC20_SALT),
125
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
126
+ splitOperator: multisig(),
127
+ stageConfigurations: stageConfigurations
128
+ }),
129
+ terminalConfigurations: terminalConfigurations,
130
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
131
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("REV"))
132
+ })
133
+ });
134
+ }
135
+
136
+ /// @notice Returns a revnet config. When `pastStart` is true, `startsAtOrAfter` is set to 1 second ago,
137
+ /// triggering the 30-day cash out delay in REVDeployer._setCashOutDelayIfNeeded.
138
+ function _getRevnetConfig(
139
+ bool pastStart,
140
+ string memory name,
141
+ string memory symbol,
142
+ bytes32 salt
143
+ )
144
+ internal
145
+ view
146
+ returns (FeeProjectConfig memory)
147
+ {
148
+ JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
149
+ accountingContextsToAccept[0] = JBAccountingContext({
150
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
151
+ });
152
+
153
+ JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
154
+ terminalConfigurations[0] =
155
+ JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
156
+
157
+ REVStageConfig[] memory stageConfigurations = new REVStageConfig[](1);
158
+ JBSplit[] memory splits = new JBSplit[](1);
159
+ splits[0].beneficiary = payable(multisig());
160
+ splits[0].percent = 10_000;
161
+
162
+ // If pastStart, set startsAtOrAfter to 1 second ago — simulates cross-chain deployment
163
+ // where the stage is already active on another chain.
164
+ uint40 startsAt = pastStart ? uint40(block.timestamp - 1) : uint40(block.timestamp);
165
+
166
+ stageConfigurations[0] = REVStageConfig({
167
+ startsAtOrAfter: startsAt,
168
+ autoIssuances: new REVAutoIssuance[](0),
169
+ splitPercent: 2000,
170
+ splits: splits,
171
+ // forge-lint: disable-next-line(unsafe-typecast)
172
+ initialIssuance: uint112(1000e18),
173
+ issuanceCutFrequency: 90 days,
174
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
175
+ cashOutTaxRate: 6000,
176
+ extraMetadata: 0
177
+ });
178
+
179
+ return FeeProjectConfig({
180
+ configuration: REVConfig({
181
+ // forge-lint: disable-next-line(named-struct-fields)
182
+ description: REVDescription(name, symbol, "ipfs://test", salt),
183
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
184
+ splitOperator: multisig(),
185
+ stageConfigurations: stageConfigurations
186
+ }),
187
+ terminalConfigurations: terminalConfigurations,
188
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
189
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: salt
190
+ })
191
+ });
192
+ }
193
+
194
+ function setUp() public override {
195
+ super.setUp();
196
+
197
+ // Warp to a realistic timestamp so startsAtOrAfter - 1 doesn't underflow.
198
+ vm.warp(1_700_000_000);
199
+
200
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
201
+
202
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
203
+ HOOK_STORE = new JB721TiersHookStore();
204
+ EXAMPLE_HOOK = new JB721TiersHook(
205
+ jbDirectory(), jbPermissions(), jbPrices(), jbRulesets(), HOOK_STORE, jbSplits(), multisig()
206
+ );
207
+ ADDRESS_REGISTRY = new JBAddressRegistry();
208
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
209
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
210
+ MOCK_BUYBACK = new MockBuybackDataHook();
211
+
212
+ LOANS_CONTRACT = new REVLoans({
213
+ controller: jbController(),
214
+ projects: jbProjects(),
215
+ revId: FEE_PROJECT_ID,
216
+ owner: address(this),
217
+ permit2: permit2(),
218
+ trustedForwarder: TRUSTED_FORWARDER
219
+ });
220
+
221
+ REV_OWNER = new REVOwner(
222
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
223
+ jbDirectory(),
224
+ FEE_PROJECT_ID,
225
+ SUCKER_REGISTRY,
226
+ address(LOANS_CONTRACT)
227
+ );
228
+
229
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
230
+ jbController(),
231
+ SUCKER_REGISTRY,
232
+ FEE_PROJECT_ID,
233
+ HOOK_DEPLOYER,
234
+ PUBLISHER,
235
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
236
+ address(LOANS_CONTRACT),
237
+ TRUSTED_FORWARDER,
238
+ address(REV_OWNER)
239
+ );
240
+
241
+ REV_OWNER.initialize(IREVDeployer(address(REV_DEPLOYER)));
242
+
243
+ // Approve the deployer to configure the fee project.
244
+ vm.prank(multisig());
245
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
246
+
247
+ // Deploy the fee project.
248
+ FeeProjectConfig memory feeProjectConfig = getFeeProjectConfig();
249
+ vm.prank(multisig());
250
+ REV_DEPLOYER.deployFor({
251
+ revnetId: FEE_PROJECT_ID,
252
+ configuration: feeProjectConfig.configuration,
253
+ terminalConfigurations: feeProjectConfig.terminalConfigurations,
254
+ suckerDeploymentConfiguration: feeProjectConfig.suckerDeploymentConfiguration,
255
+ tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
256
+ allowedPosts: REVEmpty721Config.emptyAllowedPosts()
257
+ });
258
+
259
+ // Deploy a revnet with startsAtOrAfter in the past (triggers 30-day cash out delay).
260
+ FeeProjectConfig memory delayedConfig =
261
+ _getRevnetConfig(true, "Delayed", "$DLY", keccak256(abi.encodePacked("DELAYED")));
262
+ (DELAYED_REVNET_ID,) = REV_DEPLOYER.deployFor({
263
+ revnetId: 0,
264
+ configuration: delayedConfig.configuration,
265
+ terminalConfigurations: delayedConfig.terminalConfigurations,
266
+ suckerDeploymentConfiguration: delayedConfig.suckerDeploymentConfiguration,
267
+ tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
268
+ allowedPosts: REVEmpty721Config.emptyAllowedPosts()
269
+ });
270
+
271
+ // Deploy a normal revnet with no delay.
272
+ FeeProjectConfig memory normalConfig =
273
+ _getRevnetConfig(false, "Normal", "$NRM", keccak256(abi.encodePacked("NORMAL")));
274
+ (NORMAL_REVNET_ID,) = REV_DEPLOYER.deployFor({
275
+ revnetId: 0,
276
+ configuration: normalConfig.configuration,
277
+ terminalConfigurations: normalConfig.terminalConfigurations,
278
+ suckerDeploymentConfiguration: normalConfig.suckerDeploymentConfiguration,
279
+ tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
280
+ allowedPosts: REVEmpty721Config.emptyAllowedPosts()
281
+ });
282
+
283
+ vm.deal(USER, 100 ether);
284
+ }
285
+
286
+ // ------------------------------------------------------------------
287
+ // Helpers
288
+ // ------------------------------------------------------------------
289
+
290
+ /// @notice Pay ETH into a revnet and return the number of project tokens received.
291
+ function _payAndGetTokens(uint256 revnetId, uint256 amount) internal returns (uint256 tokenCount) {
292
+ vm.prank(USER);
293
+ tokenCount = jbMultiTerminal().pay{value: amount}(revnetId, JBConstants.NATIVE_TOKEN, amount, USER, 0, "", "");
294
+ }
295
+
296
+ /// @notice Mock the permissions check so LOANS_CONTRACT can burn tokens on behalf of USER.
297
+ function _mockBorrowPermission(uint256 projectId) internal {
298
+ mockExpect(
299
+ address(jbPermissions()),
300
+ abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), USER, projectId, 11, true, true)),
301
+ abi.encode(true)
302
+ );
303
+ }
304
+
305
+ // ------------------------------------------------------------------
306
+ // Tests: delayed revnet (startsAtOrAfter in the past → 30-day delay)
307
+ // ------------------------------------------------------------------
308
+
309
+ /// @notice Verify the deployer actually set a cash out delay for the delayed revnet.
310
+ function test_delayedRevnet_hasCashOutDelay() public view {
311
+ uint256 cashOutDelay = REV_OWNER.cashOutDelayOf(DELAYED_REVNET_ID);
312
+ assertGt(cashOutDelay, block.timestamp, "Cash out delay should be in the future");
313
+ }
314
+
315
+ /// @notice Verify the normal revnet has no cash out delay.
316
+ function test_normalRevnet_noCashOutDelay() public view {
317
+ uint256 cashOutDelay = REV_OWNER.cashOutDelayOf(NORMAL_REVNET_ID);
318
+ assertEq(cashOutDelay, 0, "Normal revnet should have no cash out delay");
319
+ }
320
+
321
+ /// @notice borrowableAmountFrom should return 0 during the delay period.
322
+ function test_borrowableAmountFrom_returnsZeroDuringDelay() public {
323
+ // Pay into the delayed revnet to get tokens.
324
+ uint256 tokenCount = _payAndGetTokens(DELAYED_REVNET_ID, 1 ether);
325
+ assertGt(tokenCount, 0, "Should have tokens");
326
+
327
+ // Query borrowable amount — should be 0 during the delay.
328
+ uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
329
+ DELAYED_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
330
+ );
331
+ assertEq(borrowable, 0, "Borrowable amount should be 0 during cash out delay");
332
+ }
333
+
334
+ /// @notice borrowFrom should revert during the delay period.
335
+ function test_borrowFrom_revertsDuringDelay() public {
336
+ // Pay into the delayed revnet to get tokens.
337
+ uint256 tokenCount = _payAndGetTokens(DELAYED_REVNET_ID, 1 ether);
338
+ assertGt(tokenCount, 0, "Should have tokens");
339
+
340
+ // No permission mock needed — the function reverts before reaching the permission check.
341
+
342
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
343
+
344
+ // Attempt to borrow — should revert with CashOutDelayNotFinished.
345
+ uint256 cashOutDelay = REV_OWNER.cashOutDelayOf(DELAYED_REVNET_ID);
346
+ vm.expectRevert(
347
+ abi.encodeWithSelector(REVLoans.REVLoans_CashOutDelayNotFinished.selector, cashOutDelay, block.timestamp)
348
+ );
349
+ vm.prank(USER);
350
+ LOANS_CONTRACT.borrowFrom(DELAYED_REVNET_ID, source, 1, tokenCount, payable(USER), 25);
351
+ }
352
+
353
+ /// @notice After warping past the delay, borrowableAmountFrom should return a non-zero value.
354
+ function test_borrowableAmountFrom_nonZeroAfterDelay() public {
355
+ // Pay into the delayed revnet.
356
+ uint256 tokenCount = _payAndGetTokens(DELAYED_REVNET_ID, 1 ether);
357
+
358
+ // Still in delay — should be 0.
359
+ uint256 borrowableBefore = LOANS_CONTRACT.borrowableAmountFrom(
360
+ DELAYED_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
361
+ );
362
+ assertEq(borrowableBefore, 0, "Should be 0 during delay");
363
+
364
+ // Warp past the delay.
365
+ vm.warp(block.timestamp + REV_DEPLOYER.CASH_OUT_DELAY() + 1);
366
+
367
+ // Now should be > 0.
368
+ uint256 borrowableAfter = LOANS_CONTRACT.borrowableAmountFrom(
369
+ DELAYED_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
370
+ );
371
+ assertGt(borrowableAfter, 0, "Should be > 0 after delay expires");
372
+ }
373
+
374
+ /// @notice After warping past the delay, borrowFrom should succeed.
375
+ function test_borrowFrom_succeedsAfterDelay() public {
376
+ // Pay into the delayed revnet.
377
+ uint256 tokenCount = _payAndGetTokens(DELAYED_REVNET_ID, 1 ether);
378
+
379
+ // Warp past the delay.
380
+ vm.warp(block.timestamp + REV_DEPLOYER.CASH_OUT_DELAY() + 1);
381
+
382
+ // Get the borrowable amount.
383
+ uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
384
+ DELAYED_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
385
+ );
386
+ assertGt(borrowable, 0, "Should be borrowable after delay");
387
+
388
+ // Mock permission.
389
+ _mockBorrowPermission(DELAYED_REVNET_ID);
390
+
391
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
392
+
393
+ // Borrow — should succeed.
394
+ vm.prank(USER);
395
+ (uint256 loanId,) =
396
+ LOANS_CONTRACT.borrowFrom(DELAYED_REVNET_ID, source, borrowable, tokenCount, payable(USER), 25);
397
+ assertGt(loanId, 0, "Should have created a loan");
398
+ }
399
+
400
+ // ------------------------------------------------------------------
401
+ // Tests: normal revnet (no delay)
402
+ // ------------------------------------------------------------------
403
+
404
+ /// @notice A normal revnet (no delay) should allow borrowing immediately.
405
+ function test_normalRevnet_borrowableImmediately() public {
406
+ // Pay into the normal revnet.
407
+ uint256 tokenCount = _payAndGetTokens(NORMAL_REVNET_ID, 1 ether);
408
+
409
+ // Should have a non-zero borrowable amount immediately.
410
+ uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
411
+ NORMAL_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
412
+ );
413
+ assertGt(borrowable, 0, "Normal revnet should be borrowable immediately");
414
+ }
415
+
416
+ /// @notice A normal revnet (no delay) should allow borrowFrom immediately.
417
+ function test_normalRevnet_borrowFromImmediately() public {
418
+ // Pay into the normal revnet.
419
+ uint256 tokenCount = _payAndGetTokens(NORMAL_REVNET_ID, 1 ether);
420
+
421
+ uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
422
+ NORMAL_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
423
+ );
424
+ assertGt(borrowable, 0, "Should be borrowable");
425
+
426
+ // Mock permission.
427
+ _mockBorrowPermission(NORMAL_REVNET_ID);
428
+
429
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
430
+
431
+ // Borrow — should succeed without any delay.
432
+ vm.prank(USER);
433
+ (uint256 loanId,) =
434
+ LOANS_CONTRACT.borrowFrom(NORMAL_REVNET_ID, source, borrowable, tokenCount, payable(USER), 25);
435
+ assertGt(loanId, 0, "Should have created a loan");
436
+ }
437
+
438
+ // ------------------------------------------------------------------
439
+ // Tests: boundary conditions
440
+ // ------------------------------------------------------------------
441
+
442
+ /// @notice borrowFrom should revert at exactly the delay timestamp (not yet expired).
443
+ function test_borrowFrom_revertsAtExactDelayTimestamp() public {
444
+ uint256 tokenCount = _payAndGetTokens(DELAYED_REVNET_ID, 1 ether);
445
+
446
+ // Warp to exactly the delay timestamp (not past it).
447
+ uint256 cashOutDelay = REV_OWNER.cashOutDelayOf(DELAYED_REVNET_ID);
448
+ vm.warp(cashOutDelay);
449
+
450
+ // borrowableAmountFrom should still return 0 (cashOutDelay > block.timestamp is false, but == is not >).
451
+ // Actually cashOutDelay == block.timestamp means cashOutDelay > block.timestamp is false → should pass.
452
+ // Let's verify: at exact boundary, the delay is NOT enforced (delay == timestamp passes).
453
+ uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
454
+ DELAYED_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
455
+ );
456
+ assertGt(borrowable, 0, "At exact delay timestamp, borrowing should be allowed");
457
+ }
458
+
459
+ /// @notice borrowFrom should revert 1 second before the delay expires.
460
+ function test_borrowFrom_revertsOneSecondBeforeDelay() public {
461
+ uint256 tokenCount = _payAndGetTokens(DELAYED_REVNET_ID, 1 ether);
462
+
463
+ // Warp to 1 second before the delay expires.
464
+ uint256 cashOutDelay = REV_OWNER.cashOutDelayOf(DELAYED_REVNET_ID);
465
+ vm.warp(cashOutDelay - 1);
466
+
467
+ // borrowableAmountFrom should return 0.
468
+ uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
469
+ DELAYED_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
470
+ );
471
+ assertEq(borrowable, 0, "Should be 0 one second before delay expires");
472
+
473
+ // borrowFrom should revert before reaching the permission check.
474
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
475
+
476
+ vm.expectRevert(
477
+ abi.encodeWithSelector(REVLoans.REVLoans_CashOutDelayNotFinished.selector, cashOutDelay, block.timestamp)
478
+ );
479
+ vm.prank(USER);
480
+ LOANS_CONTRACT.borrowFrom(DELAYED_REVNET_ID, source, 1, tokenCount, payable(USER), 25);
481
+ }
482
+ }
@@ -35,6 +35,8 @@ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStor
35
35
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
36
36
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
37
37
  import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
38
+ import {REVOwner} from "../src/REVOwner.sol";
39
+ import {IREVDeployer} from "../src/interfaces/IREVDeployer.sol";
38
40
 
39
41
  /// @notice Long-tail economic simulation: run a revnet through multiple stage transitions with many payments
40
42
  /// and cash outs, verifying value conservation and bonding curve consistency.
@@ -45,6 +47,8 @@ contract TestLongTailEconomics is TestBaseWorkflow {
45
47
  // forge-lint: disable-next-line(mixed-case-variable)
46
48
  REVDeployer REV_DEPLOYER;
47
49
  // forge-lint: disable-next-line(mixed-case-variable)
50
+ REVOwner REV_OWNER;
51
+ // forge-lint: disable-next-line(mixed-case-variable)
48
52
  JB721TiersHook EXAMPLE_HOOK;
49
53
  // forge-lint: disable-next-line(mixed-case-variable)
50
54
  IJB721TiersHookDeployer HOOK_DEPLOYER;
@@ -104,6 +108,14 @@ contract TestLongTailEconomics is TestBaseWorkflow {
104
108
  trustedForwarder: TRUSTED_FORWARDER
105
109
  });
106
110
 
111
+ REV_OWNER = new REVOwner(
112
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
113
+ jbDirectory(),
114
+ FEE_PROJECT_ID,
115
+ SUCKER_REGISTRY,
116
+ address(LOANS_CONTRACT)
117
+ );
118
+
107
119
  REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
108
120
  jbController(),
109
121
  SUCKER_REGISTRY,
@@ -112,9 +124,12 @@ contract TestLongTailEconomics is TestBaseWorkflow {
112
124
  PUBLISHER,
113
125
  IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
114
126
  address(LOANS_CONTRACT),
115
- TRUSTED_FORWARDER
127
+ TRUSTED_FORWARDER,
128
+ address(REV_OWNER)
116
129
  );
117
130
 
131
+ REV_OWNER.initialize(IREVDeployer(address(REV_DEPLOYER)));
132
+
118
133
  vm.prank(multisig());
119
134
  jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
120
135
 
@@ -40,6 +40,8 @@ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStor
40
40
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
41
41
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
42
42
  import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
43
+ import {REVOwner} from "../src/REVOwner.sol";
44
+ import {IREVDeployer} from "../src/interfaces/IREVDeployer.sol";
43
45
 
44
46
  struct FeeProjectConfig {
45
47
  REVConfig configuration;
@@ -56,6 +58,8 @@ contract TestLowFindings is TestBaseWorkflow {
56
58
  // forge-lint: disable-next-line(mixed-case-variable)
57
59
  REVDeployer REV_DEPLOYER;
58
60
  // forge-lint: disable-next-line(mixed-case-variable)
61
+ REVOwner REV_OWNER;
62
+ // forge-lint: disable-next-line(mixed-case-variable)
59
63
  JB721TiersHook EXAMPLE_HOOK;
60
64
  // forge-lint: disable-next-line(mixed-case-variable)
61
65
  IJB721TiersHookDeployer HOOK_DEPLOYER;
@@ -293,6 +297,14 @@ contract TestLowFindings is TestBaseWorkflow {
293
297
  trustedForwarder: TRUSTED_FORWARDER
294
298
  });
295
299
 
300
+ REV_OWNER = new REVOwner(
301
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
302
+ jbDirectory(),
303
+ FEE_PROJECT_ID,
304
+ SUCKER_REGISTRY,
305
+ address(LOANS_CONTRACT)
306
+ );
307
+
296
308
  REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
297
309
  jbController(),
298
310
  SUCKER_REGISTRY,
@@ -301,9 +313,12 @@ contract TestLowFindings is TestBaseWorkflow {
301
313
  PUBLISHER,
302
314
  IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
303
315
  address(LOANS_CONTRACT),
304
- TRUSTED_FORWARDER
316
+ TRUSTED_FORWARDER,
317
+ address(REV_OWNER)
305
318
  );
306
319
 
320
+ REV_OWNER.initialize(IREVDeployer(address(REV_DEPLOYER)));
321
+
307
322
  // Deploy fee project.
308
323
  vm.prank(multisig());
309
324
  jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
@@ -38,6 +38,8 @@ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStor
38
38
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
39
39
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
40
40
  import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
41
+ import {REVOwner} from "../src/REVOwner.sol";
42
+ import {IREVDeployer} from "../src/interfaces/IREVDeployer.sol";
41
43
 
42
44
  /// @notice Tests for PR #32: liquidation boundary, reallocate msg.value, and decimal normalization fixes.
43
45
  contract TestMixedFixes is TestBaseWorkflow {
@@ -47,6 +49,8 @@ contract TestMixedFixes is TestBaseWorkflow {
47
49
  // forge-lint: disable-next-line(mixed-case-variable)
48
50
  REVDeployer REV_DEPLOYER;
49
51
  // forge-lint: disable-next-line(mixed-case-variable)
52
+ REVOwner REV_OWNER;
53
+ // forge-lint: disable-next-line(mixed-case-variable)
50
54
  JB721TiersHook EXAMPLE_HOOK;
51
55
  // forge-lint: disable-next-line(mixed-case-variable)
52
56
  IJB721TiersHookDeployer HOOK_DEPLOYER;
@@ -100,6 +104,14 @@ contract TestMixedFixes is TestBaseWorkflow {
100
104
  permit2: permit2(),
101
105
  trustedForwarder: TRUSTED_FORWARDER
102
106
  });
107
+ REV_OWNER = new REVOwner(
108
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
109
+ jbDirectory(),
110
+ FEE_PROJECT_ID,
111
+ SUCKER_REGISTRY,
112
+ address(LOANS_CONTRACT)
113
+ );
114
+
103
115
  REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
104
116
  jbController(),
105
117
  SUCKER_REGISTRY,
@@ -108,8 +120,11 @@ contract TestMixedFixes is TestBaseWorkflow {
108
120
  PUBLISHER,
109
121
  IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
110
122
  address(LOANS_CONTRACT),
111
- TRUSTED_FORWARDER
123
+ TRUSTED_FORWARDER,
124
+ address(REV_OWNER)
112
125
  );
126
+
127
+ REV_OWNER.initialize(IREVDeployer(address(REV_DEPLOYER)));
113
128
  vm.prank(multisig());
114
129
  jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
115
130
  _deployFeeProject();
@@ -45,6 +45,8 @@ import {IAllowanceTransfer} from "@uniswap/permit2/src/interfaces/IAllowanceTran
45
45
  import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
46
46
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
47
47
  import {JBFees} from "@bananapus/core-v6/src/libraries/JBFees.sol";
48
+ import {REVOwner} from "../src/REVOwner.sol";
49
+ import {IREVDeployer} from "../src/interfaces/IREVDeployer.sol";
48
50
 
49
51
  struct Permit2ProjectConfig {
50
52
  REVConfig configuration;
@@ -64,6 +66,8 @@ contract TestPermit2Signatures is TestBaseWorkflow {
64
66
  // forge-lint: disable-next-line(mixed-case-variable)
65
67
  REVDeployer REV_DEPLOYER;
66
68
  // forge-lint: disable-next-line(mixed-case-variable)
69
+ REVOwner REV_OWNER;
70
+ // forge-lint: disable-next-line(mixed-case-variable)
67
71
  JB721TiersHook EXAMPLE_HOOK;
68
72
  // forge-lint: disable-next-line(mixed-case-variable)
69
73
  IJB721TiersHookDeployer HOOK_DEPLOYER;
@@ -261,6 +265,14 @@ contract TestPermit2Signatures is TestBaseWorkflow {
261
265
  trustedForwarder: TRUSTED_FORWARDER
262
266
  });
263
267
 
268
+ REV_OWNER = new REVOwner(
269
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
270
+ jbDirectory(),
271
+ FEE_PROJECT_ID,
272
+ SUCKER_REGISTRY,
273
+ address(LOANS_CONTRACT)
274
+ );
275
+
264
276
  REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
265
277
  jbController(),
266
278
  SUCKER_REGISTRY,
@@ -269,9 +281,12 @@ contract TestPermit2Signatures is TestBaseWorkflow {
269
281
  PUBLISHER,
270
282
  IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
271
283
  address(LOANS_CONTRACT),
272
- TRUSTED_FORWARDER
284
+ TRUSTED_FORWARDER,
285
+ address(REV_OWNER)
273
286
  );
274
287
 
288
+ REV_OWNER.initialize(IREVDeployer(address(REV_DEPLOYER)));
289
+
275
290
  // Approve the basic deployer to configure the project.
276
291
  vm.prank(address(multisig()));
277
292
  jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);