@rev-net/core-v6 0.0.12 → 0.0.13

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 (78) hide show
  1. package/AUDIT_INSTRUCTIONS.md +295 -0
  2. package/CHANGE_LOG.md +316 -0
  3. package/README.md +2 -2
  4. package/RISKS.md +180 -35
  5. package/SKILLS.md +1 -1
  6. package/USER_JOURNEYS.md +489 -0
  7. package/package.json +9 -9
  8. package/script/Deploy.s.sol +40 -6
  9. package/script/helpers/RevnetCoreDeploymentLib.sol +7 -1
  10. package/src/REVDeployer.sol +63 -47
  11. package/src/REVLoans.sol +51 -15
  12. package/src/interfaces/IREVDeployer.sol +0 -1
  13. package/src/structs/REV721TiersHookFlags.sol +1 -0
  14. package/src/structs/REVAutoIssuance.sol +1 -0
  15. package/src/structs/REVBaseline721HookConfig.sol +1 -0
  16. package/src/structs/REVConfig.sol +1 -0
  17. package/src/structs/REVCroptopAllowedPost.sol +1 -0
  18. package/src/structs/REVDeploy721TiersHookConfig.sol +1 -0
  19. package/src/structs/REVDescription.sol +1 -0
  20. package/src/structs/REVLoan.sol +1 -0
  21. package/src/structs/REVLoanSource.sol +1 -0
  22. package/src/structs/REVStageConfig.sol +1 -0
  23. package/src/structs/REVSuckerDeploymentConfig.sol +1 -0
  24. package/test/REV.integrations.t.sol +132 -12
  25. package/test/REVAutoIssuanceFuzz.t.sol +23 -3
  26. package/test/REVDeployerRegressions.t.sol +35 -4
  27. package/test/REVInvincibility.t.sol +58 -8
  28. package/test/REVInvincibilityHandler.sol +29 -0
  29. package/test/REVLifecycle.t.sol +28 -3
  30. package/test/REVLoans.invariants.t.sol +52 -5
  31. package/test/REVLoansAttacks.t.sol +43 -5
  32. package/test/REVLoansFeeRecovery.t.sol +50 -11
  33. package/test/REVLoansFindings.t.sol +27 -3
  34. package/test/REVLoansRegressions.t.sol +25 -3
  35. package/test/REVLoansSourceFeeRecovery.t.sol +491 -0
  36. package/test/REVLoansSourced.t.sol +56 -7
  37. package/test/REVLoansUnSourced.t.sol +49 -5
  38. package/test/TestBurnHeldTokens.t.sol +32 -5
  39. package/test/TestCEIPattern.t.sol +26 -2
  40. package/test/TestCashOutCallerValidation.t.sol +30 -4
  41. package/test/TestConversionDocumentation.t.sol +26 -5
  42. package/test/TestCrossCurrencyReclaim.t.sol +584 -0
  43. package/test/TestCrossSourceReallocation.t.sol +26 -2
  44. package/test/TestERC2771MetaTx.t.sol +557 -0
  45. package/test/TestEmptyBuybackSpecs.t.sol +23 -3
  46. package/test/TestFlashLoanSurplus.t.sol +28 -3
  47. package/test/TestHookArrayOOB.t.sol +24 -4
  48. package/test/TestLiquidationBehavior.t.sol +26 -3
  49. package/test/TestLoanSourceRotation.t.sol +525 -0
  50. package/test/TestLongTailEconomics.t.sol +651 -0
  51. package/test/TestLowFindings.t.sol +65 -2
  52. package/test/TestMixedFixes.t.sol +28 -3
  53. package/test/TestPermit2Signatures.t.sol +657 -0
  54. package/test/TestReallocationSandwich.t.sol +384 -0
  55. package/test/TestRevnetRegressions.t.sol +324 -0
  56. package/test/TestSplitWeightAdjustment.t.sol +24 -2
  57. package/test/TestSplitWeightE2E.t.sol +29 -2
  58. package/test/TestSplitWeightFork.t.sol +46 -7
  59. package/test/TestStageTransitionBorrowable.t.sol +24 -2
  60. package/test/TestSwapTerminalPermission.t.sol +23 -3
  61. package/test/TestUint112Overflow.t.sol +28 -2
  62. package/test/TestZeroRepayment.t.sol +26 -2
  63. package/test/fork/ForkTestBase.sol +46 -3
  64. package/test/fork/TestCashOutFork.t.sol +1 -1
  65. package/test/fork/TestLoanBorrowFork.t.sol +1 -0
  66. package/test/fork/TestLoanCrossRulesetFork.t.sol +3 -1
  67. package/test/fork/TestLoanLiquidationFork.t.sol +1 -0
  68. package/test/fork/TestLoanReallocateFork.t.sol +1 -0
  69. package/test/fork/TestLoanRepayFork.t.sol +1 -0
  70. package/test/fork/TestLoanTransferFork.t.sol +133 -0
  71. package/test/fork/TestSplitWeightFork.t.sol +3 -0
  72. package/test/helpers/REVEmpty721Config.sol +1 -0
  73. package/test/mock/MockBuybackDataHook.sol +1 -0
  74. package/test/regression/TestBurnPermissionRequired.t.sol +267 -0
  75. package/test/regression/TestCrossRevnetLiquidation.t.sol +228 -0
  76. package/test/regression/TestCumulativeLoanCounter.t.sol +27 -4
  77. package/test/regression/TestLiquidateGapHandling.t.sol +29 -4
  78. package/test/regression/TestZeroPriceFeed.t.sol +396 -0
