@bananapus/suckers-v6 0.0.59 → 0.0.60

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": "@bananapus/suckers-v6",
3
- "version": "0.0.59",
3
+ "version": "0.0.60",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,11 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
38
38
  //*********************************************************************//
39
39
 
40
40
  error JBCCIPSucker_InvalidRouter(address router);
41
+ error JBCCIPSucker_PositiveRootWithoutDelivery(uint256 rootAmount);
42
+ error JBCCIPSucker_UnderDeliveredAmount(uint256 delivered, uint256 rootAmount);
43
+ error JBCCIPSucker_UnexpectedDeliveredTokens(uint256 count);
41
44
  error JBCCIPSucker_UnknownMessageType(uint8 messageType);
45
+ error JBCCIPSucker_WrongDeliveredToken(address delivered, address expected);
42
46
 
43
47
  //*********************************************************************//
44
48
  // ------------------------------ events ----------------------------- //
@@ -175,6 +179,42 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
175
179
  // Decode the root message from the payload.
176
180
  JBMessageRoot memory root = abi.decode(payload, (JBMessageRoot));
177
181
 
182
+ // Cross-check the delivered tokens against the advertised root before recording anything.
183
+ //
184
+ // The send-side guarantees at most one entry in `destTokenAmounts`: length 0 for zero-value batches,
185
+ // length 1 for value-bearing batches. A compromised peer (or a malformed CCIP delivery) that violates
186
+ // these invariants would otherwise let `fromRemote` record a root advertising more value than was
187
+ // bridged, letting later claims mint project tokens against unrelated balance until the inbox runs dry.
188
+ // `JBSwapCCIPSucker.ccipReceive` already enforces equivalent reverts for the swap variant; mirror
189
+ // them here so both variants share a single defensive baseline.
190
+ uint256 deliveryCount = any2EvmMessage.destTokenAmounts.length;
191
+ if (deliveryCount > 1) {
192
+ revert JBCCIPSucker_UnexpectedDeliveredTokens(deliveryCount);
193
+ }
194
+ if (deliveryCount == 0) {
195
+ if (root.amount > 0) revert JBCCIPSucker_PositiveRootWithoutDelivery(root.amount);
196
+ } else {
197
+ Client.EVMTokenAmount calldata delivered = any2EvmMessage.destTokenAmounts[0];
198
+
199
+ // For NATIVE_TOKEN bridges the delivered ERC-20 is the wrapped native token (CCIP cannot transport
200
+ // raw native), so the token-identity check happens inside `unwrapReceivedTokens` against the
201
+ // router-reported wrapped native address. For everything else, the delivered token must equal the
202
+ // local mapped token the root advertises.
203
+ if (root.token != bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN)))) {
204
+ address expectedToken = _toAddress(root.token);
205
+ if (delivered.token != expectedToken) {
206
+ revert JBCCIPSucker_WrongDeliveredToken({delivered: delivered.token, expected: expectedToken});
207
+ }
208
+ }
209
+
210
+ // The bridged amount must back at least the value the root advertises. A short delivery against a
211
+ // positive root is the structural twin of "no delivery + positive root" — both leave the inbox
212
+ // recording more claimable value than it actually holds.
213
+ if (delivered.amount < root.amount) {
214
+ revert JBCCIPSucker_UnderDeliveredAmount({delivered: delivered.amount, rootAmount: root.amount});
215
+ }
216
+ }
217
+
178
218
  // Only unwrap wrapped native token when the root targets native token (not when claiming it as ERC-20).
