@bananapus/univ4-lp-split-hook-v6 0.0.9 → 0.0.11

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/ADMINISTRATION.md CHANGED
@@ -33,7 +33,7 @@ Admin privileges and their scope in univ4-lp-split-hook-v6.
33
33
 
34
34
  | Function | Required Role | Permission ID | Scope | What It Does |
35
35
  |----------|--------------|---------------|-------|-------------|
36
- | `deployPool(projectId, terminalToken, amount0Min, amount1Min, minCashOutReturn)` | Project owner or SET_BUYBACK_POOL operator. **Becomes permissionless** when the current ruleset weight has decayed to 1/10th or less of `initialWeightOf[projectId]`. | `JBPermissionIds.SET_BUYBACK_POOL` (26) | Per-project, per-terminal-token | Creates a Uniswap V4 pool at the geometric mean of issuance/cashout rates. Cashes out a computed fraction of accumulated project tokens for terminal tokens, mints a concentrated LP position, and transitions the project from accumulation to burn mode. (Lines 491-531) |
36
+ | `deployPool(projectId, terminalToken, amount0Min, amount1Min, minCashOutReturn)` | Project owner or SET_BUYBACK_POOL operator. **Becomes permissionless** when the current ruleset weight has decayed to 1/10th or less of `initialWeightOf[projectId]`. | `JBPermissionIds.SET_BUYBACK_POOL` (26) | Per-project, single terminal-token path | Creates a Uniswap V4 pool at the geometric mean of issuance/cashout rates. Cashes out a computed fraction of accumulated project tokens for terminal tokens, mints a concentrated LP position, and transitions the project from accumulation to burn mode. This permanently commits the hook instance to one terminal-token path for that project. |
37
37
  | `rebalanceLiquidity(projectId, terminalToken, ...)` | Project owner or SET_BUYBACK_POOL operator | `JBPermissionIds.SET_BUYBACK_POOL` (26) | Per-project, per-terminal-token | Burns the existing LP position NFT, collects and routes accrued fees, recalculates tick bounds from current issuance/cashout rates, and mints a new position with updated bounds. Reverts with `InsufficientLiquidity` if the new position would have zero liquidity. (Lines 559-658) |
38
38
  | `claimFeeTokensFor(projectId, beneficiary)` | Project owner or SET_BUYBACK_POOL operator | `JBPermissionIds.SET_BUYBACK_POOL` (26) | Per-project | Transfers accumulated fee-project tokens to the specified beneficiary address. Validates the caller's permission, not the beneficiary's identity. Zeroes `claimableFeeTokens[projectId]` before transferring. (Lines 441-456) |
39
39
  | `processSplitWith(context)` | JB Controller (system) | None (checked via `controllerOf`) | Per-project | Only callable by the project's registered controller. Accumulates project tokens (pre-deployment) or burns them (post-deployment). Validates `context.split.hook == address(this)`, `groupId == 1`, and controller identity. (Lines 534-555) |
@@ -98,6 +98,7 @@ What admins **cannot** do:
98
98
  4. **Cannot modify pool parameters after deployment.** The `PoolKey` (fee tier, tick spacing, hook address, currency pair) is set during `_createAndInitializePool()` and stored immutably in `_poolKeys`.
99
99
 
100
100
  5. **Cannot deploy a second pool for the same project/terminal-token pair.** `deployPool()` reverts with `PoolAlreadyDeployed` if `tokenIdOf[projectId][terminalToken] != 0`.
101
+ 6. **Cannot deploy a second terminal-token pool for the same project.** `processSplitWith` only receives the project token, not the terminal token. Once any pool is deployed, the project enters burn mode and `deployPool()` reverts with `OnlyOneTerminalTokenSupported` for other terminal tokens.
101
102
 
102
103
  6. **Cannot prevent permissionless fee collection.** `collectAndRouteLPFees()` has no access control. Anyone can trigger fee collection and routing for any deployed pool.
103
104
 
package/ARCHITECTURE.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- Uniswap V4 liquidity pool deployment hook for Juicebox V6. Receives project tokens via reserved token splits, accumulates them until a deployment threshold is met, then creates a Uniswap V4 pool and provides initial liquidity. After deployment, burns new tokens and routes LP fees back to the project.
5
+ Uniswap V4 liquidity pool deployment hook for Juicebox V6. Receives project tokens via reserved token splits, accumulates them until a deployment threshold is met, then creates one Uniswap V4 pool for the project and provides initial liquidity. After deployment, burns new tokens and routes LP fees back to the project.
6
6
 
7
7
  **Requirement:** The project must have a deployed ERC-20 token (via `JBTokens.deployERC20For`). Projects using only internal credits (`tokenOf == address(0)`) are rejected — credits cannot be paired as Uniswap V4 LP. `processSplitWith` reverts with `InvalidProjectId` if the token is `address(0)`.
8
8
 
@@ -26,7 +26,7 @@ Reserved token distribution → JBMultiTerminal.sendPayoutsOf()
26
26
  → Track accumulated balance
27
27
 
28
28
  Owner/Operator → deployPool()
29
- → Validate: enough tokens accumulated
29
+ → Validate: enough tokens accumulated and no other terminal-token pool already exists for the project
30
30
  → Create Uniswap V4 pool (projectToken/terminalToken) with ORACLE_HOOK (TWAP via observe())