@@ -9,6 +9,7 @@ import {IREVDeployer} from "./../../src/interfaces/IREVDeployer.sol";
9
9
  import {IREVLoans} from "./../../src/interfaces/IREVLoans.sol";
10
10
 
11
11
  struct RevnetCoreDeployment {
12
+ // forge-lint: disable-next-line(mixed-case-variable)
12
13
  IREVDeployer basic_deployer;
13
14
  IREVLoans loans;
14
15
  }
@@ -16,6 +17,7 @@ struct RevnetCoreDeployment {
16
17
  library RevnetCoreDeploymentLib {
17
18
  // Cheat code address, 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D.
18
19
  address internal constant VM_ADDRESS = address(uint160(uint256(keccak256("hevm cheat code"))));
20
+ // forge-lint: disable-next-line(screaming-snake-case-const)
19
21
  Vm internal constant vm = Vm(VM_ADDRESS);
20
22
 
21
23
  function getDeployment(string memory path) internal returns (RevnetCoreDeployment memory deployment) {
@@ -38,6 +40,7 @@ library RevnetCoreDeploymentLib {
38
40
 
39
41
  function getDeployment(
40
42
  string memory path,
43
+ // forge-lint: disable-next-line(mixed-case-variable)
41
44
  string memory network_name
42
45
  )
43
46
  internal
@@ -64,7 +67,9 @@ library RevnetCoreDeploymentLib {
64
67
  /// @return The address of the contract.
65
68
  function _getDeploymentAddress(
66
69
  string memory path,
70
+ // forge-lint: disable-next-line(mixed-case-variable)
67
71
  string memory project_name,
72
+ // forge-lint: disable-next-line(mixed-case-variable)
68
73
  string memory network_name,
69
74
  string memory contractName
70
75
  )
@@ -73,7 +78,8 @@ library RevnetCoreDeploymentLib {
73
78
  returns (address)
74
79
  {
75
80
  string memory deploymentJson =
76
- vm.readFile(string.concat(path, project_name, "/", network_name, "/", contractName, ".json"));
81
+ // forge-lint: disable-next-line(unsafe-cheatcode)
82
+ vm.readFile(string.concat(path, project_name, "/", network_name, "/", contractName, ".json"));
77
83
  return stdJson.readAddress({json: deploymentJson, key: ".address"});
78
84
  }
79
85
  }
@@ -1,12 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity 0.8.26;
3
3
 
4
- import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
5
- import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
6
- import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
7
- import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
8
- import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
9
- import {mulDiv} from "@prb/math/src/Common.sol";
10
4
  import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
11
5
  import {IJB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookDeployer.sol";
12
6
  import {JB721TiersHookFlags} from "@bananapus/721-hook-v6/src/structs/JB721TiersHookFlags.sol";
@@ -15,13 +9,11 @@ import {IJBBuybackHookRegistry} from "@bananapus/buyback-hook-v6/src/interfaces/
15
9
  import {IJBCashOutHook} from "@bananapus/core-v6/src/interfaces/IJBCashOutHook.sol";
16
10
  import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
17
11
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
18
- import {IJBPayHook} from "@bananapus/core-v6/src/interfaces/IJBPayHook.sol";
19
12
  import {IJBPermissioned} from "@bananapus/core-v6/src/interfaces/IJBPermissioned.sol";
20
13
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
21
14
  import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
22
15
  import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
23
16
  import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
24
- import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
25
17
  import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
26
18
  import {JBCashOuts} from "@bananapus/core-v6/src/libraries/JBCashOuts.sol";
27
19
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
@@ -38,14 +30,18 @@ import {JBPermissionsData} from "@bananapus/core-v6/src/structs/JBPermissionsDat
38
30
  import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
39
31
  import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.sol";
40
32
  import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol";
41
- import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
42
- import {JBTokenAmount} from "@bananapus/core-v6/src/structs/JBTokenAmount.sol";
43
33
  import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
44
34
  import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
45
35
  import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
46
36
  import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
47
37
  import {CTPublisher} from "@croptop/core-v6/src/CTPublisher.sol";
48
38
  import {CTAllowedPost} from "@croptop/core-v6/src/structs/CTAllowedPost.sol";
39
+ import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
40
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
41
+ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
42
+ import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
43
+ import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
44
+ import {mulDiv} from "@prb/math/src/Common.sol";
49
45
 
50
46
  import {IREVDeployer} from "./interfaces/IREVDeployer.sol";
51
47
  import {REVAutoIssuance} from "./structs/REVAutoIssuance.sol";
@@ -224,6 +220,8 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
224
220
  });
225
221
 
226
222
  // Give the loan contract permission to use the surplus allowance of all revnets.
223
+ // Uses wildcard revnetId=0 intentionally — the loan contract is a singleton shared by all revnets,
224
+ // and each revnet's surplus allowance limits already constrain how much can be drawn.
227
225
  _setPermission({operator: LOANS, revnetId: 0, permissionId: JBPermissionIds.USE_ALLOWANCE});
228
226
 
229
227
  // Give the buyback hook (registry) permission to configure pools on all revnets.
@@ -255,6 +253,8 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
255
253
  )
256
254
  {
257
255
  // If the cash out is from a sucker, return the full cash out amount without taxes or fees.
256
+ // This relies on the sucker registry to only contain trusted sucker contracts deployed via
257
+ // the registry's own deploySuckersFor flow — external addresses cannot register as suckers.
258
258
  if (_isSuckerOf({revnetId: context.projectId, addr: context.holder})) {
259
259
  return (0, context.cashOutCount, context.totalSupply, hookSpecifications);
260
260
  }
@@ -270,12 +270,15 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
270
270
  // Get the terminal that will receive the cash out fee.
271
271
  IJBTerminal feeTerminal = DIRECTORY.primaryTerminalOf({projectId: FEE_REVNET_ID, token: context.surplus.token});
272
272
 
273
- // If there's no cash out tax (100% cash out tax rate), or if there's no fee terminal, do not charge a fee.
274
- if (context.cashOutTaxRate == 0 || address(feeTerminal) == address(0)) {
273
+ // If there's no cash out tax (100% cash out tax rate), if there's no fee terminal, or if the beneficiary is
274
+ // feeless (e.g. the router terminal routing value between projects), do not charge a fee.
275
+ if (context.cashOutTaxRate == 0 || address(feeTerminal) == address(0) || context.beneficiaryIsFeeless) {
275
276
  return (context.cashOutTaxRate, context.cashOutCount, context.totalSupply, hookSpecifications);
276
277
  }
277
278
 
278
279
  // Get a reference to the number of tokens being used to pay the fee (out of the total being cashed out).
280
+ // Micro cash outs (< 40 wei at 2.5% fee) round feeCashOutCount to zero, bypassing the fee.
281
+ // Economically insignificant: the gas cost of the transaction far exceeds the bypassed fee. No fix needed.
279
282
  uint256 feeCashOutCount = mulDiv({x: context.cashOutCount, y: FEE, denominator: JBConstants.MAX_FEE});
280
283
  uint256 nonFeeCashOutCount = context.cashOutCount - feeCashOutCount;
281
284
 
@@ -483,26 +486,6 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
483
486
  }
484
487
  }
485
488
 
486
- /// @notice Try to initialize a Uniswap V4 buyback pool for a terminal token at a generic 1:1 price.
487
- /// @dev Called after the ERC-20 token is deployed so the pool can be initialized in the PoolManager.
488
- /// Silently catches failures (e.g., if the pool is already initialized).
489
- /// @param revnetId The ID of the revnet.
490
- /// @param terminalToken The terminal token to initialize a buyback pool for.
491
- function _tryInitializeBuybackPoolFor(uint256 revnetId, address terminalToken) internal {
492
- // Try to initialize the pool at a generic 1:1 sqrtPriceX96 and configure the buyback hook.
493
- // The buyback hook constructs the PoolKey internally from the project token, terminal token, and pool params.
494
- // slither-disable-next-line calls-loop
495
- try BUYBACK_HOOK.initializePoolFor({
496
- projectId: revnetId,
497
- fee: DEFAULT_BUYBACK_POOL_FEE,
498
- tickSpacing: DEFAULT_BUYBACK_TICK_SPACING,
499
- twapWindow: DEFAULT_BUYBACK_TWAP_WINDOW,
500
- terminalToken: terminalToken,
501
- sqrtPriceX96: uint160(1 << 96)
502
- }) {}
503
- catch {} // Pool may already be initialized — that's OK.
504
- }
505
-
506
489
  /// @notice Make a ruleset configuration for a revnet's stage.
507
490
  /// @param baseCurrency The base currency of the revnet.
508
491
  /// @param stageConfiguration The stage configuration to make a ruleset for.
@@ -582,6 +565,26 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
582
565
  }
583
566
  }
584
567
 
568
+ /// @notice Try to initialize a Uniswap V4 buyback pool for a terminal token at a generic 1:1 price.
569
+ /// @dev Called after the ERC-20 token is deployed so the pool can be initialized in the PoolManager.
570
+ /// Silently catches failures (e.g., if the pool is already initialized).
571
+ /// @param revnetId The ID of the revnet.
572
+ /// @param terminalToken The terminal token to initialize a buyback pool for.
573
+ function _tryInitializeBuybackPoolFor(uint256 revnetId, address terminalToken) internal {
574
+ // Try to initialize the pool at a generic 1:1 sqrtPriceX96 and configure the buyback hook.
575
+ // The buyback hook constructs the PoolKey internally from the project token, terminal token, and pool params.
576
+ // slither-disable-next-line calls-loop
577
+ try BUYBACK_HOOK.initializePoolFor({
578
+ projectId: revnetId,
579
+ fee: DEFAULT_BUYBACK_POOL_FEE,
580
+ tickSpacing: DEFAULT_BUYBACK_TICK_SPACING,
581
+ twapWindow: DEFAULT_BUYBACK_TWAP_WINDOW,
582
+ terminalToken: terminalToken,
583
+ sqrtPriceX96: uint160(1 << 96)
584
+ }) {}
585
+ catch {} // Pool may already be initialized — that's OK.
586
+ }
587
+
585
588
  //*********************************************************************//
586
589
  // --------------------- external transactions ----------------------- //
587
590
  //*********************************************************************//
@@ -649,7 +652,13 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
649
652
  /// @param stageId The ID of the stage auto-mint tokens are available from.
650
653
  /// @param beneficiary The address to auto-mint tokens to.
651
654
  function autoIssueFor(uint256 revnetId, uint256 stageId, address beneficiary) external override {
652
- // Get a reference to the ruleset for the stage.
655
+ // Get the ruleset for the stage to check if it has started.
656
+ // Stage IDs are `block.timestamp + i` where `i` is the stage index. These match real JB ruleset IDs
657
+ // because JBRulesets assigns IDs the same way: `latestId >= block.timestamp ? latestId + 1 : block.timestamp`
658
+ // (see JBRulesets.sol L172). When all stages are queued in a single deployFor() call, the sequential
659
+ // IDs `block.timestamp`, `block.timestamp + 1`, ... exactly correspond to the JB-assigned ruleset IDs.
660
+ // The returned `ruleset.start` contains the derived start time (from `deriveStartFrom` using the stage's
661
+ // `mustStartAtOrAfter`), NOT the queue timestamp — so the timing guard correctly blocks early claims.
653
662
  // slither-disable-next-line unused-return
654
663
  (JBRuleset memory ruleset,) = CONTROLLER.getRulesetOf({projectId: revnetId, rulesetId: stageId});
655
664
 
@@ -678,6 +687,17 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
678
687
  });
