@rev-net/core-v6 0.0.17 → 0.0.18

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/ARCHITECTURE.md CHANGED
@@ -76,6 +76,7 @@ Cash Out → REVDeployer.beforeCashOutRecordedWith()
76
76
  ### Loan Flow
77
77
  ```
78
78
  Borrower → REVLoans.borrowFrom()
79
+ → Enforce cash-out delay if set (cross-chain deployment protection)
79
80
  → Burn borrower's revnet tokens as collateral
80
81
  → Calculate borrow amount from bonding curve value of collateral
81
82
  → Pull funds from treasury via USE_ALLOWANCE
@@ -93,6 +93,7 @@ User cashes out via terminal
93
93
  ```
94
94
  Borrower calls REVLoans.borrowFrom()
95
95
  -> Prerequisite: caller must have granted BURN_TOKENS permission to REVLoans via JBPermissions
96
+ -> Enforce cash-out delay: resolve deployer from ruleset dataHook, check cashOutDelayOf(revnetId)
96
97
  -> Validate: collateral > 0, terminal registered, prepaidFeePercent in range
97
98
  -> Generate loan ID: revnetId * 1T + loanNumber
98
99
  -> Create loan in storage
@@ -332,6 +333,7 @@ No prior formal audit with finding IDs has been conducted on this codebase. All
332
333
  | `REVDeployer_StagesRequired` | REVDeployer | `deployFor` / `launchChainsFor` called with empty `stageConfigurations` array |
333
334
  | `REVDeployer_StageTimesMustIncrease` | REVDeployer | Stage `startsAtOrAfter` timestamps are not strictly increasing |
334
335
  | `REVDeployer_Unauthorized` | REVDeployer | Caller is not the split operator (for operator-gated functions) or not the project owner (for `launchChainsFor`) |
336
+ | `REVLoans_CashOutDelayNotFinished` | REVLoans | `borrowFrom` called during the 30-day cash-out delay period (cross-chain deployment protection) |
335
337
  | `REVLoans_CollateralExceedsLoan` | REVLoans | `reallocateCollateralFromLoan` called with `collateralCountToReturn > loan.collateral` |
336
338
  | `REVLoans_InvalidPrepaidFeePercent` | REVLoans | `prepaidFeePercent` outside `[MIN_PREPAID_FEE_PERCENT, MAX_PREPAID_FEE_PERCENT]` range (25-500) |
337
339
  | `REVLoans_InvalidTerminal` | REVLoans | Loan source references a terminal not registered in `JBDirectory` for the revnet |