31
31
  → Provide initial liquidity from accumulated tokens
32
32
  → Set up LP position
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Juicebox UniV4 LP Split Hook
2
2
 
3
- Juicebox split hook that accumulates reserved project tokens over time, then deploys a Uniswap V4 concentrated liquidity position bounded by the project's issuance rate (ceiling) and cash-out rate (floor). After deployment, it manages fee collection, liquidity rebalancing, and routes LP fees back to the project with a configurable fee split.
3
+ Juicebox split hook that accumulates reserved project tokens over time, then deploys a single Uniswap V4 concentrated liquidity position for that project, bounded by the project's issuance rate (ceiling) and cash-out rate (floor). After deployment, it manages fee collection, liquidity rebalancing, and routes LP fees back to the project with a configurable fee split.
4
4
 
5
5
  [Docs](https://docs.juicebox.money) | [Discord](https://discord.gg/juicebox)
6
6
 
@@ -10,7 +10,7 @@ This hook connects Juicebox's reserved token system to Uniswap V4's concentrated
10
10
 
11
11
  **Stage 1 -- Accumulation:** Configure the hook as a reserved-token split in a project's ruleset. Each time reserved tokens are distributed, the hook accumulates them. This continues across multiple ruleset cycles until enough tokens are collected.
12
12
 
13
- **Stage 2 -- Deployment & Operation:** The project owner calls `deployPool()` to create a V4 pool and mint an LP position. The hook cashes out a geometrically-optimized fraction of the accumulated tokens (typically 15-30%) for terminal tokens, then provides both as concentrated liquidity bounded by the project's economic rates. After deployment, any newly received reserved tokens are burned to prevent LP dilution.
13
+ **Stage 2 -- Deployment & Operation:** The project owner calls `deployPool()` to create a V4 pool and mint an LP position. This permanently selects the project's terminal-token path for this hook instance. The hook cashes out a geometrically-optimized fraction of the accumulated tokens (typically 15-30%) for terminal tokens, then provides both as concentrated liquidity bounded by the project's economic rates. After deployment, any newly received reserved tokens are burned to prevent LP dilution.
14
14
 
15
15
  The LP position is bounded by two rate-derived ticks:
16
16
  - **Lower bound (floor):** The cash-out rate -- what you get from redeeming project tokens via the bonding curve
@@ -153,6 +153,7 @@ script/
153
153
  - **Impermanent loss:** The LP position is subject to standard concentrated liquidity IL. If the market price moves outside the [cashOut, issuance] range, the position becomes single-sided.
154
154
  - **Stale tick bounds:** If the project's issuance or cash-out rates change significantly (e.g., new ruleset with different weight), the LP position bounds become stale. The project owner must call `rebalanceLiquidity` to update them.
155
155
  - **Cash-out price impact:** The initial `deployPool` cashes out a fraction of accumulated tokens, which affects the bonding curve. Large accumulations may create meaningful price impact.
156
+ - **One terminal token per project:** `processSplitWith` does not receive the terminal token, so once any pool is deployed the hook permanently switches the project into burn mode. A second terminal-token pool is intentionally unsupported and `deployPool()` reverts.
156
157
  - **One position per pool:** The hook manages a single V4 NFT position per project/terminal-token pair. Rebalancing destroys and recreates it, temporarily leaving no active position.
157
158
  - **Fee-project token accumulation:** Fee-project tokens are held by the hook until claimed via `claimFeeTokensFor`. If the fee project token changes or is not deployed, tokens may be stuck.
158
159
  - **Rebalance to zero liquidity:** If both token balances are zero when rebalancing, the transaction reverts with `InsufficientLiquidity` to prevent bricking the position (tokenIdOf would become zero while projectDeployed remains true).
package/SKILLS.md CHANGED
@@ -17,13 +17,13 @@ Juicebox reserved-token split hook that accumulates project tokens, deploys a Un
17
17
 
18
18
  | Function | What it does |
19
19
  |----------|-------------|
20
- | `processSplitWith(context)` | Called by controller during reserved token distribution. If no pool deployed: accumulates tokens. If pool exists: burns received tokens. Only accepts `groupId == 1` (reserved tokens); reverts on payout splits (`groupId == 0`). |
20
+ | `processSplitWith(context)` | Called by controller during reserved token distribution. If no pool deployed: accumulates tokens. If pool exists: burns received tokens. Only accepts `groupId == 1` (reserved tokens); reverts on payout splits (`groupId == 0`). Because the split context has no terminal token, one deployment permanently switches the project into burn mode. |
21
21
 
22
22
  ### Pool Deployment
23
23
 
24
24
  | Function | What it does |
25
25
  |----------|-------------|
26
- | `deployPool(projectId, terminalToken, amount0Min, amount1Min, minCashOutReturn)` | Requires `SET_BUYBACK_POOL` permission unless the current ruleset's weight has decayed to 1/10th or less of `initialWeightOf[projectId]` (becomes permissionless). Creates V4 pool at geometric mean of [cashOut, issuance] rates. Computes optimal cash-out fraction, cashes out tokens via terminal, mints concentrated LP position, handles leftovers (burns project tokens, adds terminal tokens to project balance). Sets `projectDeployed = true`. |
26
+ | `deployPool(projectId, terminalToken, amount0Min, amount1Min, minCashOutReturn)` | Requires `SET_BUYBACK_POOL` permission unless the current ruleset's weight has decayed to 1/10th or less of `initialWeightOf[projectId]` (becomes permissionless). Creates V4 pool at geometric mean of [cashOut, issuance] rates. Computes optimal cash-out fraction, cashes out tokens via terminal, mints concentrated LP position, handles leftovers (burns project tokens, adds terminal tokens to project balance). Once a pool exists, a different `terminalToken` for the same project is rejected. |
27
27
 
28
28
  ### Fee Management
29
29
 
@@ -127,7 +127,7 @@ Juicebox reserved-token split hook that accumulates project tokens, deploys a Un
127
127
  | `accumulatedProjectTokens` | `projectId => uint256` | Pre-deployment token accumulation |
128
128
  | `initialWeightOf` | `projectId => uint256` | Ruleset weight when first tokens were accumulated (for 10x decay check) |
129
129
  | `projectDeployed` | `projectId => terminalToken => bool` | Whether a V4 pool has been deployed for this project/token pair |
130
- | `deployedPoolCount` | `projectId => uint256` | Number of pools deployed for project (used for accumulate vs burn decision in processSplitWith) |
130
+ | `deployedPoolCount` | `projectId => uint256` | Number of pools deployed for project. Intentionally capped at 1 because processSplitWith cannot distinguish terminal-token paths. |
131
131
  | `claimableFeeTokens` | `projectId => uint256` | Fee-project tokens claimable via `claimFeeTokensFor` |
132
132
  | `initialized` | `bool` | Prevents re-initialization of clone instances |
133
133
  | `ORACLE_HOOK` | `IHooks` (immutable) | Oracle hook for all JB V4 pools. Set in constructor. All pools are created with this hook in the `PoolKey.hooks` field, providing TWAP via `observe()`. |
package/USER_JOURNEYS.md CHANGED
@@ -46,7 +46,7 @@ A new `JBUniswapV4LPSplitHook` clone is deployed, initialized, and registered. I
46
46
 
47
47
  ### Precondition
48
48
 
49
- The project's ruleset has a reserved token split configured with `hook = address(lpSplitHook)`. No pool has been deployed yet (`deployedPoolCount[projectId] == 0`).
49
+ The project's ruleset has a reserved token split configured with `hook = address(lpSplitHook)`. No pool has been deployed yet (`deployedPoolCount[projectId] == 0`). This hook instance supports only one terminal-token deployment per project because the split context does not include the terminal token.
50
50
 
51
51
  ### Steps
52
52
 
@@ -94,14 +94,14 @@ The hook holds project tokens in `accumulatedProjectTokens[projectId]`. The toke
94
94
 
95
95
  ### Precondition
96
96
 
97
- Tokens have been accumulated (`accumulatedProjectTokens[projectId] > 0`). No pool exists for this project/terminal-token pair.
97
+ Tokens have been accumulated (`accumulatedProjectTokens[projectId] > 0`). No pool exists for this project and no terminal-token path has been committed yet.
98
98
 
99
99
  ### Steps
100
100
 
101
101
  1. **Caller invokes `deployPool(projectId, terminalToken, minCashOutReturn)`**
102
102
 
103
103
  - Permission check: requires `SET_BUYBACK_POOL` from project owner, unless `ruleset.weight * 10 <= initialWeightOf[projectId]`
104
- - Checks: `tokenIdOf == 0`, `accumulatedProjectTokens > 0`, `primaryTerminalOf(projectId, terminalToken) != address(0)`
104
+ - Checks: `tokenIdOf == 0`, `deployedPoolCount == 0`, `accumulatedProjectTokens > 0`, `primaryTerminalOf(projectId, terminalToken) != address(0)`
105
105
 
106
106
  2. **`_createAndInitializePool()` creates the V4 pool**
107
107
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/univ4-lp-split-hook-v6",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,9 +16,9 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@bananapus/address-registry-v6": "^0.0.10",
19
- "@bananapus/core-v6": "^0.0.17",
19
+ "@bananapus/core-v6": "^0.0.23",
20
20
  "@bananapus/permission-ids-v6": "^0.0.10",
21
- "@bananapus/univ4-router-v6": "^0.0.8",
21
+ "@bananapus/univ4-router-v6": "^0.0.9",
22
22
  "@openzeppelin/contracts": "^5.6.1",
23
23
  "@prb/math": "^4.1.0",
24
24
  "@uniswap/permit2": "github:Uniswap/permit2",
@@ -21,7 +21,6 @@ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
21
21
  import {IJBMultiTerminal} from "@bananapus/core-v6/src/interfaces/IJBMultiTerminal.sol";
22
22
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
23
23
  import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
24
- import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
25
24
  import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
26
25
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
27
26
  import {JBRulesetMetadataResolver} from "@bananapus/core-v6/src/libraries/JBRulesetMetadataResolver.sol";
@@ -73,6 +72,7 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
73
72
  error JBUniswapV4LPSplitHook_InvalidTerminalToken();
74
73
  error JBUniswapV4LPSplitHook_NoTokensAccumulated();
75
74
  error JBUniswapV4LPSplitHook_NotHookSpecifiedInContext();
75
+ error JBUniswapV4LPSplitHook_OnlyOneTerminalTokenSupported();
76
76
  error JBUniswapV4LPSplitHook_Permit2AmountOverflow();
77
77
  error JBUniswapV4LPSplitHook_PoolAlreadyDeployed();
78
78
  error JBUniswapV4LPSplitHook_SplitSenderNotValidControllerOrTerminal();
@@ -158,9 +158,9 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
158
158
  mapping(uint256 projectId => uint256 accumulatedProjectTokens) public accumulatedProjectTokens;
159
159
 
160
160
  /// @notice ProjectID => Number of deployed pools for this project.
161
- /// @dev Monotonic counter by design pools are never removed, only added. Used by
162
- /// processSplitWith to decide accumulate vs burn, since the split context only
163
- /// provides the project token, not the terminal token.
161
+ /// @dev This is intentionally capped at 1. `processSplitWith` only receives the project token and cannot tell
162
+ /// which terminal token a reserved-token distribution is intended for, so one deployment permanently flips
163
+ /// the project from accumulation to burn mode.
164
164
  mapping(uint256 projectId => uint256 count) public deployedPoolCount;
165
165
 
166
166
  /// @notice ProjectID => Fee tokens claimable by that project
@@ -272,11 +272,9 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
272
272
  try IJBMultiTerminal(
273
273
  address(IJBDirectory(DIRECTORY).primaryTerminalOf({projectId: projectId, token: terminalToken}))
274
274
  ).STORE()
275
- .currentReclaimableSurplusOf({
275
+ .currentTotalReclaimableSurplusOf({
276
276
  projectId: projectId,
277
277
  cashOutCount: _WAD,
278
- terminals: new IJBTerminal[](0),
279
- accountingContexts: new JBAccountingContext[](0),
280
278
  decimals: _getTokenDecimals(terminalToken),
281
279
  // Safe: truncation to uint32 is the standard Juicebox currency encoding.
282
280
  // forge-lint: disable-next-line(unsafe-typecast)
@@ -507,7 +505,6 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
507
505
  }
508
506
 
509
507
  /// @notice Collect LP fees and route them back to the project
510
- // slither-disable-next-line reentrancy-events
511
508
  // forge-lint: disable-next-line(mixed-case-function)
512
509
  function collectAndRouteLPFees(uint256 projectId, address terminalToken) external {
513
510
  uint256 tokenId = tokenIdOf[projectId][terminalToken];
@@ -527,6 +524,7 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
527
524
  params[0] = abi.encode(tokenId, uint256(0), uint128(0), uint128(0), "");
528
525
  params[1] = abi.encode(key.currency0, key.currency1, address(this));
529
526
 
527
+ // slither-disable-next-line reentrancy-events,reentrancy-no-eth
530
528
  POSITION_MANAGER.modifyLiquidities({
531
529
  unlockData: abi.encode(actions, params), deadline: block.timestamp + _DEADLINE_SECONDS
532
530
  });
@@ -536,6 +534,7 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
536
534
  uint256 amount1 = _currencyBalance(key.currency1) - bal1Before;
537
535
 
538
536
  // Route terminal token fees back to the project
537
+ // slither-disable-next-line reentrancy-events,reentrancy-no-eth
539
538
  _routeCollectedFees({
540
539
  projectId: projectId,
541
540
  projectToken: projectToken,
@@ -545,6 +544,7 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
545
544
  });
546
545
 
547
546
  // Burn collected project token fees
547
+ // slither-disable-next-line reentrancy-events,reentrancy-no-eth
548
548
  _burnReceivedTokens({projectId: projectId, projectToken: projectToken});
549
549
  }
550
550
 
@@ -566,6 +566,7 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
566
566
  }
567
567
 
568
568
  if (tokenIdOf[projectId][terminalToken] != 0) revert JBUniswapV4LPSplitHook_PoolAlreadyDeployed();
569
+ if (deployedPoolCount[projectId] != 0) revert JBUniswapV4LPSplitHook_OnlyOneTerminalTokenSupported();
569
570
 
570
571
  address projectToken = address(IJBTokens(TOKENS).tokenOf(projectId));
571
572
  uint256 projectTokenBalance = accumulatedProjectTokens[projectId];
@@ -576,6 +577,10 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
576
577
  address(IJBDirectory(DIRECTORY).primaryTerminalOf({projectId: projectId, token: terminalToken}));
577
578
  if (terminal == address(0)) revert JBUniswapV4LPSplitHook_InvalidTerminalToken();
578
579
 
580
+ // Flip the project into post-deploy burn mode before any external calls so reentrancy cannot
581
+ // observe the project as still being in accumulation mode.
582
+ deployedPoolCount[projectId]++;
583
+
579
584
  _deployPoolAndAddLiquidity({
580
585
  projectId: projectId,
581
586
  projectToken: projectToken,
@@ -583,8 +588,6 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
583
588
  minCashOutReturn: minCashOutReturn
584
589
  });
585
590
 
586
- deployedPoolCount[projectId]++;
587
-
588
591
  emit ProjectDeployed(projectId, terminalToken, PoolId.unwrap(_poolKeys[projectId][terminalToken].toId()));
589
592
  }
590
593
 
@@ -650,105 +653,17 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
650
653
  address projectToken = address(IJBTokens(TOKENS).tokenOf(projectId));
651
654
  PoolKey memory key = _poolKeys[projectId][terminalToken];
652
655
 
653
- // Step 1: Collect accrued fees via DECREASE_LIQUIDITY(0) + TAKE_PAIR
654
- {
655
- uint256 bal0Before = _currencyBalance(key.currency0);
656
- uint256 bal1Before = _currencyBalance(key.currency1);
657
-
658
- bytes memory feeActions = abi.encodePacked(uint8(Actions.DECREASE_LIQUIDITY), uint8(Actions.TAKE_PAIR));
659
-
660
- bytes[] memory feeParams = new bytes[](2);
661
- feeParams[0] = abi.encode(tokenId, uint256(0), uint128(0), uint128(0), "");
662
- feeParams[1] = abi.encode(key.currency0, key.currency1, address(this));
663
-
664
- POSITION_MANAGER.modifyLiquidities({
665
- unlockData: abi.encode(feeActions, feeParams), deadline: block.timestamp + _DEADLINE_SECONDS
666
- });
667
-
668
- uint256 feeAmount0 = _currencyBalance(key.currency0) - bal0Before;
669
- uint256 feeAmount1 = _currencyBalance(key.currency1) - bal1Before;
670
-
671
- _routeCollectedFees({
672
- projectId: projectId,
673
- projectToken: projectToken,
674
- terminalToken: terminalToken,
675
- amount0: feeAmount0,
676
- amount1: feeAmount1
677
- });
678
- _burnReceivedTokens({projectId: projectId, projectToken: projectToken});
679
- }
680
-
681
- // Step 2: Burn position to recover principal via BURN_POSITION + TAKE_PAIR
682
- {
683
- bytes memory burnActions = abi.encodePacked(uint8(Actions.BURN_POSITION), uint8(Actions.TAKE_PAIR));
684
-
685
- bytes[] memory burnParams = new bytes[](2);
686
- // Safe: min amounts are user-provided slippage params; PositionManager accepts uint128.
687
- // forge-lint: disable-next-line(unsafe-typecast)
688
- burnParams[0] = abi.encode(tokenId, uint128(decreaseAmount0Min), uint128(decreaseAmount1Min), "");
689
- burnParams[1] = abi.encode(key.currency0, key.currency1, address(this));
690
-
691
- POSITION_MANAGER.modifyLiquidities({
692
- unlockData: abi.encode(burnActions, burnParams), deadline: block.timestamp + _DEADLINE_SECONDS
693
- });
694
- }
695
-
696
- // Step 2: Mint new position with updated tick bounds
697
- {
698
- uint256 projectTokenBalance = IERC20(projectToken).balanceOf(address(this));
699
- uint256 terminalTokenBalance = _getTerminalTokenBalance(terminalToken);
700
-
701
- (int24 tickLower, int24 tickUpper) =
702
- _calculateTickBounds({projectId: projectId, terminalToken: terminalToken, projectToken: projectToken});
703
-
704
- // Use the actual pool price for liquidity calculation so the target matches the pool's
705
- // current state. Using JB issuance price here would produce suboptimal liquidity when the
706
- // pool price has diverged.
707
- // slither-disable-next-line unused-return
708
- (uint160 sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(key.toId());
709
- uint160 sqrtPriceA = TickMath.getSqrtPriceAtTick(tickLower);
710
- uint160 sqrtPriceB = TickMath.getSqrtPriceAtTick(tickUpper);
711
-
712
- // Sort amounts by currency order
713
- Currency terminalCurrency = _toCurrency(terminalToken);
714
- (address token0,) = _sortTokens({tokenA: projectToken, tokenB: Currency.unwrap(terminalCurrency)});
715
- uint256 amount0 = projectToken == token0 ? projectTokenBalance : terminalTokenBalance;
716
- uint256 amount1 = projectToken == token0 ? terminalTokenBalance : projectTokenBalance;
717
-
718
- // Calculate liquidity from amounts
719
- uint128 liquidity = LiquidityAmounts.getLiquidityForAmounts({
720
- sqrtPriceX96: sqrtPriceX96,
721
- sqrtPriceAX96: sqrtPriceA,
722
- sqrtPriceBX96: sqrtPriceB,
723
- amount0: amount0,
724
- amount1: amount1
725
- });
726
-
727
- if (liquidity > 0) {
728
- uint256 newTokenId = POSITION_MANAGER.nextTokenId();
729
-
730
- _mintPosition({
731
- key: key,
732
- tickLower: tickLower,
733
- tickUpper: tickUpper,
734
- liquidity: liquidity,
735
- amount0: amount0,
736
- amount1: amount1
737
- });
656
+ _collectAndRouteFees({
657
+ projectId: projectId, projectToken: projectToken, terminalToken: terminalToken, tokenId: tokenId, key: key
658
+ });
738
659
 
739
- tokenIdOf[projectId][terminalToken] = newTokenId;
740
- } else {
741
- // Zero liquidity means the position cannot be re-created (e.g., price moved
742
- // outside tick range making the position single-sided with zero on one side).
743
- // Revert to prevent bricking the project's LP — the old position was already
744
- // burned by the BURN_POSITION action above, so this protects the invariant
745
- // that tokenIdOf is always nonzero for deployed projects.
746
- revert JBUniswapV4LPSplitHook_InsufficientLiquidity();
747
- }
660
+ _burnExistingPosition({
661
+ tokenId: tokenId, key: key, decreaseAmount0Min: decreaseAmount0Min, decreaseAmount1Min: decreaseAmount1Min
662
+ });
748
663
 
749
- // Handle leftover tokens
750
- _handleLeftoverTokens({projectId: projectId, projectToken: projectToken, terminalToken: terminalToken});
751
- }
664
+ _mintRebalancedPosition({
665
+ projectId: projectId, projectToken: projectToken, terminalToken: terminalToken, key: key
666
+ });
752
667
  }
753
668
 
754
669
  //*********************************************************************//
@@ -896,6 +811,38 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
896
811
  return rounded;
897
812
  }
898
813
 
814
+ /// @notice Burn an existing LP position via `BURN_POSITION` + `TAKE_PAIR` and recover its principal.
815
+ /// @dev Called during rebalancing after fees have already been collected. The recovered tokens remain in
816
+ /// this contract for the subsequent `_mintRebalancedPosition` call.
817
+ /// @param tokenId The Uniswap V4 position NFT token ID to burn.
818
+ /// @param key The pool key identifying the Uniswap V4 pool.
819
+ /// @param decreaseAmount0Min Minimum amount of token0 to receive (slippage protection).
820
+ /// @param decreaseAmount1Min Minimum amount of token1 to receive (slippage protection).
821
+ function _burnExistingPosition(
822
+ uint256 tokenId,
823
+ PoolKey memory key,
824
+ uint256 decreaseAmount0Min,
825
+ uint256 decreaseAmount1Min
826
+ )
827
+ internal
828
+ {
829
+ // BURN_POSITION removes all remaining liquidity and destroys the NFT.
830
+ // TAKE_PAIR transfers the recovered token0 and token1 to this contract.
831
+ bytes memory burnActions = abi.encodePacked(uint8(Actions.BURN_POSITION), uint8(Actions.TAKE_PAIR));
832
+
833
+ bytes[] memory burnParams = new bytes[](2);
834
+ // BURN_POSITION params: (tokenId, minAmount0, minAmount1, hookData).
835
+ // Min amounts are caller-supplied slippage bounds; PositionManager accepts uint128.
836
+ // forge-lint: disable-next-line(unsafe-typecast)
837
+ burnParams[0] = abi.encode(tokenId, uint128(decreaseAmount0Min), uint128(decreaseAmount1Min), "");
838
+ // TAKE_PAIR params: (currency0, currency1, recipient).
839
+ burnParams[1] = abi.encode(key.currency0, key.currency1, address(this));
840
+
841
+ POSITION_MANAGER.modifyLiquidities({
842
+ unlockData: abi.encode(burnActions, burnParams), deadline: block.timestamp + _DEADLINE_SECONDS
843
+ });
844
+ }
845
+
899
846
  /// @notice Burn project tokens using the controller
900
847
  // slither-disable-next-line incorrect-equality,reentrancy-events
901
848
  function _burnProjectTokens(uint256 projectId, address projectToken, uint256 amount, string memory memo) internal {
@@ -986,6 +933,59 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
986
933
  }
987
934
  }