679
688
  }
680
689
 
690
+ /// @notice Burn any of a revnet's tokens held by this contract.
691
+ /// @dev Project tokens can end up here from reserved token distribution when splits don't sum to 100%.
692
+ /// @param revnetId The ID of the revnet whose tokens should be burned.
693
+ function burnHeldTokensOf(uint256 revnetId) external override {
694
+ uint256 balance = CONTROLLER.TOKENS().totalBalanceOf({holder: address(this), projectId: revnetId});
695
+ if (balance == 0) revert REVDeployer_NothingToBurn();
696
+ CONTROLLER.burnTokensOf({holder: address(this), projectId: revnetId, tokenCount: balance, memo: ""});
697
+ // slither-disable-next-line reentrancy-events
698
+ emit BurnHeldTokens(revnetId, balance, _msgSender());
699
+ }
700
+
681
701
  /// @notice Launch a revnet, or initialize an existing Juicebox project as a revnet.
682
702
  /// @dev When initializing an existing project (revnetId != 0):
683
703
  /// - The project must not yet have a controller or rulesets. `JBController.launchRulesetsFor` enforces this —
@@ -780,17 +800,6 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
780
800
  return (revnetId, hook);
781
801
  }
782
802
 
783
- /// @notice Burn any of a revnet's tokens held by this contract.
784
- /// @dev Project tokens can end up here from reserved token distribution when splits don't sum to 100%.
785
- /// @param revnetId The ID of the revnet whose tokens should be burned.
786
- function burnHeldTokensOf(uint256 revnetId) external override {
787
- uint256 balance = CONTROLLER.TOKENS().totalBalanceOf({holder: address(this), projectId: revnetId});
788
- if (balance == 0) revert REVDeployer_NothingToBurn();
789
- CONTROLLER.burnTokensOf({holder: address(this), projectId: revnetId, tokenCount: balance, memo: ""});
790
- // slither-disable-next-line reentrancy-events
791
- emit BurnHeldTokens(revnetId, balance, _msgSender());
792
- }
793
-
794
803
  /// @notice Deploy new suckers for an existing revnet.
