@rev-net/core-v6 0.0.72 → 0.0.74

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rev-net/core-v6",
3
- "version": "0.0.72",
3
+ "version": "0.0.74",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  "dependencies": {
27
27
  "@bananapus/721-hook-v6": "^0.0.55",
28
28
  "@bananapus/buyback-hook-v6": "^0.0.51",
29
- "@bananapus/core-v6": "^0.0.60",
29
+ "@bananapus/core-v6": "^0.0.64",
30
30
  "@bananapus/ownable-v6": "^0.0.28",
31
31
  "@bananapus/permission-ids-v6": "^0.0.27",
32
32
  "@bananapus/router-terminal-v6": "^0.0.49",
@@ -91,7 +91,7 @@ Use this file when you need revnet-specific risks, state reads, constants, or ex
91
91
  | Constant | Value | Purpose |
92
92
  |----------|-------|---------|
93
93
  | `CASH_OUT_DELAY` | 2,592,000 (30 days) | Prevents cross-chain liquidity arbitrage on new chain deployments |
94
- | `FEE` | 25 (of MAX_FEE=1000) | 2.5% cash-out fee paid to fee revnet |
94
+ | `FEE` | 25 (of MAX_FEE=1000) | 2.5% cash-out fee paid to fee revnet for non-zero-tax ordinary cash-outs |
95
95
  | `DEFAULT_BUYBACK_POOL_FEE` | 10,000 | 1% Uniswap fee tier for default buyback pools |
96
96
  | `DEFAULT_BUYBACK_TWAP_WINDOW` | 2 days | TWAP observation window for buyback price |
97
97
  | `DEFAULT_BUYBACK_TICK_SPACING` | 200 | Tick spacing for default buyback V4 pools |
@@ -147,7 +147,7 @@ Use this file when you need revnet-specific risks, state reads, constants, or ex
147
147
  4. **Loan ID encoding.** `loanId = revnetId * 1_000_000_000_000 + loanNumber`. Each revnet supports ~1 trillion loans. Use `revnetIdOfLoanWith(loanId)` to decode.
148
148
  5. **uint112 truncation risk.** `REVLoan.amount` and `REVLoan.collateral` are `uint112`. Values above ~5.19e33 truncate silently.
149
149
  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.
150
- 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.
150
+ 7. **Cash-out fee stacking.** Non-zero-tax ordinary 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% revnet 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. Zero-tax ordinary cash-outs route through the buyback hook without adding the revnet fee hook, matching current code behavior. This is by design.
151
151
  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 stored on REVOwner (`cashOutDelayOf(revnetId)`) and populated by REVDeployer during deployment via the bundled `initializeRevnet()` call. REVLoans imports IREVOwner (not IREVDeployer) to read it.
152
152
  9. **`cashOutTaxRate` cannot be MAX.** Must be strictly less than `MAX_CASH_OUT_TAX_RATE` (10,000). Revnets cannot fully disable cash outs.
153
153
  10. **Split operator is singular.** Only ONE address can be operator at a time. The operator can replace itself via `setOperatorOf` but cannot delegate or multi-sig.
@@ -29,7 +29,7 @@ Deploy and manage Revnets -- autonomous, unowned Juicebox projects with staged i
29
29
  | Function | Permissions | What it does |
30
30
  |----------|------------|-------------|
31
31
  | `REVOwner.beforePayRecordedWith(context)` | Terminal callback | Calls the 721 hook first for split specs, then calls the buyback hook with a reduced amount context (payment minus split amount). Adjusts the returned weight proportionally for splits (`weight = mulDiv(weight, amount - splitAmount, amount)`) so the terminal only mints tokens for the amount entering the project. Assembles pay hook specs (721 hook specs first, then buyback spec). Reads `tiered721HookOf` from REVOwner storage. |
32
- | `REVOwner.beforeCashOutRecordedWith(context)` | Terminal callback | If sucker: returns full amount with 0 tax (fee exempt). Otherwise: calculates 2.5% fee, enforces 30-day cash-out delay (reads `cashOutDelayOf` from REVOwner storage), returns modified count + fee hook spec. |
32
+ | `REVOwner.beforeCashOutRecordedWith(context)` | Terminal callback | If sucker: returns full amount with 0 tax (fee exempt). Otherwise: enforces the 30-day cash-out delay, routes zero-tax ordinary cash-outs through the buyback hook without a revnet fee, and only calculates the 2.5% revnet fee when `cashOutTaxRate != 0`. |
33
33
  | `REVOwner.afterCashOutRecordedWith(context)` | Permissionless | Cash-out hook callback. Receives fee amount and pays it to the fee revnet's terminal. Falls back to returning funds if fee payment fails. |
34
34
  | `REVOwner.hasMintPermissionFor(revnetId, ruleset, addr)` | View | Returns `true` for: loans contract, buyback hook, buyback hook delegates, or suckers. |
35
35
  | `REVOwner.cashOutDelayOf(revnetId)` | View | Returns the cash-out delay timestamp from REVOwner storage. Exposed for REVLoans compatibility (REVLoans imports IREVOwner for this). |
@@ -6,7 +6,6 @@ import {
6
6
  BuybackDeployment,
7
7
  BuybackDeploymentLib
8
8
  } from "@bananapus/buyback-hook-v6/script/helpers/BuybackDeploymentLib.sol";
9
- import {CoreDeployment, CoreDeploymentLib} from "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
10
9
  import {SuckerDeployment, SuckerDeploymentLib} from "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
11
10
  import {
12
11
  RouterTerminalDeployment,
@@ -44,6 +43,7 @@ import {JB721InitTiersConfig} from "@bananapus/721-hook-v6/src/structs/JB721Init
44
43
  import {JB721TierConfig} from "@bananapus/721-hook-v6/src/structs/JB721TierConfig.sol";
45
44
  import {REVBaseline721HookConfig} from "../src/structs/REVBaseline721HookConfig.sol";
46
45
  import {REV721TiersHookFlags} from "../src/structs/REV721TiersHookFlags.sol";
46
+ import {CoreDeployment, CoreDeploymentLib} from "./helpers/CoreDeploymentLib.sol";
47
47
 
48
48
  struct FeeProjectConfig {
49
49
  REVConfig configuration;
@@ -426,7 +426,7 @@ contract DeployScript is Script, Sphinx {
426
426
  }
427
427
  }
428
428
  if (!_foundExisting) {
429
- feeProjectId = core.projects.createFor(safeAddress());
429
+ feeProjectId = core.projects.createFor{value: core.projects.creationFee()}(safeAddress());
430
430
  }
431
431
  }
432
432
 
@@ -0,0 +1,172 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {stdJson} from "forge-std/Script.sol";
5
+ import {Vm} from "forge-std/Vm.sol";
6
+
7
+ import {JBController} from "@bananapus/core-v6/src/JBController.sol";
8
+ import {JBDirectory} from "@bananapus/core-v6/src/JBDirectory.sol";
9
+ import {JBFeelessAddresses} from "@bananapus/core-v6/src/JBFeelessAddresses.sol";
10
+ import {JBFundAccessLimits} from "@bananapus/core-v6/src/JBFundAccessLimits.sol";
11
+ import {JBMultiTerminal} from "@bananapus/core-v6/src/JBMultiTerminal.sol";
12
+ import {JBPermissions} from "@bananapus/core-v6/src/JBPermissions.sol";
13
+ import {JBPrices} from "@bananapus/core-v6/src/JBPrices.sol";
14
+ import {JBProjects} from "@bananapus/core-v6/src/JBProjects.sol";
15
+ import {JBRulesets} from "@bananapus/core-v6/src/JBRulesets.sol";
16
+ import {JBSplits} from "@bananapus/core-v6/src/JBSplits.sol";
17
+ import {JBTerminalStore} from "@bananapus/core-v6/src/JBTerminalStore.sol";
18
+ import {JBTokens} from "@bananapus/core-v6/src/JBTokens.sol";
19
+
20
+ import {SphinxConstants, NetworkInfo} from "@sphinx-labs/contracts/contracts/foundry/SphinxConstants.sol";
21
+
22
+ struct CoreDeployment {
23
+ JBPermissions permissions;
24
+ JBProjects projects;
25
+ JBDirectory directory;
26
+ JBSplits splits;
27
+ JBRulesets rulesets;
28
+ JBController controller;
29
+ JBMultiTerminal terminal;
30
+ JBTerminalStore terminalStore;
31
+ JBPrices prices;
32
+ JBFeelessAddresses feeless;
33
+ JBFundAccessLimits fundAccess;
34
+ JBTokens tokens;
35
+ address trustedForwarder;
36
+ }
37
+
38
+ library CoreDeploymentLib {
39
+ address internal constant VM_ADDRESS = address(uint160(uint256(keccak256("hevm cheat code"))));
40
+ // forge-lint: disable-next-line(screaming-snake-case-const)
41
+ Vm internal constant vm = Vm(VM_ADDRESS);
42
+ string internal constant PROJECT_NAME = "nana-core-v6";
43
+
44
+ function getDeployment(string memory path) internal returns (CoreDeployment memory deployment) {
45
+ uint256 chainId = block.chainid;
46
+
47
+ SphinxConstants sphinxConstants = new SphinxConstants();
48
+ NetworkInfo[] memory networks = sphinxConstants.getNetworkInfoArray();
49
+
50
+ for (uint256 i; i < networks.length; i++) {
51
+ if (networks[i].chainId == chainId) return getDeployment({path: path, networkName: networks[i].name});
52
+ }
53
+
54
+ revert("ChainID is not (currently) supported by Sphinx.");
55
+ }
56
+
57
+ function getDeployment(
58
+ string memory path,
59
+ string memory networkName
60
+ )
61
+ internal
62
+ returns (CoreDeployment memory deployment)
63
+ {
64
+ deployment.permissions = JBPermissions(
65
+ _getDeploymentAddress({
66
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "JBPermissions"
67
+ })
68
+ );
69
+
70
+ deployment.projects = JBProjects(
71
+ _getDeploymentAddress({
72
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "JBProjects"
73
+ })
74
+ );
75
+
76
+ deployment.directory = JBDirectory(
77
+ _getDeploymentAddress({
78
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "JBDirectory"
79
+ })
80
+ );
81
+
82
+ deployment.splits = JBSplits(
83
+ _getDeploymentAddress({
84
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "JBSplits"
85
+ })
86
+ );
87
+
88
+ deployment.rulesets = JBRulesets(
89
+ _getDeploymentAddress({
90
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "JBRulesets"
91
+ })
92
+ );
93
+
94
+ deployment.controller = JBController(
95
+ _tryGetDeploymentAddress({
96
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "JBController"
97
+ })
98
+ );
99
+
100
+ deployment.terminal = JBMultiTerminal(
101
+ _getDeploymentAddress({
102
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "JBMultiTerminal"
103
+ })
104
+ );
105
+
106
+ deployment.terminalStore = JBTerminalStore(
107
+ _getDeploymentAddress({
108
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "JBTerminalStore"
109
+ })
110
+ );
111
+
112
+ deployment.prices = JBPrices(
113
+ _getDeploymentAddress({
114
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "JBPrices"
115
+ })
116
+ );
117
+
118
+ deployment.feeless = JBFeelessAddresses(
119
+ _getDeploymentAddress({
120
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "JBFeelessAddresses"
121
+ })
122
+ );
123
+
124
+ deployment.fundAccess = JBFundAccessLimits(
125
+ _getDeploymentAddress({
126
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "JBFundAccessLimits"
127
+ })
128
+ );
129
+
130
+ deployment.tokens = JBTokens(
131
+ _getDeploymentAddress({
132
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "JBTokens"
133
+ })
134
+ );
135
+
136
+ deployment.trustedForwarder = _getDeploymentAddress({
137
+ path: path, projectName: PROJECT_NAME, networkName: networkName, contractName: "ERC2771Forwarder"
138
+ });
139
+ }
140
+
141
+ function _getDeploymentAddress(
142
+ string memory path,
143
+ string memory projectName,
144
+ string memory networkName,
145
+ string memory contractName
146
+ )
147
+ internal
148
+ view
149
+ returns (address)
150
+ {
151
+ string memory deploymentJson =
152
+ // forge-lint: disable-next-line(unsafe-cheatcode)
153
+ vm.readFile(string.concat(path, projectName, "/", networkName, "/", contractName, ".json"));
154
+ return stdJson.readAddress({json: deploymentJson, key: ".address"});
155
+ }
156
+
157
+ function _tryGetDeploymentAddress(
158
+ string memory path,
159
+ string memory projectName,
160
+ string memory networkName,
161
+ string memory contractName
162
+ )
163
+ internal
164
+ returns (address)
165
+ {
166
+ string memory filePath = string.concat(path, projectName, "/", networkName, "/", contractName, ".json");
167
+ // forge-lint: disable-next-line(unsafe-cheatcode)
168
+ if (!vm.exists(filePath)) return address(0);
169
+ // forge-lint: disable-next-line(unsafe-cheatcode)
170
+ return stdJson.readAddress({json: vm.readFile(filePath), key: ".address"});
171
+ }
172
+ }
@@ -24,6 +24,7 @@ import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.so
24
24
  import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol";
25
25
  import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
26
26
  import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
27
+ import {JBOwnable} from "@bananapus/ownable-v6/src/JBOwnable.sol";
27
28
  import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
28
29
  import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
29
30
  import {CTPublisher} from "@croptop/core-v6/src/CTPublisher.sol";
@@ -63,6 +64,7 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
63
64
  error REVDeployer_AutoIssuanceBeneficiaryZeroAddress(uint256 stageIndex, uint256 autoIssuanceIndex);
64
65
  error REVDeployer_CashOutsCantBeTurnedOffCompletely(uint256 cashOutTaxRate, uint256 maxCashOutTaxRate);
65
66
  error REVDeployer_MustHaveSplits(uint256 stageIndex, uint256 splitPercent);
67
+ error REVDeployer_ProjectCreationFeeNotNeeded(uint256 revnetId, uint256 value);
66
68
  error REVDeployer_RulesetDoesNotAllowDeployingSuckers(uint256 revnetId);
67
69
  error REVDeployer_StagesRequired(uint256 stageCount);
68
70
  error REVDeployer_StageTimesMustIncrease(uint256 stageIndex, uint256 previousStageStart, uint256 effectiveStart);
@@ -345,6 +347,27 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
345
347
  }