988
935
 
936
+ /// @notice Collect accrued Uniswap LP trading fees and route them to the project's terminal balance.
937
+ /// @dev Uses `DECREASE_LIQUIDITY(0)` to trigger fee collection without removing any principal, followed by
938
+ /// `TAKE_PAIR` to transfer the fees to this contract. Terminal-token fees are added to the project's balance;
939
+ /// project-token fees are burned to avoid inflating supply.
940
+ /// @param projectId The ID of the Juicebox project whose LP fees are being collected.
941
+ /// @param projectToken The project's ERC-20 token address.
942
+ /// @param terminalToken The terminal token (e.g. ETH or USDC) paired with the project token.
943
+ /// @param tokenId The Uniswap V4 position NFT token ID to collect fees from.
944
+ /// @param key The pool key identifying the Uniswap V4 pool.
945
+ // slither-disable-next-line reentrancy-eth,reentrancy-benign,reentrancy-events
946
+ function _collectAndRouteFees(
947
+ uint256 projectId,
948
+ address projectToken,
949
+ address terminalToken,
950
+ uint256 tokenId,
951
+ PoolKey memory key
952
+ )
953
+ internal
954
+ {
955
+ // Snapshot balances before collection to isolate fee amounts from any existing balance.
956
+ uint256 bal0Before = _currencyBalance(key.currency0);
957
+ uint256 bal1Before = _currencyBalance(key.currency1);
958
+
959
+ // DECREASE_LIQUIDITY with amount=0 triggers fee collection without removing principal.
960
+ // TAKE_PAIR transfers the collected fees (both currencies) to this contract.
961
+ bytes memory feeActions = abi.encodePacked(uint8(Actions.DECREASE_LIQUIDITY), uint8(Actions.TAKE_PAIR));
962
+
963
+ bytes[] memory feeParams = new bytes[](2);
964
+ // DECREASE_LIQUIDITY params: (tokenId, liquidity=0, minAmount0=0, minAmount1=0, hookData).
965
+ feeParams[0] = abi.encode(tokenId, uint256(0), uint128(0), uint128(0), "");
966
+ // TAKE_PAIR params: (currency0, currency1, recipient).
967
+ feeParams[1] = abi.encode(key.currency0, key.currency1, address(this));
968
+
969
+ POSITION_MANAGER.modifyLiquidities({
970
+ unlockData: abi.encode(feeActions, feeParams), deadline: block.timestamp + _DEADLINE_SECONDS
971
+ });
972
+
973
+ // Diff balances to determine exactly how much was collected as fees.
974
+ uint256 feeAmount0 = _currencyBalance(key.currency0) - bal0Before;
975
+ uint256 feeAmount1 = _currencyBalance(key.currency1) - bal1Before;
976
+
977
+ // Route terminal-token fees to the project's balance; project-token fees are burned below.
978
+ _routeCollectedFees({
979
+ projectId: projectId,
980
+ projectToken: projectToken,
981
+ terminalToken: terminalToken,
982
+ amount0: feeAmount0,
983
+ amount1: feeAmount1
984
+ });
985
+ // Burn any project tokens received as fees to avoid inflating circulating supply.
986
+ _burnReceivedTokens({projectId: projectId, projectToken: projectToken});
987
+ }
988
+
989
989
  /// @notice Compute the initial sqrtPriceX96 for pool initialization