795
804
  /// @dev Only the revnet's split operator can deploy new suckers.
796
805
  /// @param revnetId The ID of the revnet to deploy suckers for.
@@ -1122,6 +1131,9 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
1122
1131
  /// of collateral) or lower issuance weight (reducing the surplus-per-token ratio). Borrowers should monitor
1123
1132
  /// upcoming stage transitions and adjust their positions accordingly, as loans that fall below their required
1124
1133
  /// collateralization may become eligible for liquidation.
1134
+ /// @dev `cashOutTaxRate` changes at stage boundaries may allow users to cash out just before a rate increase.
1135
+ /// This is accepted behavior — the arbitrage window is bounded by the ruleset design, and all stages are
1136
+ /// configured immutably at deployment time.
1125
1137
  /// @param revnetId The ID of the revnet to make rulesets for.
1126
1138
  /// @param configuration The configuration containing the revnet's stages.
1127
1139
  /// @param terminalConfigurations The terminals to set up for the revnet. Used for payments and cash outs.
@@ -1229,8 +1241,11 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
1229
1241
  });
1230
1242
 
1231
1243
  // Store the amount of tokens that can be auto-minted on this chain during this stage.
1232
- // The first stage ID is stored at this block's timestamp,
1233
- // and further stage IDs have incrementally increasing IDs
1244
+ // The stage ID is `block.timestamp + i`. This matches the ruleset ID that JBRulesets assigns
1245
+ // because JBRulesets uses `latestId >= block.timestamp ? latestId + 1 : block.timestamp`
1246
+ // (JBRulesets.sol L172), producing the same sequential IDs when all stages are queued in one tx.
1247
+ // `autoIssueFor` later calls `getRulesetOf(revnetId, stageId)` — the returned `ruleset.start`
1248
+ // is the derived start time (not the queue time), so the timing guard works correctly.
1234
1249
  // slither-disable-next-line reentrancy-benign
