@bananapus/suckers-v6 0.0.40 → 0.0.42

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/foundry.toml CHANGED
@@ -1,5 +1,6 @@
1
1
  [profile.default]
2
2
  solc = '0.8.28'
3
+ bytecode_hash = "none"
3
4
  evm_version = 'cancun'
4
5
  optimizer_runs = 200
5
6
  libs = ["node_modules", "lib"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/suckers-v6",
3
- "version": "0.0.40",
3
+ "version": "0.0.42",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@arbitrum/nitro-contracts": "3.2.0",
33
- "@bananapus/core-v6": "^0.0.44",
33
+ "@bananapus/core-v6": "^0.0.48",
34
34
  "@bananapus/permission-ids-v6": "^0.0.25",
35
35
  "@chainlink/contracts-ccip": "1.6.4",
36
36
  "@chainlink/local": "0.2.7",
@@ -91,8 +91,11 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
91
91
  error JBSwapCCIPSucker_InvalidBridgeToken(address bridgeToken, address wrappedNativeToken);
92
92
  error JBSwapCCIPSucker_NoPendingSwap(address localToken, uint64 nonce, bool retrySwapLocked);
93
93
  error JBSwapCCIPSucker_OnlySelf(address caller, address expected);
94
+ error JBSwapCCIPSucker_PositiveRootWithoutDelivery(uint256 rootAmount);
94
95
  error JBSwapCCIPSucker_SwapFailed(address tokenIn, address tokenOut, uint256 amountIn);
95
96
  error JBSwapCCIPSucker_SwapPending(uint64 nonce);
97
+ error JBSwapCCIPSucker_UnexpectedDeliveredTokens(uint256 count);
98
+ error JBSwapCCIPSucker_WrongDeliveredToken(address delivered, address expected);
96
99
 
97
100
  //*********************************************************************//
98
101
  // ------------------------------ events ----------------------------- //
@@ -278,22 +281,58 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
278
281
  abi.decode(payload, (JBMessageRoot, uint256, uint256));
279
282
 
280
283
  address localToken = _toAddress(root.token);
284
+ uint64 nonce = root.remoteRoot.nonce;
285
+ uint256 leafTotal = root.amount;
281
286
  uint256 localAmount;
282
287
  bool swapFailed;
288
+ // Cache the single delivered entry once so subsequent branches reuse it without re-indexing
289
+ // calldata. `deliveredAmount > 0` later implies a delivery was present.
290
+ address deliveredToken;
291
+ uint256 deliveredAmount;
292
+ {
293
+ // Send-side guarantees: at most one entry in `destTokenAmounts` (length 0 for zero-value
294
+ // batches, length 1 for value-bearing batches), and when present the delivered token is
295
+ // `BRIDGE_TOKEN`. Refuse anything that deviates so a peer compromise or a malformed CCIP
296
+ // delivery cannot register positive root accounting against zero or wrong-token backing.
297
+ uint256 deliveryCount = any2EvmMessage.destTokenAmounts.length;
298
+ if (deliveryCount > 1) {
299
+ revert JBSwapCCIPSucker_UnexpectedDeliveredTokens(deliveryCount);
300
+ }
301
+ if (deliveryCount == 0) {
302
+ if (leafTotal > 0) revert JBSwapCCIPSucker_PositiveRootWithoutDelivery(leafTotal);
303
+ } else {
304
+ Client.EVMTokenAmount calldata delivered = any2EvmMessage.destTokenAmounts[0];
305
+ deliveredToken = delivered.token;
306
+ deliveredAmount = delivered.amount;
307
+ if (deliveredToken != address(BRIDGE_TOKEN)) {
308
+ revert JBSwapCCIPSucker_WrongDeliveredToken({
309
+ delivered: deliveredToken, expected: address(BRIDGE_TOKEN)
310
+ });
311
+ }
312
+ // Zero delivery alongside a positive root is structurally indistinguishable from
313
+ // "no delivery + positive root" — both leave the local sucker with nothing to back
314
+ // the leaves the root advertises. Reject so a peer cannot mint a claimable rate
315
+ // that records `leafTotal=N, localTotal=0` and lets later claims withdraw against
316
+ // unrelated balance.
317
+ if (leafTotal > 0 && deliveredAmount == 0) {
318
+ revert JBSwapCCIPSucker_PositiveRootWithoutDelivery(leafTotal);
319
+ }
320
+ }
321
+ }
283
322
 
284
- if (any2EvmMessage.destTokenAmounts.length == 1) {
285
- Client.EVMTokenAmount memory tokenAmount = any2EvmMessage.destTokenAmounts[0];
286
-
287
- if (localToken == address(BRIDGE_TOKEN) || localToken == tokenAmount.token) {
323
+ // After the validation block above, `deliveredToken != address(0)` iff a delivery was present,
324
+ // because the invariants ensure it equals `BRIDGE_TOKEN` (a non-zero ERC-20) whenever there is one.
325
+ if (deliveredToken != address(0)) {
326
+ if (localToken == address(BRIDGE_TOKEN) || localToken == deliveredToken) {
288
327
  // No swap needed — bridge token IS the local token.
289
- localAmount = tokenAmount.amount;
328
+ localAmount = deliveredAmount;
290
329
  } else {
291
330
  // Swap bridge token -> local token via best V3/V4 pool.
292
331
  // Wrapped in try-catch so a swap failure doesn't revert the entire CCIP message
293
332
  // (which would leave tokens stuck in the OffRamp). On failure, bridge tokens are
294
333
  // stored for later retry via `retrySwap` (written below, after nonce validation).
295
334
  try this.executeSwapExternal({
296
- tokenIn: tokenAmount.token, tokenOut: localToken, amount: tokenAmount.amount
335
+ tokenIn: deliveredToken, tokenOut: localToken, amount: deliveredAmount
297
336
  }) returns (
298
337
  uint256 swapped
299
338
  ) {
@@ -321,28 +360,23 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
321
360
  // Detect "already seen" without extra storage: a nonce has been processed if it has
322
361
  // either a batch range (batchEnd > 0) or a conversion rate / pending swap recorded.
323
362
  if (
324
- _batchEndOf[localToken][root.remoteRoot.nonce] == 0
325
- && _conversionRateOf[localToken][root.remoteRoot.nonce].leafTotal == 0
326
- && pendingSwapOf[localToken][root.remoteRoot.nonce].leafTotal == 0
363
+ _batchEndOf[localToken][nonce] == 0 && _conversionRateOf[localToken][nonce].leafTotal == 0
364
+ && pendingSwapOf[localToken][nonce].leafTotal == 0
327
365
  ) {
328
366
  // Record the batch range so _findNonceForLeafIndex can resolve leaf ownership
329
367
  // independently of nonce ordering. Each nonce is self-describing: [start, end).
330
368
  if (batchEnd > 0) {
331
- _batchStartOf[localToken][root.remoteRoot.nonce] = batchStart;
332
- _batchEndOf[localToken][root.remoteRoot.nonce] = batchEnd;
333
- if (root.remoteRoot.nonce > _highestReceivedNonce[localToken]) {
334
- _highestReceivedNonce[localToken] = root.remoteRoot.nonce;
369
+ _batchStartOf[localToken][nonce] = batchStart;
370
+ _batchEndOf[localToken][nonce] = batchEnd;
371
+ if (nonce > _highestReceivedNonce[localToken]) {
372
+ _highestReceivedNonce[localToken] = nonce;
335
373
  }
336
374
  }
337
375
 
338
376
  // Store pendingSwapOf for failed swaps now that nonce is validated.
339
377
  if (swapFailed) {
340
- Client.EVMTokenAmount memory failedTokenAmount = any2EvmMessage.destTokenAmounts[0];
341
- pendingSwapOf[localToken][root.remoteRoot.nonce] = PendingSwap({
342
- bridgeToken: failedTokenAmount.token,
343
- bridgeAmount: failedTokenAmount.amount,
344
- leafTotal: root.amount
345
- });
378
+ pendingSwapOf[localToken][nonce] =
379
+ PendingSwap({bridgeToken: deliveredToken, bridgeAmount: deliveredAmount, leafTotal: leafTotal});
346
380
  }
347
381
 
348
382
  // Zero-output swap guard: When a swap succeeds but returns zero local tokens, the
@@ -354,25 +388,14 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
354
388
  // Route zero-output swaps into `pendingSwapOf` so the swap can be retried via
355
389
  // `retrySwap` once pool conditions improve. Only store the conversion rate when
356
390
  // the swap produced a positive local amount.
357
- if (root.amount > 0 && !swapFailed) {
358
- if (localAmount == 0 && any2EvmMessage.destTokenAmounts.length == 1) {
359
- Client.EVMTokenAmount memory zeroSwapTokenAmount = any2EvmMessage.destTokenAmounts[0];
360
- // Only route to pending if there were actual bridge tokens delivered.
361
- // If bridgeAmount is also 0 (zero-value batch), store the conversion rate
362
- // normally — there is nothing to retry.
363
- if (zeroSwapTokenAmount.amount > 0) {
364
- pendingSwapOf[localToken][root.remoteRoot.nonce] = PendingSwap({
365
- bridgeToken: zeroSwapTokenAmount.token,
366
- bridgeAmount: zeroSwapTokenAmount.amount,
367
- leafTotal: root.amount
368
- });
369
- } else {
370
- _conversionRateOf[localToken][root.remoteRoot.nonce] =
371
- ConversionRate({leafTotal: root.amount, localTotal: 0});
372
- }
391
+ if (leafTotal > 0 && !swapFailed) {
392
+ if (localAmount == 0 && deliveredAmount > 0) {
393
+ pendingSwapOf[localToken][nonce] = PendingSwap({
394
+ bridgeToken: deliveredToken, bridgeAmount: deliveredAmount, leafTotal: leafTotal
395
+ });
373
396
  } else {
374
- _conversionRateOf[localToken][root.remoteRoot.nonce] =
375
- ConversionRate({leafTotal: root.amount, localTotal: localAmount});
397
+ _conversionRateOf[localToken][nonce] =
398
+ ConversionRate({leafTotal: leafTotal, localTotal: localAmount});
376
399
  }
377
400
  }
378
401
  }
@@ -51,6 +51,7 @@ library JBSwapPoolLib {
51
51
  error JBSwapPoolLib_InsufficientTwapHistory(address pool, uint256 availableWindow, uint256 requiredWindow);
52
52
  error JBSwapPoolLib_NoLiquidity(address pool, PoolId poolId);
53
53
  error JBSwapPoolLib_NoPool(address tokenIn, address tokenOut);
54
+ error JBSwapPoolLib_PartialFill(uint256 consumed, uint256 requested);
54
55
  error JBSwapPoolLib_SlippageExceeded(uint256 amountOut, uint256 minAmountOut);
55
56
 
56
57
  //*********************************************************************//
@@ -132,6 +133,7 @@ library JBSwapPoolLib {
132
133
  config: config,
133
134
  key: v4Key,
134
135
  normalizedTokenIn: normalizedIn,
136
+ originalTokenIn: tokenIn,
135
137
  amount: amount,
136
138
  minAmountOut: minAmountOut
137
139
  });
@@ -142,6 +144,7 @@ library JBSwapPoolLib {
142
144
  key: v4Key,
143
145
  normalizedTokenIn: normalizedIn,
144
146
  normalizedTokenOut: normalizedOut,
147
+ originalTokenIn: tokenIn,
145
148
  amount: amount
146
149
  });
147
150
  }
@@ -893,6 +896,7 @@ library JBSwapPoolLib {
893
896
  PoolKey memory key,
894
897
  address normalizedTokenIn,
895
898
  address normalizedTokenOut,
899
+ address originalTokenIn,
896
900
  uint256 amount
897
901
  )
898
902
  internal
@@ -909,7 +913,12 @@ library JBSwapPoolLib {
909
913
 
910
914
  // Execute the swap through the V4 PoolManager.
911
915
  amountOut = _executeV4Swap({
912
- config: config, key: key, normalizedTokenIn: normalizedTokenIn, amount: amount, minAmountOut: minOut
916
+ config: config,
917
+ key: key,
918
+ normalizedTokenIn: normalizedTokenIn,
919
+ originalTokenIn: originalTokenIn,
920
+ amount: amount,
921
+ minAmountOut: minOut
913
922
  });
914
923
  }
915
924
 
@@ -981,6 +990,18 @@ library JBSwapPoolLib {
981
990
  data: abi.encode(originalTokenIn, normalizedTokenIn, normalizedTokenOut)
982
991
  });
983
992
 
993
+ // Reject partial fills: when the V3 pool's price limit is hit before the full input is
994
+ // consumed, the pool returns only the consumed portion of `amount` and the caller is left
995
+ // holding the unconsumed remainder. The sucker's accounting assumes the full bridge amount
996
+ // was either swapped or made retryable, so a silent partial fill strands input tokens and
997
+ // breaks cross-chain solvency. Revert so the caller can either retry with a smaller size
998
+ // or wait for liquidity to return.
999
+ // forge-lint: disable-next-line(unsafe-typecast)
1000
+ uint256 consumedAmount = uint256(zeroForOne ? amount0 : amount1);
1001
+ if (consumedAmount < amount) {
1002
+ revert JBSwapPoolLib_PartialFill({consumed: consumedAmount, requested: amount});
1003
+ }
1004
+
984
1005
  // Extract the output amount from the signed delta (negative = tokens received).
985
1006
  amountOut = uint256(-(zeroForOne ? amount1 : amount0));
986
1007
 
@@ -1001,6 +1022,7 @@ library JBSwapPoolLib {
1001
1022
  SwapConfig memory config,
1002
1023
  PoolKey memory key,
1003
1024
  address normalizedTokenIn,
1025
+ address originalTokenIn,
1004
1026
  uint256 amount,
1005
1027
  uint256 minAmountOut
1006
1028
  )
@@ -1013,6 +1035,16 @@ library JBSwapPoolLib {
1013
1035
  // Determine swap direction based on currency ordering in the pool key.
1014
1036
  bool zeroForOne = Currency.unwrap(key.currency0) == v4In;
1015
1037
 
1038
+ // Tell the unlock callback whether to consume any wrapped-native-token balance the caller may hold.
1039
+ // The pool's input side is native iff the swap input came from the wrapped-native ERC-20 (not the
1040
+ // NATIVE_TOKEN sentinel). If the caller's input was already native (NATIVE_TOKEN sentinel), the caller
1041
+ // holds raw ETH for THIS swap; any wrapped balance it holds is for unrelated reasons (e.g., backing other
1042
+ // claims) and must not be consumed here.
1043
+ address callbackWrappedNativeToken;
1044
+ if (v4In == address(0) && originalTokenIn != JBConstants.NATIVE_TOKEN) {
1045
+ callbackWrappedNativeToken = config.wrappedNativeToken;
1046
+ }
1047
+
1016
1048
  // Build the encoded unlock data in a scoped block to avoid stack-too-deep.
1017
1049
  bytes memory unlockData;
1018
1050
  {
@@ -1027,7 +1059,7 @@ library JBSwapPoolLib {
1027
1059
  int256 exactInputAmount = -int256(amount);
1028
1060
 
1029
1061
  unlockData = abi.encode(
1030
- key, zeroForOne, exactInputAmount, sqrtPriceLimitX96, minAmountOut, config.wrappedNativeToken
1062
+ key, zeroForOne, exactInputAmount, sqrtPriceLimitX96, minAmountOut, callbackWrappedNativeToken
1031
1063
  );
1032
1064
  }
1033
1065