346
348
  }
347
349
 
350
+ /// @notice Calculate a Uniswap V4 sqrt price, returning zero if the ratio is out of range.
351
+ /// @param numerator The numerator of the raw token price ratio.
352
+ /// @param denominator The denominator of the raw token price ratio.
353
+ /// @return sqrtPriceX96 The encoded sqrt price, or zero when it cannot be represented.
354
+ function _sqrtPriceX96From(uint256 numerator, uint256 denominator) internal pure returns (uint160 sqrtPriceX96) {
355
+ // Q192 is the fixed-point scale Uniswap uses before taking the square root.
356
+ uint256 q192 = 1 << 192;
357
+
358
+ // `mulDiv` reverts if `numerator * Q192 / denominator` exceeds uint256.
359
+ uint256 maxRatio = type(uint256).max / q192;
360
+
361
+ // Cap the numerator at a conservative bound that keeps the scaled ratio representable.
362
+ uint256 maxNumerator = denominator > type(uint256).max / maxRatio ? type(uint256).max : maxRatio * denominator;
363
+
364
+ // A zero denominator is invalid, and an out-of-range numerator means this pool price should be skipped.
365
+ if (denominator == 0 || numerator > maxNumerator) return 0;
366
+
367
+ // The bounded ratio fits in uint256, and its square root always fits in uint160.
368
+ sqrtPriceX96 = uint160(sqrt(mulDiv({x: numerator, y: q192, denominator: denominator})));
369
+ }
370
+
348
371
  /// @notice Try to initialize a Uniswap V4 buyback pool for a terminal token at its fair issuance price.