1235
1250
  amountToAutoIssue[revnetId][block.timestamp + i][autoIssuance.beneficiary] += autoIssuance.count;
1236
1251
  }
@@ -1288,7 +1303,8 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IJBRulesetDataHook, IJBCas
1288
1303
  {
1289
1304
  // Set up the permission data.
1290
1305
  JBPermissionsData memory permissionData =
1291
- JBPermissionsData({operator: operator, projectId: uint64(revnetId), permissionIds: permissionIds});
1306
+ // forge-lint: disable-next-line(unsafe-typecast)
1307
+ JBPermissionsData({operator: operator, projectId: uint64(revnetId), permissionIds: permissionIds});
1292
1308
 
1293
1309
  // Set the permissions.
1294
1310
  PERMISSIONS.setPermissionsFor({account: account, permissionsData: permissionData});
package/src/REVLoans.sol CHANGED
@@ -59,6 +59,7 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
59
59
  error REVLoans_InvalidPrepaidFeePercent(uint256 prepaidFeePercent, uint256 min, uint256 max);
60
60
  error REVLoans_InvalidTerminal(address terminal, uint256 revnetId);
61
61
  error REVLoans_LoanExpired(uint256 timeSinceLoanCreated, uint256 loanLiquidationDuration);
62
+ error REVLoans_LoanIdOverflow();
62
63
  error REVLoans_NewBorrowAmountGreaterThanLoanAmount(uint256 newBorrowAmount, uint256 loanAmount);
63
64
  error REVLoans_NoMsgValueAllowed();
64
65
  error REVLoans_NotEnoughCollateral();
@@ -70,6 +71,7 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
70
71
  error REVLoans_SourceMismatch();
71
72
  error REVLoans_Unauthorized(address caller, address owner);
72
73
  error REVLoans_UnderMinBorrowAmount(uint256 minBorrowAmount, uint256 borrowAmount);
74
+ error REVLoans_ZeroBorrowAmount();
73
75
  error REVLoans_ZeroCollateralLoanIsInvalid();
74
76
 
75
77
  //*********************************************************************//
@@ -93,7 +95,7 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
93
95
  uint256 public constant override MIN_PREPAID_FEE_PERCENT = 25; // 2.5%
94
96
 
95
97
  //*********************************************************************//
96
- // -------------------- private constant properties ------------------ //
98
+ // ------------------------ private constants ------------------------ //
97
99
  //*********************************************************************//
98
100
 
99
101
  /// @notice Just a kind reminder to our readers.
@@ -416,8 +418,10 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
416
418
  // If the loan period has passed the prepaid time frame, take a fee.
417
419
  if (timeSinceLoanCreated <= loan.prepaidDuration) return 0;
418
420
 
419
- // If the loan period has reached or passed the liquidation time frame, do not allow loan management.
420
- if (timeSinceLoanCreated >= LOAN_LIQUIDATION_DURATION) {
421
+ // If the loan period has passed the liquidation time frame, do not allow loan management.
422
+ // Uses `>` (not `>=`) so the exact boundary second is still repayable — the liquidation path
423
+ // uses `<=`, and matching `>=` here would create a 1-second window where neither path is available.
424
+ if (timeSinceLoanCreated > LOAN_LIQUIDATION_DURATION) {
421
425
  revert REVLoans_LoanExpired(timeSinceLoanCreated, LOAN_LIQUIDATION_DURATION);
422
426
  }
423
427
 
@@ -461,9 +465,8 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
461
465
  /// @dev Each source's `totalBorrowedFrom` is stored in the source token's native decimals (e.g. 6 for USDC,
462
466
  /// 18 for ETH). Before aggregation, each amount is normalized to the target `decimals` to prevent mixed-decimal
463
467
  /// arithmetic errors. For cross-currency sources, the normalized amount is then converted via the price feed.
464
- /// @dev Callers should ensure the price feed has sufficient precision for the target `decimals`. Inverse price
465
- /// feeds may truncate to zero at low decimal counts (e.g. a feed returning 1e21 at 6 decimals inverts to
466
- /// mulDiv(1e6, 1e6, 1e21) = 0), which would cause a division-by-zero in the price conversion.
468
+ /// @dev Inverse price feeds may truncate to zero at low decimal counts (e.g. a feed returning 1e21 at 6 decimals
469
+ /// inverts to mulDiv(1e6, 1e6, 1e21) = 0). Sources with a zero price are skipped to prevent division-by-zero.
467
470
  /// @param revnetId The ID of the revnet to check for borrowed assets from.
468
471
  /// @param decimals The decimals the resulting fixed point value will include.
469
472
  /// @param currency The currency the resulting value will be in terms of.
@@ -519,6 +522,11 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
519
522
  decimals: decimals
520
523
  });