package/CHANGE_LOG.md CHANGED
@@ -108,6 +108,7 @@ The boolean semantics are **inverted**: v5 used opt-in flags (`splitOperatorCan*
108
108
  | `REVLoans` | `REVLoans_NothingToRepay()` |
109
109
  | `REVLoans` | `REVLoans_ZeroBorrowAmount()` |
110
110
  | `REVLoans` | `REVLoans_SourceMismatch()` |
111
+ | `REVLoans` | `REVLoans_CashOutDelayNotFinished(uint256 cashOutDelay, uint256 blockTimestamp)` |
111
112
  | `REVLoans` | `REVLoans_LoanIdOverflow()` |
112
113
 
113
114
  ### 2.4 New Constants
@@ -129,6 +130,13 @@ The boolean semantics are **inverted**: v5 used opt-in flags (`splitOperatorCan*
129
130
 
130
131
  ## 3. Event Changes
131
132
 
133
+ ### 3.0 Indexer Notes
134
+
135
+ For revnet-focused subgraphs:
136
+ - both deployment flows now correlate to `deployFor` rather than a split `deployFor`/`deployWith721sFor` model;
137
+ - revnet deployment entities should expect an associated 721 hook by default;
138
+ - any entity that previously depended on caller-supplied buyback-hook config should be updated for the v6 auto-configured buyback path.
139
+
132
140
  ### 3.1 Added Events
133
141
 
134
142
  See section 2.2 above.
@@ -289,6 +297,7 @@ The following structs are identical between v5 and v6 (only `forge-lint` comment
289
297
  | **Source fee try-catch hardening** | The source fee payment in `_adjust` is now wrapped in a try-catch block. If the source terminal's `pay` call reverts, the ERC-20 allowance is reclaimed and the fee amount is returned to the beneficiary instead of blocking the entire loan operation. v5 called `terminal.pay` directly without error handling. |
290
298
  | **Timestamp cast fix** | `borrowFrom` now casts `block.timestamp` to `uint48` when setting `loan.createdAt`, matching the `REVLoan.createdAt` field width. v5 used `uint40`, which would silently truncate timestamps after the year 36812. |
291
299
  | **`ReallocateCollateral` event typo fix** | v5 used `removedcollateralCount` (lowercase 'c'). v6 fixes it to `removedCollateralCount` (uppercase 'C'). |
300
+ | **Cash out delay enforced in loans** | `borrowFrom` now resolves the `REVDeployer` from the ruleset's `dataHook` and checks `cashOutDelayOf(revnetId)`. If the 30-day cross-chain deployment delay hasn't passed, `borrowFrom` reverts with `REVLoans_CashOutDelayNotFinished`. `borrowableAmountFrom` returns 0 during the delay so UIs reflect the restriction. v5 did not enforce the cash out delay in the loans contract. |
292
301
  | **NatSpec documentation** | Extensive NatSpec added to all functions, views, and internal helpers. Flash loan safety analysis documented in `_borrowableAmountFrom`. |
293
302
 
294
303
  ### 6.3 Named Arguments
package/RISKS.md CHANGED
@@ -81,6 +81,10 @@ Read [ARCHITECTURE.md](./ARCHITECTURE.md) and [SKILLS.md](./SKILLS.md) for proto
81
81
  - **Source mismatch check.** `reallocateCollateralFromLoan` enforces that the new loan's source matches the existing loan's source (`source.token == existingSource.token && source.terminal == existingSource.terminal`). This prevents cross-source value extraction.
82
82
  - **MEV opportunity at stage boundaries.** If a borrower knows a stage transition will decrease `cashOutTaxRate`, they can wait until just after the transition and `reallocateCollateralFromLoan` to extract more borrowed funds from the same collateral. This is predictable and not preventable by design.
83
83
 
84
+ ### Cross-chain cash-out delay enforcement
85
+
86
+ - **Loans enforce the same 30-day cash-out delay as direct cash outs.** When a revnet is deployed to a new chain where its first stage has already started, `REVDeployer._setCashOutDelayIfNeeded()` sets a 30-day delay. `borrowFrom` resolves the deployer from the current ruleset's `dataHook` and checks `cashOutDelayOf(revnetId)`, reverting with `REVLoans_CashOutDelayNotFinished` if the delay hasn't passed. `borrowableAmountFrom` returns 0 during the delay. This prevents cross-chain arbitrage via the loan system (bridging tokens to a new chain and immediately borrowing against them before prices equilibrate).
87
+
84
88
  ### BURN_TOKENS permission prerequisite
85
89
 
86
90
  - **Borrowers must grant BURN_TOKENS permission before calling `borrowFrom`.** The loans contract burns the caller's tokens as collateral via `JBController.burnTokensOf`, which requires the caller to have granted `BURN_TOKENS` permission to the loans contract for the revnet's project ID. Without this, the transaction reverts deep in `JBController` with `JBPermissioned_Unauthorized`. The prerequisite is documented in `borrowFrom`'s NatSpec.
package/SKILLS.md CHANGED
@@ -47,7 +47,7 @@ Deploy and manage Revnets -- autonomous, unowned Juicebox projects with staged i
47
47
 
48
48
  | Function | Permissions | What it does |
49
49
  |----------|------------|-------------|
50
- | `REVLoans.borrowFrom(revnetId, source, minBorrowAmount, collateralCount, beneficiary, prepaidFeePercent)` | Permissionless (caller must grant BURN_TOKENS to REVLoans) | Open a loan: burn collateral tokens, pull funds from revnet via `useAllowanceOf`, pay REV fee (1%) + terminal fee (2.5%), transfer remainder to beneficiary, mint loan NFT. |
50
+ | `REVLoans.borrowFrom(revnetId, source, minBorrowAmount, collateralCount, beneficiary, prepaidFeePercent)` | Permissionless (caller must grant BURN_TOKENS to REVLoans) | Open a loan: enforce cash-out delay if set (cross-chain deployment protection), burn collateral tokens, pull funds from revnet via `useAllowanceOf`, pay REV fee (1%) + terminal fee (2.5%), transfer remainder to beneficiary, mint loan NFT. |
51
51
  | `REVLoans.repayLoan(loanId, maxRepayBorrowAmount, collateralCountToReturn, beneficiary, allowance)` | Loan NFT owner | Repay fully or partially. Returns funds to revnet via `addToBalanceOf`, re-mints collateral tokens, burns/replaces the loan NFT. Supports permit2 signatures. |
52
52
  | `REVLoans.reallocateCollateralFromLoan(loanId, collateralCountToTransfer, source, minBorrowAmount, collateralCountToAdd, beneficiary, prepaidFeePercent)` | Loan NFT owner | Refinance: remove excess collateral from an existing loan and open a new loan with the freed collateral. Burns original, mints two replacements. |
53
53
  | `REVLoans.liquidateExpiredLoansFrom(revnetId, startingLoanId, count)` | Permissionless | Clean up loans past the 10-year liquidation duration. Burns NFTs and decrements accounting totals. Collateral is permanently lost. |
@@ -57,7 +57,7 @@ Deploy and manage Revnets -- autonomous, unowned Juicebox projects with staged i
57
57
 
58
58
  | Function | What it does |
59
59
  |----------|-------------|
60
- | `REVLoans.borrowableAmountFrom(revnetId, collateralCount, decimals, currency)` | Calculate how much can be borrowed for a given collateral amount. Aggregates surplus from all terminals, applies bonding curve. |
60
+ | `REVLoans.borrowableAmountFrom(revnetId, collateralCount, decimals, currency)` | Calculate how much can be borrowed for a given collateral amount. Returns 0 during the cash-out delay period. Aggregates surplus from all terminals, applies bonding curve. |
61
61
  | `REVLoans.determineSourceFeeAmount(loan, amount)` | Calculate the time-proportional source fee for a loan repayment. Zero during prepaid window, linear accrual after. |
62
62
  | `REVLoans.loanOf(loanId)` | Returns the full `REVLoan` struct for a loan. |
63
63
  | `REVLoans.loanSourcesOf(revnetId)` | Returns all `(terminal, token)` pairs used for loans by a revnet. |
@@ -139,6 +139,7 @@ Deploy and manage Revnets -- autonomous, unowned Juicebox projects with staged i
139
139
 
140
140
  | Error | When It Fires |
141
141
  |-------|---------------|
142
+ | `REVLoans_CashOutDelayNotFinished(cashOutDelay, blockTimestamp)` | When borrowing during the 30-day cash-out delay period (cross-chain deployment protection). |
142
143
  | `REVLoans_CollateralExceedsLoan(collateralToReturn, loanCollateral)` | When trying to return more collateral than the loan holds. |
143
144
  | `REVLoans_InvalidPrepaidFeePercent(prepaidFeePercent, min, max)` | When `prepaidFeePercent` is outside the allowed range (2.5%--50%). |
144
145
  | `REVLoans_InvalidTerminal(terminal, revnetId)` | When the specified terminal is not registered for the revnet. |
@@ -212,8 +213,8 @@ Deploy and manage Revnets -- autonomous, unowned Juicebox projects with staged i
212
213
  4. **Loan ID encoding.** `loanId = revnetId * 1_000_000_000_000 + loanNumber`. Each revnet supports ~1 trillion loans. Use `revnetIdOfLoanWith(loanId)` to decode.
213
214
  5. **uint112 truncation risk.** `REVLoan.amount` and `REVLoan.collateral` are `uint112`. Values above ~5.19e33 truncate silently.
214
215
  6. **Auto-issuance stage IDs.** Computed as `block.timestamp + i` during deployment. These match the Juicebox ruleset IDs because `JBRulesets` assigns IDs the same way (`latestId >= block.timestamp ? latestId + 1 : block.timestamp`), producing identical sequential IDs when all stages are queued in a single `deployFor()` call.
215
- 7. **Cash-out fee stacking.** Cash outs incur both the Juicebox terminal fee (2.5%) and the revnet cash-out fee (2.5% to fee revnet). These compound.
216
- 8. **30-day cash-out delay.** Applied when deploying an existing revnet to a new chain where the first stage has already started. Prevents cross-chain liquidity arbitrage.
216
+ 7. **Cash-out fee stacking.** Cash outs incur both the Juicebox terminal fee (2.5%) and the revnet cash-out fee (2.5% to fee revnet). These compound. The 2.5% fee is deducted from the TOKEN AMOUNT being cashed out, not from the reclaim value. 2.5% of the tokens are redirected to the fee revnet, which then redeems them at the bonding curve independently. The net reclaim to the caller is based on 97.5% of the tokens, not 97.5% of the computed ETH value. This is by design.
217
+ 8. **30-day cash-out delay.** Applied when deploying an existing revnet to a new chain where the first stage has already started. Prevents cross-chain liquidity arbitrage. Enforced in both `beforeCashOutRecordedWith` (direct cash outs) and `REVLoans.borrowFrom` / `borrowableAmountFrom` (loans). The delay is resolved via the ruleset's `dataHook` (the REVDeployer) and `cashOutDelayOf(revnetId)`.
217
218
  9. **`cashOutTaxRate` cannot be MAX.** Must be strictly less than `MAX_CASH_OUT_TAX_RATE` (10,000). Revnets cannot fully disable cash outs.
218
219
  10. **Split operator is singular.** Only ONE address can be split operator at a time. The operator can replace itself via `setSplitOperatorOf` but cannot delegate or multi-sig.
219
220
  11. **NATIVE_TOKEN on non-ETH chains.** `JBConstants.NATIVE_TOKEN` on Celo means CELO, on Polygon means MATIC -- not ETH. Use ERC-20 WETH instead. The config matching hash does NOT catch terminal configuration differences.
package/USER_JOURNEYS.md CHANGED
@@ -163,6 +163,7 @@ This is a standard Juicebox payment, but REVDeployer intervenes as the data hook
163
163
  **Prerequisites:**
164
164
  - Caller must hold `collateralCount` revnet ERC-20 tokens
165
165
  - Caller must grant `BURN_TOKENS` permission to the REVLoans contract for the revnet's project ID via `JBPermissions.setPermissionsFor()`. Without this, the transaction reverts in `JBController.burnTokensOf` with `JBPermissioned_Unauthorized`.
166
+ - The revnet's cash-out delay must have passed (if one was set during cross-chain deployment). `borrowableAmountFrom` returns 0 and `borrowFrom` reverts with `REVLoans_CashOutDelayNotFinished` until the 30-day delay expires.
166
167
 
167
168
  **Key parameters:**
168
169
 
@@ -181,6 +182,7 @@ This is a standard Juicebox payment, but REVDeployer intervenes as the data hook
181
182
  - `collateralCount > 0` (no zero-collateral loans)
182
183
  - `source.terminal` is registered for the revnet in the directory
183
184
  - `prepaidFeePercent` in range [25, 500]
185
+ - Cash-out delay has passed: resolves the `REVDeployer` from the current ruleset's `dataHook`, checks `cashOutDelayOf(revnetId)`. Reverts with `REVLoans_CashOutDelayNotFinished(cashOutDelay, block.timestamp)` if `cashOutDelay > block.timestamp`.
184
186
  2. **Loan ID generation:** `revnetId * 1_000_000_000_000 + (++totalLoansBorrowedFor[revnetId])`
185
187
  3. **Loan creation in storage:**
186
188
  - `source`, `createdAt = block.timestamp`, `prepaidFeePercent`, `prepaidDuration = mulDiv(prepaidFeePercent, 3650 days, 500)`
@@ -210,6 +212,7 @@ This is a standard Juicebox payment, but REVDeployer intervenes as the data hook
210
212
  - `prepaidDuration` at minimum (25): `25 * 3650 days / 500 = 182.5 days`. At maximum (500): `500 * 3650 days / 500 = 3650 days`.
211
213
  - Both the REV fee payment and the source fee payment failures are non-fatal. If either `feeTerminal.pay` or `source.terminal.pay` reverts, the fee amount is transferred to the beneficiary instead.
212
214
  - Loan NFT is minted to `_msgSender()`, not `beneficiary`. The caller owns the loan; the beneficiary receives the funds.
215
+ - When a revnet deploys to a new chain with `startsAtOrAfter` in the past, `REVDeployer` sets a 30-day cash-out delay. Both `borrowFrom` and `borrowableAmountFrom` enforce this delay by resolving the deployer from the current ruleset's `dataHook` and checking `cashOutDelayOf`. This prevents cross-chain arbitrage via loans during the delay window.
213
216
 
214
217
  ---
215
218
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rev-net/core-v6",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,14 +19,14 @@
19
19
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'revnet-core-v6'"
20
20
  },
21
21
  "dependencies": {
22
- "@bananapus/721-hook-v6": "^0.0.21",
23
- "@bananapus/buyback-hook-v6": "^0.0.21",
24
- "@bananapus/core-v6": "^0.0.27",
25
- "@bananapus/ownable-v6": "^0.0.14",
22
+ "@bananapus/721-hook-v6": "^0.0.22",
23
+ "@bananapus/buyback-hook-v6": "^0.0.22",
24
+ "@bananapus/core-v6": "^0.0.28",
25
+ "@bananapus/ownable-v6": "^0.0.15",
26
26
  "@bananapus/permission-ids-v6": "^0.0.14",
27
- "@bananapus/router-terminal-v6": "^0.0.20",
28
- "@bananapus/suckers-v6": "^0.0.17",
29
- "@croptop/core-v6": "^0.0.22",
27
+ "@bananapus/router-terminal-v6": "^0.0.21",
28
+ "@bananapus/suckers-v6": "^0.0.18",
29
+ "@croptop/core-v6": "^0.0.23",
30
30
  "@openzeppelin/contracts": "^5.6.1",
31
31
  "@uniswap/v4-core": "^1.0.2",
32
32
  "@uniswap/v4-periphery": "^1.0.3"
@@ -323,7 +323,6 @@ contract DeployScript is Script, Sphinx {
323
323
  tiersConfig: JB721InitTiersConfig({
324
324
  tiers: new JB721TierConfig[](0), currency: ETH_CURRENCY, decimals: 18
325
325
  }),
326
- reserveBeneficiary: address(0),
327
326
  flags: REV721TiersHookFlags({
328
327
  noNewTiersWithReserves: false,
329
328
  noNewTiersWithVotes: false,
@@ -459,21 +458,25 @@ contract DeployScript is Script, Sphinx {
459
458
  trustedForwarder: TRUSTED_FORWARDER
460
459
  });
461
460
 
462
- // Approve the basic deployer to configure the project.
463
- core.projects.approve({to: address(_basicDeployer), tokenId: FEE_PROJECT_ID});
464
-
465
- // Build the config.
466
- FeeProjectConfig memory feeProjectConfig = getFeeProjectConfig();
467
-
468
- // Configure the project.
469
- _basicDeployer.deployFor({
470
- revnetId: FEE_PROJECT_ID,
471
- configuration: feeProjectConfig.configuration,
472
- terminalConfigurations: feeProjectConfig.terminalConfigurations,
473
- suckerDeploymentConfiguration: feeProjectConfig.suckerDeploymentConfiguration,
474
- tiered721HookConfiguration: feeProjectConfig.tiered721HookConfiguration,
475
- allowedPosts: feeProjectConfig.allowedPosts
476
- });
461
+ // Only configure the fee project if singletons were freshly deployed. Re-running `deployFor` on an
462
+ // already-configured project would fail because the project is no longer blank.
463
+ if (!_singletonsExist) {
464
+ // Approve the basic deployer to configure the project.
465
+ core.projects.approve({to: address(_basicDeployer), tokenId: FEE_PROJECT_ID});
466
+
467
+ // Build the config.
468
+ FeeProjectConfig memory feeProjectConfig = getFeeProjectConfig();
469
+
470
+ // Configure the project.
471
+ _basicDeployer.deployFor({
472
+ revnetId: FEE_PROJECT_ID,
473
+ configuration: feeProjectConfig.configuration,
474
+ terminalConfigurations: feeProjectConfig.terminalConfigurations,
475
+ suckerDeploymentConfiguration: feeProjectConfig.suckerDeploymentConfiguration,
476
+ tiered721HookConfiguration: feeProjectConfig.tiered721HookConfiguration,
477
+ allowedPosts: feeProjectConfig.allowedPosts
478
+ });
479
+ }
477
480
  }
478
481
 
479
482
  /// @notice Check whether a contract has already been deployed at its deterministic address.
@@ -282,6 +282,9 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
282
282
  }
283
283
 
284
284
  // Split the cashed-out tokens into a fee portion and a non-fee portion.
285
+ // The fee is applied to TOKEN COUNT (2.5% of tokens), not to value. The fee revnet receives the bonding-curve
286
+ // reclaim of its 2.5% token share regardless of whether the remaining 97.5% routes through a buyback pool at
287
+ // a better price. This is by design.
285
288
  // Micro cash outs (< 40 wei at 2.5% fee) round feeCashOutCount to zero, bypassing the fee.
286
289
  // Economically insignificant: the gas cost of the transaction far exceeds the bypassed fee. No fix needed.
287
290
  uint256 feeCashOutCount = mulDiv({x: context.cashOutCount, y: FEE, denominator: JBConstants.MAX_FEE});
@@ -608,6 +611,7 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
608
611
  sqrtPriceX96 = uint160(1 << 96);
609
612
  } else {
610
613
  address normalizedTerminalToken = terminalToken == JBConstants.NATIVE_TOKEN ? address(0) : terminalToken;
614
+ // slither-disable-next-line calls-loop
611
615
  address projectToken = address(CONTROLLER.TOKENS().tokenOf(revnetId));
612
616
 
613
617
  if (projectToken == address(0) || projectToken == normalizedTerminalToken) {
@@ -955,7 +959,6 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
955
959
  tokenUriResolver: tiered721HookConfiguration.baseline721HookConfiguration.tokenUriResolver,
956
960
  contractUri: tiered721HookConfiguration.baseline721HookConfiguration.contractUri,
957
961
  tiersConfig: tiered721HookConfiguration.baseline721HookConfiguration.tiersConfig,
958
- reserveBeneficiary: tiered721HookConfiguration.baseline721HookConfiguration.reserveBeneficiary,
959
962
  flags: JB721TiersHookFlags({
960
963
  noNewTiersWithReserves: tiered721HookConfiguration.baseline721HookConfiguration.flags
961
964
  .noNewTiersWithReserves,
package/src/REVLoans.sol CHANGED
@@ -27,6 +27,7 @@ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingCo
27
27
  import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
28
28
  import {JBSingleAllowance} from "@bananapus/core-v6/src/structs/JBSingleAllowance.sol";
29
29
 
30
+ import {IREVDeployer} from "./interfaces/IREVDeployer.sol";
30
31
  import {IREVLoans} from "./interfaces/IREVLoans.sol";
31
32
  import {REVLoan} from "./structs/REVLoan.sol";
32
33
  import {REVLoanSource} from "./structs/REVLoanSource.sol";
@@ -55,6 +56,7 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
55
56
  // --------------------------- custom errors ------------------------- //
56
57
  //*********************************************************************//
57
58
 
59
+ error REVLoans_CashOutDelayNotFinished(uint256 cashOutDelay, uint256 blockTimestamp);
58
60
  error REVLoans_CollateralExceedsLoan(uint256 collateralToReturn, uint256 loanCollateral);
59
61
  error REVLoans_InvalidPrepaidFeePercent(uint256 prepaidFeePercent, uint256 min, uint256 max);
60
62
  error REVLoans_InvalidTerminal(address terminal, uint256 revnetId);
@@ -225,6 +227,22 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
225
227
  view
226
228
  returns (uint256)
227
229
  {
230
+ // Get the current ruleset to resolve the deployer from its data hook.
231
+ // slither-disable-next-line unused-return
232
+ (JBRuleset memory currentRuleset,) = CONTROLLER.currentRulesetOf(revnetId);
233
+
234
+ // The ruleset's data hook is the REVDeployer that configured this revnet.
235
+ address deployer = currentRuleset.dataHook();
236
+
237
+ // Only check the delay if a deployer is set.
238
+ if (deployer != address(0)) {
239
+ // Get the timestamp after which cash outs (and loans) are allowed.
240
+ uint256 cashOutDelay = IREVDeployer(deployer).cashOutDelayOf(revnetId);
241
+
242
+ // If the delay hasn't passed yet, no amount is borrowable.
243
+ if (cashOutDelay > block.timestamp) return 0;
244
+ }
245
+
228
246
  return _borrowableAmountFrom({
229
247
  revnetId: revnetId,
230
248
  collateralCount: collateralCount,
@@ -580,6 +598,24 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
580
598
  );
581
599
  }
582
600
 
601
+ // Get the current ruleset to resolve the deployer from its data hook.
602
+ // slither-disable-next-line unused-return
603
+ (JBRuleset memory currentRuleset,) = CONTROLLER.currentRulesetOf(revnetId);
604
+
605
+ // The ruleset's data hook is the REVDeployer that configured this revnet.
606
+ address deployer = currentRuleset.dataHook();
607
+
608
+ // Only check the delay if a deployer is set.
609
+ if (deployer != address(0)) {
610
+ // Get the timestamp after which cash outs (and loans) are allowed.
611
+ uint256 cashOutDelay = IREVDeployer(deployer).cashOutDelayOf(revnetId);
612
+
613
+ // Revert if the delay hasn't passed yet.
614
+ if (cashOutDelay > block.timestamp) {
615
+ revert REVLoans_CashOutDelayNotFinished(cashOutDelay, block.timestamp);
616
+ }
617
+ }
618
+
583
619
  // Prevent the loan number from exceeding the ID namespace for this revnet.
584
620
  if (totalLoansBorrowedFor[revnetId] >= _ONE_TRILLION) revert REVLoans_LoanIdOverflow();
585
621
 
@@ -12,7 +12,6 @@ import {REV721TiersHookFlags} from "./REV721TiersHookFlags.sol";
12
12
  /// @custom:member tokenUriResolver The token URI resolver for the NFT collection.
13
13
  /// @custom:member contractUri The contract URI for the NFT collection.
14
14
  /// @custom:member tiersConfig The tier configuration for the NFT collection.
15
- /// @custom:member reserveBeneficiary The default reserve beneficiary for the NFT collection.
16
15
  /// @custom:member flags A set of flags that configure the 721 hook. Omits `issueTokensForSplits` since revnets
17
16
  /// always force it to `false`.
18
17
  // forge-lint: disable-next-line(pascal-case-struct)
@@ -23,6 +22,5 @@ struct REVBaseline721HookConfig {
23
22
  IJB721TokenUriResolver tokenUriResolver;
24
23
  string contractUri;
25
24
  JB721InitTiersConfig tiersConfig;
26
- address reserveBeneficiary;
27
25
  REV721TiersHookFlags flags;
28
26
  }
@@ -0,0 +1,467 @@
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
+
41
+ struct FeeProjectConfig {
42
+ REVConfig configuration;
43
+ JBTerminalConfig[] terminalConfigurations;
44
+ REVSuckerDeploymentConfig suckerDeploymentConfiguration;
45
+ }
46
+
47
+ /// @notice Tests that REVLoans enforces the cash out delay set by REVDeployer for cross-chain deployments.
48
+ contract TestLoansCashOutDelay is TestBaseWorkflow {
49
+ // forge-lint: disable-next-line(mixed-case-variable)
50
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
51
+ // forge-lint: disable-next-line(mixed-case-variable)
52
+ bytes32 ERC20_SALT = "REV_TOKEN";
53
+
54
+ // forge-lint: disable-next-line(mixed-case-variable)
55
+ REVDeployer REV_DEPLOYER;
56
+ // forge-lint: disable-next-line(mixed-case-variable)
57
+ JB721TiersHook EXAMPLE_HOOK;
58
+ // forge-lint: disable-next-line(mixed-case-variable)
59
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
60
+ // forge-lint: disable-next-line(mixed-case-variable)
61
+ IJB721TiersHookStore HOOK_STORE;
62
+ // forge-lint: disable-next-line(mixed-case-variable)
63
+ IJBAddressRegistry ADDRESS_REGISTRY;
64
+ // forge-lint: disable-next-line(mixed-case-variable)
65
+ IREVLoans LOANS_CONTRACT;
66
+ // forge-lint: disable-next-line(mixed-case-variable)
67
+ IJBSuckerRegistry SUCKER_REGISTRY;
68
+ // forge-lint: disable-next-line(mixed-case-variable)
69
+ CTPublisher PUBLISHER;
70
+ // forge-lint: disable-next-line(mixed-case-variable)
71
+ MockBuybackDataHook MOCK_BUYBACK;
72
+
73
+ // forge-lint: disable-next-line(mixed-case-variable)
74
+ uint256 FEE_PROJECT_ID;
75
+
76
+ /// @notice Revnet deployed with startsAtOrAfter in the past (triggers cash out delay).
77
+ // forge-lint: disable-next-line(mixed-case-variable)
78
+ uint256 DELAYED_REVNET_ID;
79
+
80
+ /// @notice Revnet deployed with startsAtOrAfter == block.timestamp (no delay).
81
+ // forge-lint: disable-next-line(mixed-case-variable)
82
+ uint256 NORMAL_REVNET_ID;
83
+
84
+ // forge-lint: disable-next-line(mixed-case-variable)
85
+ address USER = makeAddr("user");
86
+
87
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
88
+
89
+ function getFeeProjectConfig() internal view returns (FeeProjectConfig memory) {
90
+ JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
91
+ accountingContextsToAccept[0] = JBAccountingContext({
92
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
93
+ });
94
+
95
+ JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
96
+ terminalConfigurations[0] =
97
+ JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
98
+
99
+ REVStageConfig[] memory stageConfigurations = new REVStageConfig[](1);
100
+ JBSplit[] memory splits = new JBSplit[](1);
101
+ splits[0].beneficiary = payable(multisig());
102
+ splits[0].percent = 10_000;
103
+
104
+ stageConfigurations[0] = REVStageConfig({
105
+ startsAtOrAfter: uint40(block.timestamp),
106
+ autoIssuances: new REVAutoIssuance[](0),
107
+ splitPercent: 2000,
108
+ splits: splits,
109
+ // forge-lint: disable-next-line(unsafe-typecast)
110
+ initialIssuance: uint112(1000e18),
111
+ issuanceCutFrequency: 90 days,
112
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
113
+ cashOutTaxRate: 6000,
114
+ extraMetadata: 0
115
+ });
116
+
117
+ return FeeProjectConfig({
118
+ configuration: REVConfig({
119
+ // forge-lint: disable-next-line(named-struct-fields)
120
+ description: REVDescription("Revnet", "$REV", "ipfs://fee", ERC20_SALT),
121
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
122
+ splitOperator: multisig(),
123
+ stageConfigurations: stageConfigurations
124
+ }),
125
+ terminalConfigurations: terminalConfigurations,
126
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
127
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("REV"))
128
+ })
129
+ });
130
+ }
131
+
132
+ /// @notice Returns a revnet config. When `pastStart` is true, `startsAtOrAfter` is set to 1 second ago,
133
+ /// triggering the 30-day cash out delay in REVDeployer._setCashOutDelayIfNeeded.
134
+ function _getRevnetConfig(
135
+ bool pastStart,
136
+ string memory name,
137
+ string memory symbol,
138
+ bytes32 salt
139
+ )
140
+ internal
141
+ view
142
+ returns (FeeProjectConfig memory)
143
+ {
144
+ JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
145
+ accountingContextsToAccept[0] = JBAccountingContext({
146
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
147
+ });
148
+
149
+ JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
150
+ terminalConfigurations[0] =
151
+ JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
152
+
153
+ REVStageConfig[] memory stageConfigurations = new REVStageConfig[](1);
154
+ JBSplit[] memory splits = new JBSplit[](1);
155
+ splits[0].beneficiary = payable(multisig());
156
+ splits[0].percent = 10_000;
157
+
158
+ // If pastStart, set startsAtOrAfter to 1 second ago — simulates cross-chain deployment
159
+ // where the stage is already active on another chain.
160
+ uint40 startsAt = pastStart ? uint40(block.timestamp - 1) : uint40(block.timestamp);
161
+
162
+ stageConfigurations[0] = REVStageConfig({
163
+ startsAtOrAfter: startsAt,
164
+ autoIssuances: new REVAutoIssuance[](0),
165
+ splitPercent: 2000,
166
+ splits: splits,
167
+ // forge-lint: disable-next-line(unsafe-typecast)
168
+ initialIssuance: uint112(1000e18),
169
+ issuanceCutFrequency: 90 days,
170
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
171
+ cashOutTaxRate: 6000,
172
+ extraMetadata: 0
173
+ });
174
+
175
+ return FeeProjectConfig({
176
+ configuration: REVConfig({
177
+ // forge-lint: disable-next-line(named-struct-fields)
178
+ description: REVDescription(name, symbol, "ipfs://test", salt),
179
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
180
+ splitOperator: multisig(),
181
+ stageConfigurations: stageConfigurations
182
+ }),
183
+ terminalConfigurations: terminalConfigurations,
184
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
185
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: salt
186
+ })
187
+ });
188
+ }
189
+
190
+ function setUp() public override {
191
+ super.setUp();
192
+
193
+ // Warp to a realistic timestamp so startsAtOrAfter - 1 doesn't underflow.
194
+ vm.warp(1_700_000_000);
195
+
196
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
197
+
198
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
199
+ HOOK_STORE = new JB721TiersHookStore();
200
+ EXAMPLE_HOOK = new JB721TiersHook(
201
+ jbDirectory(), jbPermissions(), jbPrices(), jbRulesets(), HOOK_STORE, jbSplits(), multisig()
202
+ );
203
+ ADDRESS_REGISTRY = new JBAddressRegistry();
204
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
205
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
206
+ MOCK_BUYBACK = new MockBuybackDataHook();
207
+
208
+ LOANS_CONTRACT = new REVLoans({
209
+ controller: jbController(),
210
+ projects: jbProjects(),
211
+ revId: FEE_PROJECT_ID,
212
+ owner: address(this),
213
+ permit2: permit2(),
214
+ trustedForwarder: TRUSTED_FORWARDER
215
+ });
216
+
217
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
218
+ jbController(),
219
+ SUCKER_REGISTRY,
220
+ FEE_PROJECT_ID,
221
+ HOOK_DEPLOYER,
222
+ PUBLISHER,
223
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
224
+ address(LOANS_CONTRACT),
225
+ TRUSTED_FORWARDER
226
+ );
227
+
228
+ // Approve the deployer to configure the fee project.
229
+ vm.prank(multisig());
230
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
231
+
232
+ // Deploy the fee project.
233
+ FeeProjectConfig memory feeProjectConfig = getFeeProjectConfig();
234
+ vm.prank(multisig());
235
+ REV_DEPLOYER.deployFor({
236
+ revnetId: FEE_PROJECT_ID,
237
+ configuration: feeProjectConfig.configuration,
238
+ terminalConfigurations: feeProjectConfig.terminalConfigurations,
239
+ suckerDeploymentConfiguration: feeProjectConfig.suckerDeploymentConfiguration,
240
+ tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
241
+ allowedPosts: REVEmpty721Config.emptyAllowedPosts()
242
+ });
243
+
244
+ // Deploy a revnet with startsAtOrAfter in the past (triggers 30-day cash out delay).
245
+ FeeProjectConfig memory delayedConfig =
246
+ _getRevnetConfig(true, "Delayed", "$DLY", keccak256(abi.encodePacked("DELAYED")));
247
+ (DELAYED_REVNET_ID,) = REV_DEPLOYER.deployFor({
248
+ revnetId: 0,
249
+ configuration: delayedConfig.configuration,
250
+ terminalConfigurations: delayedConfig.terminalConfigurations,
251
+ suckerDeploymentConfiguration: delayedConfig.suckerDeploymentConfiguration,
252
+ tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
253
+ allowedPosts: REVEmpty721Config.emptyAllowedPosts()
254
+ });
255
+
256
+ // Deploy a normal revnet with no delay.
257
+ FeeProjectConfig memory normalConfig =
258
+ _getRevnetConfig(false, "Normal", "$NRM", keccak256(abi.encodePacked("NORMAL")));
259
+ (NORMAL_REVNET_ID,) = REV_DEPLOYER.deployFor({
260
+ revnetId: 0,
261
+ configuration: normalConfig.configuration,
262
+ terminalConfigurations: normalConfig.terminalConfigurations,
263
+ suckerDeploymentConfiguration: normalConfig.suckerDeploymentConfiguration,
264
+ tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
265
+ allowedPosts: REVEmpty721Config.emptyAllowedPosts()
266
+ });
267
+
268
+ vm.deal(USER, 100 ether);
269
+ }
270
+
271
+ // ------------------------------------------------------------------
272
+ // Helpers
273
+ // ------------------------------------------------------------------
274
+
275
+ /// @notice Pay ETH into a revnet and return the number of project tokens received.
276
+ function _payAndGetTokens(uint256 revnetId, uint256 amount) internal returns (uint256 tokenCount) {
277
+ vm.prank(USER);
278
+ tokenCount = jbMultiTerminal().pay{value: amount}(revnetId, JBConstants.NATIVE_TOKEN, amount, USER, 0, "", "");
279
+ }
280
+
281
+ /// @notice Mock the permissions check so LOANS_CONTRACT can burn tokens on behalf of USER.
282
+ function _mockBorrowPermission(uint256 projectId) internal {
283
+ mockExpect(
284
+ address(jbPermissions()),
285
+ abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), USER, projectId, 11, true, true)),
286
+ abi.encode(true)
287
+ );
288
+ }
289
+
290
+ // ------------------------------------------------------------------
291
+ // Tests: delayed revnet (startsAtOrAfter in the past → 30-day delay)
292
+ // ------------------------------------------------------------------
293
+
294
+ /// @notice Verify the deployer actually set a cash out delay for the delayed revnet.
295
+ function test_delayedRevnet_hasCashOutDelay() public view {
296
+ uint256 cashOutDelay = REV_DEPLOYER.cashOutDelayOf(DELAYED_REVNET_ID);
297
+ assertGt(cashOutDelay, block.timestamp, "Cash out delay should be in the future");
298
+ }
299
+
300
+ /// @notice Verify the normal revnet has no cash out delay.
301
+ function test_normalRevnet_noCashOutDelay() public view {
302
+ uint256 cashOutDelay = REV_DEPLOYER.cashOutDelayOf(NORMAL_REVNET_ID);
303
+ assertEq(cashOutDelay, 0, "Normal revnet should have no cash out delay");
304
+ }
305
+
306
+ /// @notice borrowableAmountFrom should return 0 during the delay period.
307
+ function test_borrowableAmountFrom_returnsZeroDuringDelay() public {
308
+ // Pay into the delayed revnet to get tokens.
309
+ uint256 tokenCount = _payAndGetTokens(DELAYED_REVNET_ID, 1 ether);
310
+ assertGt(tokenCount, 0, "Should have tokens");
311
+
312
+ // Query borrowable amount — should be 0 during the delay.
313
+ uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
314
+ DELAYED_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
315
+ );
316
+ assertEq(borrowable, 0, "Borrowable amount should be 0 during cash out delay");
317
+ }
318
+
319
+ /// @notice borrowFrom should revert during the delay period.
320
+ function test_borrowFrom_revertsDuringDelay() public {
321
+ // Pay into the delayed revnet to get tokens.
322
+ uint256 tokenCount = _payAndGetTokens(DELAYED_REVNET_ID, 1 ether);
323
+ assertGt(tokenCount, 0, "Should have tokens");
324
+
325
+ // No permission mock needed — the function reverts before reaching the permission check.
326
+
327
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
328
+
329
+ // Attempt to borrow — should revert with CashOutDelayNotFinished.
330
+ uint256 cashOutDelay = REV_DEPLOYER.cashOutDelayOf(DELAYED_REVNET_ID);
331
+ vm.expectRevert(
332
+ abi.encodeWithSelector(REVLoans.REVLoans_CashOutDelayNotFinished.selector, cashOutDelay, block.timestamp)
333
+ );
334
+ vm.prank(USER);
335
+ LOANS_CONTRACT.borrowFrom(DELAYED_REVNET_ID, source, 1, tokenCount, payable(USER), 25);
336
+ }
337
+
338
+ /// @notice After warping past the delay, borrowableAmountFrom should return a non-zero value.
339
+ function test_borrowableAmountFrom_nonZeroAfterDelay() public {
340
+ // Pay into the delayed revnet.
341
+ uint256 tokenCount = _payAndGetTokens(DELAYED_REVNET_ID, 1 ether);
342
+
343
+ // Still in delay — should be 0.
344
+ uint256 borrowableBefore = LOANS_CONTRACT.borrowableAmountFrom(
345
+ DELAYED_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
346
+ );
347
+ assertEq(borrowableBefore, 0, "Should be 0 during delay");
348
+
349
+ // Warp past the delay.
350
+ vm.warp(block.timestamp + REV_DEPLOYER.CASH_OUT_DELAY() + 1);
351
+
352
+ // Now should be > 0.
353
+ uint256 borrowableAfter = LOANS_CONTRACT.borrowableAmountFrom(
354
+ DELAYED_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
355
+ );
356
+ assertGt(borrowableAfter, 0, "Should be > 0 after delay expires");
357
+ }
358
+
359
+ /// @notice After warping past the delay, borrowFrom should succeed.
360
+ function test_borrowFrom_succeedsAfterDelay() public {
361
+ // Pay into the delayed revnet.
362
+ uint256 tokenCount = _payAndGetTokens(DELAYED_REVNET_ID, 1 ether);
363
+
364
+ // Warp past the delay.
365
+ vm.warp(block.timestamp + REV_DEPLOYER.CASH_OUT_DELAY() + 1);
366
+
367
+ // Get the borrowable amount.
368
+ uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
369
+ DELAYED_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
370
+ );
371
+ assertGt(borrowable, 0, "Should be borrowable after delay");
372
+
373
+ // Mock permission.
374
+ _mockBorrowPermission(DELAYED_REVNET_ID);
375
+
376
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
377
+
378
+ // Borrow — should succeed.
379
+ vm.prank(USER);
380
+ (uint256 loanId,) =
381
+ LOANS_CONTRACT.borrowFrom(DELAYED_REVNET_ID, source, borrowable, tokenCount, payable(USER), 25);
382
+ assertGt(loanId, 0, "Should have created a loan");
383
+ }
384
+
385
+ // ------------------------------------------------------------------
386
+ // Tests: normal revnet (no delay)
387
+ // ------------------------------------------------------------------
388
+
389
+ /// @notice A normal revnet (no delay) should allow borrowing immediately.
390
+ function test_normalRevnet_borrowableImmediately() public {
391
+ // Pay into the normal revnet.
392
+ uint256 tokenCount = _payAndGetTokens(NORMAL_REVNET_ID, 1 ether);
393
+
394
+ // Should have a non-zero borrowable amount immediately.
395
+ uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
396
+ NORMAL_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
397
+ );
398
+ assertGt(borrowable, 0, "Normal revnet should be borrowable immediately");
399
+ }
400
+
401
+ /// @notice A normal revnet (no delay) should allow borrowFrom immediately.
402
+ function test_normalRevnet_borrowFromImmediately() public {
403
+ // Pay into the normal revnet.
404
+ uint256 tokenCount = _payAndGetTokens(NORMAL_REVNET_ID, 1 ether);
405
+
406
+ uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
407
+ NORMAL_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
408
+ );
409
+ assertGt(borrowable, 0, "Should be borrowable");
410
+
411
+ // Mock permission.
412
+ _mockBorrowPermission(NORMAL_REVNET_ID);
413
+
414
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
415
+
416
+ // Borrow — should succeed without any delay.
417
+ vm.prank(USER);
418
+ (uint256 loanId,) =
419
+ LOANS_CONTRACT.borrowFrom(NORMAL_REVNET_ID, source, borrowable, tokenCount, payable(USER), 25);
420
+ assertGt(loanId, 0, "Should have created a loan");
421
+ }
422
+
423
+ // ------------------------------------------------------------------
424
+ // Tests: boundary conditions
425
+ // ------------------------------------------------------------------
426
+
427
+ /// @notice borrowFrom should revert at exactly the delay timestamp (not yet expired).
428
+ function test_borrowFrom_revertsAtExactDelayTimestamp() public {
429
+ uint256 tokenCount = _payAndGetTokens(DELAYED_REVNET_ID, 1 ether);
430
+
431
+ // Warp to exactly the delay timestamp (not past it).
432
+ uint256 cashOutDelay = REV_DEPLOYER.cashOutDelayOf(DELAYED_REVNET_ID);
433
+ vm.warp(cashOutDelay);
434
+
435
+ // borrowableAmountFrom should still return 0 (cashOutDelay > block.timestamp is false, but == is not >).
436
+ // Actually cashOutDelay == block.timestamp means cashOutDelay > block.timestamp is false → should pass.
437
+ // Let's verify: at exact boundary, the delay is NOT enforced (delay == timestamp passes).
438
+ uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
439
+ DELAYED_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
440
+ );
441
+ assertGt(borrowable, 0, "At exact delay timestamp, borrowing should be allowed");
442
+ }
443
+
444
+ /// @notice borrowFrom should revert 1 second before the delay expires.
445
+ function test_borrowFrom_revertsOneSecondBeforeDelay() public {
446
+ uint256 tokenCount = _payAndGetTokens(DELAYED_REVNET_ID, 1 ether);
447
+
448
+ // Warp to 1 second before the delay expires.
449
+ uint256 cashOutDelay = REV_DEPLOYER.cashOutDelayOf(DELAYED_REVNET_ID);
450
+ vm.warp(cashOutDelay - 1);
451
+
452
+ // borrowableAmountFrom should return 0.
453
+ uint256 borrowable = LOANS_CONTRACT.borrowableAmountFrom(
454
+ DELAYED_REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN))
455
+ );
456
+ assertEq(borrowable, 0, "Should be 0 one second before delay expires");
457
+
458
+ // borrowFrom should revert before reaching the permission check.
459
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
460
+
461
+ vm.expectRevert(
462
+ abi.encodeWithSelector(REVLoans.REVLoans_CashOutDelayNotFinished.selector, cashOutDelay, block.timestamp)
463
+ );
464
+ vm.prank(USER);
465
+ LOANS_CONTRACT.borrowFrom(DELAYED_REVNET_ID, source, 1, tokenCount, payable(USER), 25);
466
+ }
467
+ }
@@ -218,7 +218,6 @@ contract TestSplitWeightE2E is TestBaseWorkflow {
218
218
  tiersConfig: JB721InitTiersConfig({
219
219
  tiers: tiers, currency: uint32(uint160(JBConstants.NATIVE_TOKEN)), decimals: 18
220
220
  }),
221
- reserveBeneficiary: address(0),
222
221
  flags: REV721TiersHookFlags({
223
222
  noNewTiersWithReserves: false,
224
223
  noNewTiersWithVotes: false,
@@ -429,7 +429,6 @@ contract TestSplitWeightFork is TestBaseWorkflow {
429
429
  tiersConfig: JB721InitTiersConfig({
430
430
  tiers: tiers, currency: uint32(uint160(JBConstants.NATIVE_TOKEN)), decimals: 18
431
431
  }),
432
- reserveBeneficiary: address(0),
433
432
  flags: REV721TiersHookFlags({
434
433
  noNewTiersWithReserves: false,
435
434
  noNewTiersWithVotes: false,
@@ -435,7 +435,6 @@ abstract contract ForkTestBase is TestBaseWorkflow {
435
435
  tiersConfig: JB721InitTiersConfig({
436
436
  tiers: tiers, currency: uint32(uint160(JBConstants.NATIVE_TOKEN)), decimals: 18
437
437
  }),
438
- reserveBeneficiary: address(0),
439
438
  flags: REV721TiersHookFlags({
440
439
  noNewTiersWithReserves: false,
441
440
  noNewTiersWithVotes: false,
@@ -24,7 +24,6 @@ library REVEmpty721Config {
24
24
  tiersConfig: JB721InitTiersConfig({
25
25
  tiers: new JB721TierConfig[](0), currency: baseCurrency, decimals: 18
26
26
  }),
27
- reserveBeneficiary: address(0),
28
27
  flags: REV721TiersHookFlags({
29
28
  noNewTiersWithReserves: false,
30
29
  noNewTiersWithVotes: false,