349
372
  /// @dev Called after the ERC-20 token is deployed so the pool can be initialized in the PoolManager.
350
373
  /// Computes `sqrtPriceX96` from `initialIssuance` so the pool starts at the same price as the bonding curve.
@@ -406,13 +429,15 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
406
429
  sqrtPriceX96 = uint160(1 << 96);
407
430
  } else if (normalizedTerminalToken < projectToken) {
408
431
  // token0 = terminal, token1 = project → price = adjustedIssuance / terminalTokenUnit
409
- sqrtPriceX96 =
410
- uint160(sqrt(mulDiv({x: adjustedInitialIssuance, y: 1 << 192, denominator: terminalTokenUnit})));
432
+ sqrtPriceX96 = _sqrtPriceX96From({numerator: adjustedInitialIssuance, denominator: terminalTokenUnit});
411
433
  } else {
412
434
  // token0 = project, token1 = terminal → price = terminalTokenUnit / adjustedIssuance
413
- sqrtPriceX96 =
414
- uint160(sqrt(mulDiv({x: terminalTokenUnit, y: 1 << 192, denominator: adjustedInitialIssuance})));
435
+ sqrtPriceX96 = _sqrtPriceX96From({numerator: terminalTokenUnit, denominator: adjustedInitialIssuance});
415
436
  }