521
524
 
525
+ // If the price feed returns zero, skip this source to avoid a division-by-zero panic
526
+ // that would DoS all loan operations. This intentionally understates total debt for
527
+ // the affected source — an acceptable tradeoff vs. blocking every borrow/repay.
528
+ if (pricePerUnit == 0) continue;
529
+
522
530
  borrowedAmount += mulDiv({x: normalizedTokens, y: 10 ** decimals, denominator: pricePerUnit});
523
531
  }
524
532
  }
@@ -529,6 +537,8 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
529
537
  //*********************************************************************//
530
538
 
531
539
  /// @notice Open a loan by borrowing from a revnet.
540
+ /// @dev The caller must first grant BURN_TOKENS permission to this contract via JBPermissions.setPermissionsFor().
541
+ /// This is required because collateral posting burns the caller's tokens through the controller.
532
542
  /// @dev Collateral tokens are permanently burned when the loan is created. They are re-minted to the borrower
533
543
  /// only upon repayment. If the loan expires (after LOAN_LIQUIDATION_DURATION), the collateral is permanently
534
544
  /// lost and cannot be recovered.
@@ -579,7 +589,8 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
579
589
 
580
590
  // Set the loan's values.
581
591
  loan.source = source;
582
- loan.createdAt = uint40(block.timestamp);
592
+ loan.createdAt = uint48(block.timestamp);
593
+ // forge-lint: disable-next-line(unsafe-typecast)
583
594
  loan.prepaidFeePercent = uint16(prepaidFeePercent);
584
595
  loan.prepaidDuration =
585
596
  uint32(mulDiv({x: prepaidFeePercent, y: LOAN_LIQUIDATION_DURATION, denominator: MAX_PREPAID_FEE_PERCENT}));
@@ -587,10 +598,14 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
587
598
  // Get the amount of the loan.
588
599
  uint256 borrowAmount = _borrowAmountFrom({loan: loan, revnetId: revnetId, collateralCount: collateralCount});
589
600
 
601
+ // Revert if the bonding curve returns zero to prevent creating zero-amount loans.
602
+ if (borrowAmount == 0) revert REVLoans_ZeroBorrowAmount();
603
+
590
604
  // Make sure the minimum borrow amount is met.
591
605
  if (borrowAmount < minBorrowAmount) revert REVLoans_UnderMinBorrowAmount(minBorrowAmount, borrowAmount);
592
606
 
593
607
  // Get the amount of additional fee to take for the revnet issuing the loan.
608
+ // Fee rounding may leave a few wei of dust — economically insignificant relative to gas costs.
594
609
  uint256 sourceFeeAmount = JBFees.feeAmountFrom({amountBeforeFee: borrowAmount, feePercent: prepaidFeePercent});
595
610
 
596
611
  // Borrow the amount.
@@ -635,6 +650,9 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
635
650
  /// @param startingLoanId The ID of the loan to start iterating from.
636
651
  /// @param count The amount of loans iterate over since the last liquidated loan.