179
219
  if (root.token == bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN)))) {
180
220
  JBCCIPLib.unwrapReceivedTokens({
package/src/JBSucker.sol CHANGED
@@ -90,6 +90,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
90
90
  error JBSucker_UnexpectedMsgValue(uint256 value);
91
91
  error JBSucker_ZeroBeneficiary(bytes32 beneficiary);
92
92
  error JBSucker_ZeroERC20Token(uint256 projectId);
93
+ error JBSucker_ZeroProjectTokenCount();
93
94
 
94
95
  //*********************************************************************//
95
96
  // ------------------------- public constants ------------------------ //
@@ -553,6 +554,14 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
553
554
  external
554
555
  override
555
556
  {
557
+ // Reject zero-token prepares. A zero-token prepare burns nothing and reclaims nothing, but it still inserts a
558
+ // leaf into the outbox tree and lets `toRemote` ship a zero-value bridge message — a permissionless way to
559
+ // inflate the per-token populated-nonce list on swap-CCIP suckers, taxing every legitimate claim with extra
560
+ // lookup work and eventually exceeding the block gas limit.
561
+ if (projectTokenCount == 0) {
562
+ revert JBSucker_ZeroProjectTokenCount();
563
+ }
564
+
556
565
  // Make sure the beneficiary is not the zero address, as this would revert when minting on the remote chain.
557
566
  if (beneficiary == bytes32(0)) {
558
567
  revert JBSucker_ZeroBeneficiary({beneficiary: beneficiary});
@@ -376,7 +376,15 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
376
376
  // either a batch range (batchEnd > 0) or a conversion rate / pending swap recorded.
377
377
  // Record the batch range so _findNonceForLeafIndex can resolve leaf ownership
378
378
  // independently of nonce ordering. Each nonce is self-describing: [start, end).
379
- if (batchEnd > 0) {
379
+ //
380
+ // Only record metadata for batches that carry value (`leafTotal > 0`). The base-sucker
381
+ // `prepare` revert on zero `projectTokenCount` blocks the legitimate spam entry point,
382
+ // but a compromised peer or a `projectTokenCount = 1`-style bypass could still ship a
383
+ // zero-leaf root over the bridge. Skipping the metadata write here ensures such roots
384
+ // cannot inflate `_populatedNonceByIndex` and tax future `_findNonceForLeafIndex`
385
+ // walks. Roots with `leafTotal == 0` carry no claimable leaves, so there is nothing
386
+ // for `_findNonceForLeafIndex` to resolve against them.
387
+ if (batchEnd > 0 && leafTotal > 0) {
380
388
  // Record this batch's half-open leaf range `[batchStart, batchEnd)`. Self-
381
389
  // describing per-nonce — no implicit chain across nonces — so out-of-order
382
390
  // delivery can still resolve a leaf to its batch.
@@ -619,7 +619,14 @@ library JBSwapPoolLib {
619
619
  });
620
620
  }
621
621
 
622
- /// @notice Get a V4 quote with dynamic slippage. Hooked pools must serve TWAP; hookless pools use spot fallback.
622
+ /// @notice Get a V4 quote with dynamic slippage. Hooked pools must serve TWAP for BOTH the price tick and the
623
+ /// liquidity input into sigmoid slippage; hookless pools fall back to spot for both.
624
+ /// @dev Matches the buyback hook's TWAP pattern (`JBSwapLib.getQuoteFromOracle`): for hooked routes we derive
625
+ /// `harmonicMeanLiquidity` from the oracle's `secondsPerLiquidityCumulativeX128s`, not from
626
+ /// `PoolManager.getLiquidity` (which returns current spot and is JIT-LP-manipulable across a single block).
627
+ /// Sigmoid slippage tolerance is driven by `amountIn / liquidity`; feeding spot liquidity into a TWAP-derived
628
+ /// tick lets an LP shrink the denominator in the same block as a CCIP delivery, ballooning the tolerance to
629
+ /// `MAX_SLIPPAGE` (88%) for a one-shot per-batch immutable conversion rate that all claimers then inherit.
623
630
  /// @param config The swap configuration (pool manager, wrapped native token addresses).
624
631
  /// @param key The V4 pool key to quote against.
625
632
  /// @param normalizedTokenIn The normalized input token address.
@@ -644,37 +651,54 @@ library JBSwapPoolLib {
644
651
  PoolId id = key.toId();
645
652
 
646
653
  {
647
- // If the pool has a hook, require a TWAP from the geomean oracle.
654
+ // If the pool has a hook, require a TWAP from the geomean oracle for both price AND liquidity.
648
655
  if (address(key.hooks) != address(0)) {
649
656
  // Build the observation window: [_V4_TWAP_WINDOW seconds ago, now].
650
657
  uint32[] memory secondsAgos = new uint32[](2);
651
658
  secondsAgos[0] = _V4_TWAP_WINDOW;
652
659
  secondsAgos[1] = 0;
653
660
 
654
- // Read the TWAP from the hook's geomean oracle. The seconds-per-liquidity array is not used for
655
- // price checks.
656
- (int56[] memory tickCumulatives,) =
661
+ // Read both the TWAP tick and the seconds-per-liquidity series so liquidity is also time-averaged.
662
+ (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) =
657
663
  IGeomeanOracle(address(key.hooks)).observe({key: key, secondsAgos: secondsAgos});
658
- if (tickCumulatives.length < 2) {
664
+ if (tickCumulatives.length < 2 || secondsPerLiquidityCumulativeX128s.length < 2) {
659
665
  revert JBSwapPoolLib_InsufficientTwapHistory({
660
666
  pool: address(key.hooks), availableWindow: tickCumulatives.length, requiredWindow: 2
661
667
  });
662
668
  }
663
669
 
664
- // Compute the arithmetic mean tick from the cumulative tick difference.
665
- // The geomean oracle returns the same int24 tick domain used by Uniswap pools.
670
+ // Compute the arithmetic mean tick from the cumulative tick difference, rounding negative values
671
+ // toward negative infinity to match Uniswap's oracle pattern and the buyback hook.
672
+ int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
666
673
  // forge-lint: disable-next-line(unsafe-typecast)
667
- tick = int24((tickCumulatives[1] - tickCumulatives[0]) / int56(int32(_V4_TWAP_WINDOW)));
674
+ tick = int24(tickCumulativesDelta / int56(int32(_V4_TWAP_WINDOW)));
675
+ // forge-lint: disable-next-line(unsafe-typecast)
676
+ if (tickCumulativesDelta < 0 && tickCumulativesDelta % int56(int32(_V4_TWAP_WINDOW)) != 0) {
677
+ tick--;
678
+ }
679
+
680
+ // Derive harmonic-mean liquidity from the seconds-per-liquidity delta. This is the same shape the
681
+ // buyback hook uses (`JBSwapLib.getQuoteFromOracle`) and resists JIT-LP liquidity removal in the
682
+ // delivery block — the manipulation has to persist across the full TWAP window to move the average.
683
+ uint160 secondsPerLiquidityDelta =
684
+ secondsPerLiquidityCumulativeX128s[1] - secondsPerLiquidityCumulativeX128s[0];
685
+
686
+ if (secondsPerLiquidityDelta > 0) {
687
+ // Safe: `(uint256(_V4_TWAP_WINDOW) << 128) / secondsPerLiquidityDelta` fits in uint128 because
688
+ // _V4_TWAP_WINDOW is at most a uint32 (~4.3B) and the divisor is > 0 in this branch.
689
+ // forge-lint: disable-next-line(unsafe-typecast)
690
+ liquidity = uint128((uint256(_V4_TWAP_WINDOW) << 128) / uint256(secondsPerLiquidityDelta));
691
+ }
692
+ // If `secondsPerLiquidityDelta == 0`, liquidity stays 0 and the no-liquidity revert below fires —
693
+ // refuse to quote a hooked route whose averaged liquidity is degenerate.
668
694
  } else {
669
695
  // Hookless V4 spot pools are only selected when no TWAP-capable route exists.
670
696
  (, tick,,) = config.poolManager.getSlot0(id);
697
+ liquidity = config.poolManager.getLiquidity(id);
671
698
  }
672
-
673
- // Query the pool's current in-range liquidity.
674
- liquidity = config.poolManager.getLiquidity(id);
675
699
  }
676
700
 
677
- // Revert if the pool has no in-range liquidity.
701
+ // Revert if the pool has no usable in-range liquidity (spot for hookless, TWAP-derived for hooked).
678
702
  if (liquidity == 0) revert JBSwapPoolLib_NoLiquidity({pool: address(0), poolId: id});
679
703
 
680
704
  // V4 uses address(0) for native ETH — compute quoting addresses inline to save stack slots.
@@ -864,8 +888,8 @@ library JBSwapPoolLib {
864
888
  return _observationIsOldEnough({observationTimestamp: observationTimestamp, window: _DEFAULT_TWAP_WINDOW});
865
889
  }
866
890
 
867
- /// @notice Check whether a V4 hooked pool can return cumulative ticks for the required TWAP window.
868
- /// @dev Hookless pools return false. Reverting hooks and hooks that return fewer than two cumulative tick values
891
+ /// @notice Check whether a V4 hooked pool can return TWAP price and liquidity for the required window.
892
+ /// @dev Hookless pools return false. Reverting hooks and hooks that return incomplete or degenerate oracle data
869
893
  /// are treated as unusable for TWAP routing.
870
894
  /// @param key The V4 pool key whose hook should be probed.
871
895
  /// @return True if the hook can serve both the historical and current cumulative tick observations.
@@ -876,12 +900,12 @@ library JBSwapPoolLib {
876
900
  secondsAgos[0] = _V4_TWAP_WINDOW;
877
901
  secondsAgos[1] = 0;
878
902
 
879
- // Pool discovery intentionally probes candidate hooks in a bounded pool list. The seconds-per-liquidity array
880
- // is not needed for the history check.
903
+ // Pool discovery intentionally probes candidate hooks in a bounded pool list.
881
904
  try IGeomeanOracle(address(key.hooks)).observe({key: key, secondsAgos: secondsAgos}) returns (
882
- int56[] memory tickCumulatives, uint160[] memory
905
+ int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s
883
906
  ) {
884
- return tickCumulatives.length >= 2;
907
+ if (tickCumulatives.length < 2 || secondsPerLiquidityCumulativeX128s.length < 2) return false;
908
+ return secondsPerLiquidityCumulativeX128s[1] > secondsPerLiquidityCumulativeX128s[0];
885
909
  } catch {
886
910
  return false;
887
911
  }