437
+
438
+ // Some extreme cross-currency prices are outside Uniswap's usable sqrt-price range. In those cases,
439
+ // leave the pool uninitialized instead of reverting the whole revnet deployment.
440
+ if (sqrtPriceX96 == 0) return;
416
441
  }
417
442
 
418
443
  try BUYBACK_HOOK.initializePoolFor({
@@ -468,6 +493,7 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
468
493
  REVCroptopAllowedPost[] calldata allowedPosts
469
494
  )
470
495
  external
496
+ payable
471
497
  override
472
498
  returns (uint256, IJB721TiersHook hook)
473
499
  {
@@ -475,7 +501,11 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
475
501
  bool shouldDeployNewRevnet = revnetId == 0;
476
502
 
477
503
  // If the caller is deploying a new revnet, reserve its project ID before deriving hook/sucker config.
478
- if (shouldDeployNewRevnet) revnetId = PROJECTS.createFor(address(this));
504
+ if (shouldDeployNewRevnet) {
505
+ revnetId = PROJECTS.createFor{value: msg.value}(address(this));
506
+ } else if (msg.value != 0) {
507
+ revert REVDeployer_ProjectCreationFeeNotNeeded({revnetId: revnetId, value: msg.value});
508
+ }
479
509
 
480
510
  // Deploy the revnet with the specified tiered ERC-721 hook and croptop posting criteria.
481
511
  hook = _deploy721RevnetFor({
@@ -500,11 +530,16 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
500
530
  REVSuckerDeploymentConfig calldata suckerDeploymentConfiguration
501
531
  )
502
532
  external
533
+ payable
503
534
  override
504
535
  returns (uint256, IJB721TiersHook hook)
505
536
  {
506
537
  bool shouldDeployNewRevnet = revnetId == 0;
507
- if (shouldDeployNewRevnet) revnetId = PROJECTS.createFor(address(this));
538
+ if (shouldDeployNewRevnet) {
539
+ revnetId = PROJECTS.createFor{value: msg.value}(address(this));
540
+ } else if (msg.value != 0) {
541
+ revert REVDeployer_ProjectCreationFeeNotNeeded({revnetId: revnetId, value: msg.value});
542
+ }
508
543
 
509
544
  // Deploy the revnet (project, rulesets, ERC-20, suckers, etc.).
510
545
  (bytes32 encodedConfigurationHash, REVOwnerRevnetInit memory ownerInit) = _deployRevnetFor({
@@ -528,6 +563,9 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
528
563
  });
529
564
  }
530
565
 
566
+ // Scope the hook's permissions to REVOwner, where the operator permissions are granted.
567
+ JBOwnable(address(hook)).transferOwnership(OWNER);
568
+
531
569
  // Grant the operator all 721 permissions (no prevent* flags for default config).
532
570
  ownerInit.extraOperatorPermissionIds = new uint256[](4);
533
571
  ownerInit.extraOperatorPermissionIds[0] = JBPermissionIds.ADJUST_721_TIERS;
@@ -630,6 +668,9 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
630
668
  salt: keccak256(abi.encode(tiered721HookConfiguration.salt, encodedConfigurationHash, _msgSender()))
631
669
  });
632
670
 
671
+ // Scope the hook's permissions to REVOwner, where the operator permissions are granted.
672
+ JBOwnable(address(hook)).transferOwnership(OWNER);
673
+
633
674
  // Build the 721 permission additions based on the deployer's `preventOperator*` flags.
634
675
  {
635
676
  uint256 extraCount;
package/src/REVLoans.sol CHANGED
@@ -4,6 +4,7 @@ pragma solidity 0.8.28;
4
4
  import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
5
5
  import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
6
6
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
+ import {IJBMultiTerminal} from "@bananapus/core-v6/src/interfaces/IJBMultiTerminal.sol";
7
8
  import {IJBPayoutTerminal} from "@bananapus/core-v6/src/interfaces/IJBPayoutTerminal.sol";
8
9
  import {IJBPermissioned} from "@bananapus/core-v6/src/interfaces/IJBPermissioned.sol";
9
10
  import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
@@ -58,6 +59,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
58
59
 
59
60
  error REVLoans_CashOutDelayNotFinished(uint256 cashOutDelay, uint256 blockTimestamp);
60
61
  error REVLoans_CollateralExceedsLoan(uint256 collateralToReturn, uint256 loanCollateral);
62
+ error REVLoans_FeeOnTransferSourceUnsupported(address token, uint256 expectedAmount, uint256 creditedAmount);
61
63
  error REVLoans_InvalidAccountingContext(uint256 revnetId, address token);
62
64
  error REVLoans_InvalidPrepaidFeePercent(uint256 prepaidFeePercent, uint256 min, uint256 max);
63
65
  error REVLoans_LoanExpired(uint256 timeSinceLoanCreated, uint256 loanLiquidationDuration);
@@ -682,11 +684,20 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
682
684
  override
683
685
  nonReentrantLoanAction
684
686
  {
685
- // Prevent cross-revnet accounting corruption: loan numbers must stay within the revnet's ID namespace.
686
- uint256 endLoanNumber = startingLoanId + count;
687
- if (endLoanNumber > _ONE_TRILLION) {
687
+ // No loans are checked when count is zero.
688
+ if (count == 0) return;
689
+
690
+ // Prevent cross-revnet accounting corruption: every iterated loan number must stay within the revnet's ID
691
+ // namespace.
692
+ if (startingLoanId > _ONE_TRILLION) {
688
693
  revert REVLoans_LoanIdOverflow({
689
- revnetId: revnetId, loanNumber: endLoanNumber, maxLoanNumber: _ONE_TRILLION
694
+ revnetId: revnetId, loanNumber: startingLoanId, maxLoanNumber: _ONE_TRILLION
695
+ });
696
+ }
697
+ uint256 maxCount = _ONE_TRILLION - startingLoanId + 1;
698
+ if (count > maxCount) {
699
+ revert REVLoans_LoanIdOverflow({
700
+ revnetId: revnetId, loanNumber: _ONE_TRILLION + 1, maxLoanNumber: _ONE_TRILLION
690
701
  });
691
702
  }
692
703
 
@@ -1196,6 +1207,12 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1196
1207
 
1197
1208
  // INTERACTIONS: Execute external calls with pre-computed deltas.
1198
1209
 
1210
+ // Burn newly added collateral before pulling source funds so fee-project token mints from the borrow cannot
1211
+ // be used as same-transaction collateral.
1212
+ if (addedCollateralCount > 0) {
1213
+ _addCollateralTo({revnetId: revnetId, amount: addedCollateralCount, holder: holder});
1214
+ }
1215
+
1199
1216
  // Add to the loan if needed...
1200
1217
  if (addedBorrowAmount > 0) {
1201
1218
  _addTo({
@@ -1210,11 +1227,8 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1210
1227
  _removeFrom({loan: loan, revnetId: revnetId, repaidBorrowAmount: repaidBorrowAmount});
1211
1228
  }
1212
1229
 
1213
- // Add collateral if needed...
1214
- if (addedCollateralCount > 0) {
1215
- _addCollateralTo({revnetId: revnetId, amount: addedCollateralCount, holder: holder});
1216
- // ... or return collateral if needed.
1217
- } else if (returnedCollateralCount > 0) {
1230
+ // Return collateral if needed.
1231
+ if (returnedCollateralCount > 0) {
1218
1232
  _returnCollateralFrom({
1219
1233
  revnetId: revnetId, collateralCount: returnedCollateralCount, beneficiary: beneficiary
1220
1234
  });
@@ -1477,8 +1491,10 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1477
1491
  function _removeFrom(REVLoan memory loan, uint256 revnetId, uint256 repaidBorrowAmount) internal {
1478
1492
  address sourceToken = loan.sourceToken;
1479
1493
 
1480
- // Decrement the total amount of a token being loaned out by the revnet.
1481
- totalBorrowedFrom[revnetId][sourceToken] -= repaidBorrowAmount;
1494
+ IJBMultiTerminal terminal = IJBMultiTerminal(address(TERMINAL));
1495
+
1496
+ // Snapshot the credited terminal balance so fee-on-transfer source tokens cannot under-repay the revnet.
1497
+ uint256 balanceBefore = terminal.STORE().balanceOf(address(TERMINAL), revnetId, sourceToken);
1482
1498
 
1483
1499
  // Increase the allowance for the beneficiary.
1484
1500
  uint256 payValue = _beforeTransferTo({to: address(TERMINAL), token: sourceToken, amount: repaidBorrowAmount});
@@ -1493,6 +1509,17 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
1493
1509
  metadata: bytes(abi.encodePacked(REV_ID))
1494
1510
  });
1495
1511
 
1512
+ uint256 creditedAmount = terminal.STORE().balanceOf(address(TERMINAL), revnetId, sourceToken) - balanceBefore;
1513
+
1514
+ if (creditedAmount != repaidBorrowAmount) {
1515
+ revert REVLoans_FeeOnTransferSourceUnsupported({
1516
+ token: sourceToken, expectedAmount: repaidBorrowAmount, creditedAmount: creditedAmount
1517
+ });
1518
+ }
1519
+
1520
+ // Decrement the total amount of a token being loaned out by the revnet.
1521
+ totalBorrowedFrom[revnetId][sourceToken] -= repaidBorrowAmount;
1522
+
1496
1523
  _afterTransferTo({to: address(TERMINAL), token: sourceToken});
1497
1524
  }
1498
1525
 
package/src/REVOwner.sol CHANGED
@@ -41,8 +41,8 @@ import {REVOwnerRevnetInit} from "./structs/REVOwnerRevnetInit.sol";
41
41
  /// @notice The runtime hook for all revnets — set as every revnet's `dataHook` in ruleset metadata. At pay time, it
42
42
  /// coordinates the 721 hook (NFT tier minting) with the buyback hook (secondary market swap routing) and scales weight
43
43
  /// for split deductions. At cash-out time, it aggregates cross-chain total supply and surplus (including outstanding
44
- /// loan debt and collateral), grants suckers 0% tax, splits a 2.5% fee from non-sucker cash outs, and routes fee
45
- /// proceeds to the fee revnet via `afterCashOutRecordedWith`.
44
+ /// loan debt and collateral), grants suckers 0% tax, splits a 2.5% fee from non-sucker cash outs with a non-zero
45
+ /// cash-out tax, and routes fee proceeds to the fee revnet via `afterCashOutRecordedWith`.
46
46
  /// @dev Separated from `REVDeployer` to stay within the EIP-170 contract size limit. Also implements
47
47
  /// `IJBPeerChainAdjustedAccounts` to expose loan state to peer-chain supply/surplus snapshots.
48
48
  contract REVOwner is IREVOwner, IJBRulesetDataHook, IJBCashOutHook, IJBPeerChainAdjustedAccounts, IERC721Receiver {
@@ -167,12 +167,12 @@ contract REVOwner is IREVOwner, IJBRulesetDataHook, IJBCashOutHook, IJBPeerChain
167
167
 
168
168
  /// @notice Called before a cash out is recorded. Suckers get 0% tax (bridged tokens redeem at face value). For
169
169
  /// regular holders, aggregates cross-chain total supply and surplus (including outstanding loan debt/collateral),
170
- /// splits a 2.5% fee from the cashed-out token count, computes bonding curve reclaims for both the holder's portion
171
- /// and the fee portion, then delegates to the buyback hook for potential swap routing.
172
- /// @dev Part of `IJBRulesetDataHook`. REVOwner is intentionally not registered as a feeless address — the
173
- /// protocol
174
- /// fee (2.5%) applies on top of the rev fee. The fee hook spec amount sent to `afterCashOutRecordedWith` will have
175
- /// the protocol fee deducted by the terminal before reaching this contract.
170
+ /// splits a 2.5% fee from the cashed-out token count when cash-out tax is non-zero, computes bonding curve
171
+ /// reclaims for both the holder's portion and the fee portion, then delegates to the buyback hook for potential
172
+ /// swap routing.
173
+ /// @dev Part of `IJBRulesetDataHook`. In the non-zero-tax fee path, REVOwner is intentionally not registered as a
174
+ /// feeless address — the protocol fee (2.5%) applies on top of the rev fee. The fee hook spec amount sent to
175
+ /// `afterCashOutRecordedWith` will have the protocol fee deducted by the terminal before reaching this contract.
176
176
  /// @param context Standard Juicebox cash out context. See `JBBeforeCashOutRecordedContext`.
177
177
  /// @return cashOutTaxRate The cash out tax rate, which influences the amount of terminal tokens reclaimed.
178
178
  /// @return cashOutCount The number of revnet tokens to cash out.
@@ -232,9 +232,9 @@ contract REVOwner is IREVOwner, IJBRulesetDataHook, IJBCashOutHook, IJBPeerChain
232
232
  });
233
233
  }
234
234
 
235
- // If there's no cash out tax (100% cash out tax rate), if there's no fee terminal, or if the beneficiary is
236
- // feeless (e.g. the router terminal routing value between projects), proxy to the buyback hook with our
237
- // totalSupply and effectiveSurplusValue.
235
+ // If there's no cash out tax, if there's no fee terminal, or if the beneficiary is feeless (e.g. the router
236
+ // terminal routing value between projects), proxy to the buyback hook with our totalSupply and
237
+ // effectiveSurplusValue. Zero-tax ordinary cash-outs do not add the revnet fee hook.
238
238
  if (context.cashOutTaxRate == 0 || address(feeTerminal) == address(0) || context.beneficiaryIsFeeless) {
239
239
  // Build a modified context with cross-chain-adjusted values so the buyback hook sees the global state
240
240
  // for its swap-vs-passthrough routing decision.
@@ -156,6 +156,7 @@ interface IREVDeployer {
156
156
  REVCroptopAllowedPost[] memory allowedPosts
157
157
  )
158
158
  external
159
+ payable
159
160
  returns (uint256, IJB721TiersHook hook);
160
161
 
161
162
  /// @notice Deploy a revnet with a default empty tiered ERC-721 hook.
@@ -173,6 +174,7 @@ interface IREVDeployer {
173
174
  REVSuckerDeploymentConfig memory suckerDeploymentConfiguration
174
175
  )
175
176
  external
177
+ payable
176
178
  returns (uint256, IJB721TiersHook hook);
177
179
 
178
180
  /// @notice Deploy new suckers for an existing revnet.