637
652
  function liquidateExpiredLoansFrom(uint256 revnetId, uint256 startingLoanId, uint256 count) external override {
653
+ // Prevent cross-revnet accounting corruption: loan numbers must stay within the revnet's ID namespace.
654
+ if (startingLoanId + count > _ONE_TRILLION) revert REVLoans_LoanIdOverflow();
655
+
638
656
  // Iterate over the desired number of loans to check for liquidation.
639
657
  for (uint256 i; i < count; i++) {
640
658
  // Get a reference to the next loan ID.
@@ -928,8 +946,8 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
928
946
  internal
929
947
  {
930
948
  // Register the source if this is the first time its being used for this revnet.
931
- // Note: Sources are only appended, never removed. This is acceptable because the number of distinct
932
- // (terminal, token) pairs per revnet is practically bounded.
949
+ // Note: Sources are only appended, never removed. Gas accumulation from iteration is bounded
950
+ // because the number of distinct (terminal, token) pairs per revnet is practically small (~5-20).
933
951
  if (!isLoanSourceOf[revnetId][loan.source.terminal][loan.source.token]) {
934
952
  isLoanSourceOf[revnetId][loan.source.terminal][loan.source.token] = true;
935
953
  _loanSourcesOf[revnetId].push(REVLoanSource({token: loan.source.token, terminal: loan.source.terminal}));
@@ -995,6 +1013,9 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
995
1013
  }
996
1014
 
997
1015
  // Transfer the remaining balance to the borrower.
1016
+ // Note: In extreme fee configurations the subtraction could theoretically underflow, but the
1017
+ // protocol fee (2.5%) and source fee (capped at prepaidFeePercent) are both small fractions of
1018
+ // the borrowed amount, so `netAmountPaidOut` will always exceed their sum in practice.
998
1019
  _transferFrom({
999
1020
  from: address(this),
1000
1021
  to: beneficiary,
@@ -1035,7 +1056,9 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
1035
1056
  if (newCollateralCount > type(uint112).max) {
1036
1057
  revert REVLoans_OverflowAlert(newCollateralCount, type(uint112).max);
1037
1058
  }
1059
+ // forge-lint: disable-next-line(unsafe-typecast)
1038
1060
  loan.amount = uint112(newBorrowAmount);
1061
+ // forge-lint: disable-next-line(unsafe-typecast)
1039
1062
  loan.collateral = uint112(newCollateralCount);
1040
1063
 
1041
1064
  // INTERACTIONS: Execute external calls with pre-computed deltas.
@@ -1064,16 +1087,17 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
1064
1087
  });
1065
1088
  }
1066
1089
 
1067
- // If there is a source fee, pay it.
1090
+ // If there is a source fee, pay it. Wrapped in try-catch so a reverting source terminal
1091
+ // cannot block all loan operations (matching the REV fee pattern above).
1068
1092
  if (sourceFeeAmount > 0) {
1069
- // Increase the allowance for the beneficiary.
1093
+ // Increase the allowance for the source terminal.
1070
1094
  uint256 payValue = _beforeTransferTo({
1071
1095
  to: address(loan.source.terminal), token: loan.source.token, amount: sourceFeeAmount
1072
1096
  });
1073
1097
 
1074
- // Pay the fee.
1075
- // slither-disable-next-line unused-return
1076
- loan.source.terminal.pay{value: payValue}({
1098
+ // Pay the fee. If it fails, reclaim the allowance and give the amount back to the borrower.
1099
+ // slither-disable-next-line unused-return,arbitrary-send-eth
1100
+ try loan.source.terminal.pay{value: payValue}({
1077
1101
  projectId: revnetId,
1078
1102
  token: loan.source.token,
1079
1103
  amount: sourceFeeAmount,
@@ -1081,7 +1105,18 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
1081
1105
  minReturnedTokens: 0,
1082
1106
  memo: "Fee from loan",
1083
1107
  metadata: bytes(abi.encodePacked(REV_ID))
1084
- });
1108
+ }) {}
1109
+ catch (bytes memory) {
1110
+ // If the fee can't be processed, decrease the ERC-20 allowance and return the amount
1111
+ // to the beneficiary instead.
1112
+ if (loan.source.token != JBConstants.NATIVE_TOKEN) {
1113
+ IERC20(loan.source.token)
1114
+ .safeDecreaseAllowance({
1115
+ spender: address(loan.source.terminal), requestedDecrease: sourceFeeAmount
1116
+ });
1117
+ }
1118
+ _transferFrom({from: address(this), to: beneficiary, token: loan.source.token, amount: sourceFeeAmount});
1119
+ }
1085
1120
  }
1086
1121
  }
1087
1122
 
@@ -1351,6 +1386,7 @@ contract REVLoans is ERC721, ERC2771Context, Ownable, IREVLoans {
1351
1386
  if (amount > type(uint160).max) revert REVLoans_OverflowAlert(amount, type(uint160).max);
1352
1387
 
1353
1388
  // Otherwise, attempt to use the `permit2` method.
1389
+ // forge-lint: disable-next-line(unsafe-typecast)
1354
1390
  PERMIT2.transferFrom({from: from, to: to, amount: uint160(amount), token: token});
1355
1391
  }
1356
1392
 
@@ -8,7 +8,6 @@ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
8
8
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
9
9
  import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
10
10
  import {IJBBuybackHookRegistry} from "@bananapus/buyback-hook-v6/src/interfaces/IJBBuybackHookRegistry.sol";
11
- import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
12
11
  import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.sol";
13
12
  import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
14
13
  import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
@@ -6,6 +6,7 @@ pragma solidity ^0.8.0;
6
6
  /// @custom:member noNewTiersWithOwnerMinting A flag indicating if new tiers with owner minting are forbidden.
7
7
  /// @custom:member preventOverspending A flag indicating if payments exceeding the price of minted NFTs should be
8
8
  /// prevented.
9
+ // forge-lint: disable-next-line(pascal-case-struct)
9
10
  struct REV721TiersHookFlags {
10
11
  bool noNewTiersWithReserves;
11
12
  bool noNewTiersWithVotes;
@@ -4,6 +4,7 @@ pragma solidity ^0.8.0;
4
4
  /// @custom:member chainId The ID of the chain on which the mint should be honored.
5
5
  /// @custom:member count The number of tokens that should be minted.
6
6
  /// @custom:member beneficiary The address that will receive the minted tokens.
7
+ // forge-lint: disable-next-line(pascal-case-struct)
7
8
  struct REVAutoIssuance {
8
9
  uint32 chainId;
9
10
  uint104 count;
@@ -15,6 +15,7 @@ import {REV721TiersHookFlags} from "./REV721TiersHookFlags.sol";
15
15
  /// @custom:member reserveBeneficiary The default reserve beneficiary for the NFT collection.
16
16
  /// @custom:member flags A set of flags that configure the 721 hook. Omits `issueTokensForSplits` since revnets
17
17
  /// always force it to `false`.
18
+ // forge-lint: disable-next-line(pascal-case-struct)
18
19
  struct REVBaseline721HookConfig {
19
20
  string name;
20
21
  string symbol;
@@ -9,6 +9,7 @@ import {REVStageConfig} from "./REVStageConfig.sol";
9
9
  /// @custom:member splitOperator The address that will receive the token premint and initial production split,
10
10
  /// and who is allowed to change who the operator is. Only the operator can replace itself after deployment.
11
11
  /// @custom:member stageConfigurations The periods of changing constraints.
12
+ // forge-lint: disable-next-line(pascal-case-struct)
12
13
  struct REVConfig {
13
14
  REVDescription description;
14
15
  uint32 baseCurrency;
@@ -10,6 +10,7 @@ pragma solidity ^0.8.0;
10
10
  /// @custom:member maximumSplitPercent The maximum split percent (out of JBConstants.SPLITS_TOTAL_PERCENT) that a
11
11
  /// poster can set. 0 means splits are not allowed.
12
12
  /// @custom:member allowedAddresses A list of addresses that are allowed to post on the category through Croptop.
13
+ // forge-lint: disable-next-line(pascal-case-struct)
13
14
  struct REVCroptopAllowedPost {
14
15
  uint24 category;
15
16
  uint104 minimumPrice;
@@ -13,6 +13,7 @@ import {REVBaseline721HookConfig} from "./REVBaseline721HookConfig.sol";
13
13
  /// minting 721's from tiers that allow it.
14
14
  /// @custom:member preventSplitOperatorIncreasingDiscountPercent A flag indicating if the revnet's split operator should
15
15
  /// be prevented from increasing the discount of a tier.
16
+ // forge-lint: disable-next-line(pascal-case-struct)
16
17
  struct REVDeploy721TiersHookConfig {
17
18
  REVBaseline721HookConfig baseline721HookConfiguration;
18
19
  bytes32 salt;
@@ -6,6 +6,7 @@ pragma solidity ^0.8.0;
6
6
  /// @custom:member uri The metadata URI containing revnet's info.
7
7
  /// @custom:member salt Revnets deployed across chains by the same address with the same salt will have the same
8
8
  /// address.
9
+ // forge-lint: disable-next-line(pascal-case-struct)
9
10
  struct REVDescription {
10
11
  string name;
11
12
  string ticker;
@@ -9,6 +9,7 @@ import {REVLoanSource} from "./REVLoanSource.sol";
9
9
  /// @custom:member prepaidFeePercent The percentage of the loan's fees that were prepaid.
10
10
  /// @custom:member prepaidDuration The duration that the loan was prepaid for.
11
11
  /// @custom:member source The source of the loan.
12
+ // forge-lint: disable-next-line(pascal-case-struct)
12
13
  struct REVLoan {
13
14
  uint112 amount;
14
15
  uint112 collateral;
@@ -5,6 +5,7 @@ import {IJBPayoutTerminal} from "@bananapus/core-v6/src/interfaces/IJBPayoutTerm
5
5
 
6
6
  /// @custom:member token The token that is being loaned.
7
7
  /// @custom:member terminal The terminal that the loan is being made from.
8
+ // forge-lint: disable-next-line(pascal-case-struct)
8
9
  struct REVLoanSource {
9
10
  address token;
10
11
  IJBPayoutTerminal terminal;
@@ -21,6 +21,7 @@ import {REVAutoIssuance} from "./REVAutoIssuance.sol";
21
21
  /// cashed out. This rate is out of 10_000 (JBConstants.MAX_CASH_OUT_TAX_RATE). 0% corresponds to no tax when cashing
22
22
  /// out.
23
23
  /// @custom:member extraMetadata Extra info to attach set into this stage that may affect hooks.
24
+ // forge-lint: disable-next-line(pascal-case-struct)
24
25
  struct REVStageConfig {
25
26
  uint48 startsAtOrAfter;
26
27
  REVAutoIssuance[] autoIssuances;
@@ -5,6 +5,7 @@ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSucker
5
5
 
6
6
  /// @custom:member deployerConfigurations The information for how to suck tokens to other chains.
7
7
  /// @custom:member salt The salt to use for creating suckers so that they use the same address across chains.
8
+ // forge-lint: disable-next-line(pascal-case-struct)
8
9
  struct REVSuckerDeploymentConfig {
9
10
  JBSuckerDeployerConfig[] deployerConfigurations;
10
11
  bytes32 salt;