990
990
  function _computeInitialSqrtPrice(
991
991
  uint256 projectId,
@@ -1264,6 +1264,80 @@ contract JBUniswapV4LPSplitHook is IJBUniswapV4LPSplitHook, IJBSplitHook, JBPerm
1264
1264
  });
1265
1265
  }
1266
1266
 
1267
+ /// @notice Mint a new LP position with tick bounds recalculated from current issuance and cash-out rates.
1268
+ /// @dev Called after `_burnExistingPosition` has recovered the old position's principal. Computes liquidity from
1269
+ /// this contract's current token balances and the pool's live `sqrtPriceX96`. Reverts with
1270
+ /// `JBUniswapV4LPSplitHook_InsufficientLiquidity` if the resulting liquidity is zero (e.g. price moved entirely
1271
+ /// outside the new tick range), preventing `tokenIdOf` from being left stale. Any leftover tokens after minting
1272
+ /// are routed back to the project via `_handleLeftoverTokens`.
1273
+ /// @param projectId The ID of the Juicebox project being rebalanced.
1274
+ /// @param projectToken The project's ERC-20 token address.
1275
+ /// @param terminalToken The terminal token paired with the project token.
1276
+ /// @param key The pool key identifying the Uniswap V4 pool.
1277
+ // slither-disable-next-line reentrancy-eth,reentrancy-benign,reentrancy-events
1278
+ function _mintRebalancedPosition(
1279
+ uint256 projectId,
1280
+ address projectToken,
1281
+ address terminalToken,
1282
+ PoolKey memory key
1283
+ )
1284
+ internal
1285
+ {
1286
+ uint256 projectTokenBalance = IERC20(projectToken).balanceOf(address(this));
1287
+ uint256 terminalTokenBalance = _getTerminalTokenBalance(terminalToken);
1288
+
1289
+ (int24 tickLower, int24 tickUpper) =
1290
+ _calculateTickBounds({projectId: projectId, terminalToken: terminalToken, projectToken: projectToken});
1291
+
1292
+ // Use the actual pool price for liquidity calculation so the target matches the pool's
1293
+ // current state. Using JB issuance price here would produce suboptimal liquidity when the
1294
+ // pool price has diverged.
1295
+ // slither-disable-next-line unused-return
1296
+ (uint160 sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(key.toId());
1297
+ uint160 sqrtPriceA = TickMath.getSqrtPriceAtTick(tickLower);
1298
+ uint160 sqrtPriceB = TickMath.getSqrtPriceAtTick(tickUpper);
1299
+
1300
+ // Map token balances to (amount0, amount1) matching the pool's currency ordering.
1301
+ Currency terminalCurrency = _toCurrency(terminalToken);
1302
+ (address token0,) = _sortTokens({tokenA: projectToken, tokenB: Currency.unwrap(terminalCurrency)});
1303
+ uint256 amount0 = projectToken == token0 ? projectTokenBalance : terminalTokenBalance;
1304
+ uint256 amount1 = projectToken == token0 ? terminalTokenBalance : projectTokenBalance;
1305
+
1306
+ // Derive the maximum liquidity mintable from our balances at the current pool price.
1307
+ uint128 liquidity = LiquidityAmounts.getLiquidityForAmounts({
1308
+ sqrtPriceX96: sqrtPriceX96,
1309
+ sqrtPriceAX96: sqrtPriceA,
1310
+ sqrtPriceBX96: sqrtPriceB,
1311
+ amount0: amount0,
1312
+ amount1: amount1
1313
+ });
1314
+
1315
+ if (liquidity > 0) {
1316
+ uint256 newTokenId = POSITION_MANAGER.nextTokenId();
1317
+
1318
+ _mintPosition({
1319
+ key: key,
1320
+ tickLower: tickLower,
1321
+ tickUpper: tickUpper,
1322
+ liquidity: liquidity,
1323
+ amount0: amount0,
1324
+ amount1: amount1
1325
+ });
1326
+
1327
+ tokenIdOf[projectId][terminalToken] = newTokenId;
1328
+ } else {
1329
+ // Zero liquidity means the position cannot be re-created (e.g., price moved
1330
+ // outside tick range making the position single-sided with zero on one side).
1331
+ // Revert to prevent bricking the project's LP — the old position was already
1332
+ // burned by the BURN_POSITION action above, so this protects the invariant
1333
+ // that tokenIdOf is always nonzero for deployed projects.
1334
+ revert JBUniswapV4LPSplitHook_InsufficientLiquidity();
1335
+ }
1336
+
1337
+ // Return any dust left over after minting (due to rounding or single-sided excess) to the project.
1338
+ _handleLeftoverTokens({projectId: projectId, projectToken: projectToken, terminalToken: terminalToken});
1339
+ }
1340
+
1267
1341
  /// @notice Approve an ERC20 token via Permit2 so PositionManager can pull it during SETTLE.
1268
1342
  function _approveViaPermit2(address token, uint256 amount) internal {
1269
1343
  IERC20(token).forceApprove({spender: address(PERMIT2), value: amount});
@@ -139,6 +139,20 @@ contract DeploymentStageTest is LPSplitHookV4TestBase {
139
139
  hook.deployPool(PROJECT_ID, address(terminalToken), 0);
140
140
  }
141
141
 
142
+ /// @notice Once any pool is deployed for a project, a second terminal-token pool is rejected.
143
+ function test_DeployPool_RevertsIf_SecondTerminalTokenRequested() public {
144
+ _accumulateAndDeploy(PROJECT_ID, 100e18);
145
+
146
+ address secondTerminalToken = makeAddr("secondTerminalToken");
147
+ _setDirectoryTerminal(PROJECT_ID, secondTerminalToken, address(terminal));
148
+
149
+ _accumulateTokens(PROJECT_ID, 50e18);
150
+
151
+ vm.expectRevert(JBUniswapV4LPSplitHook.JBUniswapV4LPSplitHook_OnlyOneTerminalTokenSupported.selector);
152
+ vm.prank(owner);
153
+ hook.deployPool(PROJECT_ID, secondTerminalToken, 0);
154
+ }
155
+
142
156
  // ─────────────────────────────────────────────────────────────────────
143
157
  // 9. deployPool — reverts if terminal token is invalid
144
158
  // ─────────────────────────────────────────────────────────────────────
@@ -254,9 +254,8 @@ contract SplitHookRegressionsTest is LPSplitHookV4TestBase {
254
254
  );
255
255
  }
256
256
 
257
- /// @notice Demonstrates that multiple terminal tokens can have independent
258
- /// projectDeployed flags per the new mapping structure.
259
- function test_M2_multiTerminalToken_independentFlags() public {
257
+ /// @notice Once any pool is deployed, the hook rejects a second terminal-token pool for the project.
258
+ function test_M2_multiTerminalToken_secondDeployReverts() public {
260
259
  // PROJECT_ID already has a pool for terminalToken
261
260
  assertTrue(
262
261
  hook.isPoolDeployed(PROJECT_ID, address(terminalToken)), "First terminal token pool should be deployed"
@@ -288,9 +287,9 @@ contract SplitHookRegressionsTest is LPSplitHookV4TestBase {
288
287
  hook.isPoolDeployed(PROJECT_ID, address(terminalToken)), "First terminal token should still be deployed"
289
288
  );
290
289
 
291
- // Note: processSplitWith limitation -- it uses deployedPoolCount (per-project) to decide
292
- // accumulate vs burn. Once any pool is deployed, all subsequent splits burn tokens.
293
- // A future improvement could track per-terminal-token accumulation, but this requires
294
- // the split context to include the terminal token, which it currently does not.
290
+ projectToken.mint(address(hook), 10e18);
291
+ vm.expectRevert(JBUniswapV4LPSplitHook.JBUniswapV4LPSplitHook_OnlyOneTerminalTokenSupported.selector);
292
+ vm.prank(owner);
293
+ hook.deployPool(PROJECT_ID, address(secondTerminalToken), 0);
295
294
  }
296
295
  }
@@ -542,14 +542,31 @@ contract MockJBTerminalStore {
542
542
  }
543
543
 
544
544
  /// @dev Matches
545
- /// IJBTerminalStore.currentReclaimableSurplusOf(uint256,uint256,IJBTerminal[],JBAccountingContext[],uint256,uint256)
545
+ /// IJBTerminalStore.currentReclaimableSurplusOf(uint256,uint256,IJBTerminal[],address[],uint256,uint256)
546
546
  function currentReclaimableSurplusOf(
547
547
  uint256 projectId,
548
548
  uint256 cashOutCount,
549
549
  IJBTerminal[] calldata,
550
550
  /* terminals */
551
- JBAccountingContext[] calldata,
552
- /* accountingContexts */
551
+ address[] calldata,
552
+ /* tokens */
553
+ uint256,
554
+ /* decimals */
555
+ uint256 /* currency */
556
+ )
557
+ external
558
+ view
559
+ returns (uint256)
560
+ {
561
+ uint256 surplus = surplusPerToken[projectId];
562
+ if (surplus == 0) return 0;
563
+ return (surplus * cashOutCount) / 1e18;
564
+ }
565
+
566
+ /// @dev Matches IJBTerminalStore.currentTotalReclaimableSurplusOf(uint256,uint256,uint256,uint256)
567
+ function currentTotalReclaimableSurplusOf(
568
+ uint256 projectId,
569
+ uint256 cashOutCount,
553
570
  uint256,
554
571
  /* decimals */
555
572
  uint256 /* currency */