@bananapus/suckers-v6 0.0.39 → 0.0.41

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.39",
3
+ "version": "0.0.41",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,8 +30,8 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@arbitrum/nitro-contracts": "3.2.0",
33
- "@bananapus/core-v6": "^0.0.44",
34
- "@bananapus/permission-ids-v6": "^0.0.23",
33
+ "@bananapus/core-v6": "^0.0.48",
34
+ "@bananapus/permission-ids-v6": "^0.0.25",
35
35
  "@chainlink/contracts-ccip": "1.6.4",
36
36
  "@chainlink/local": "0.2.7",
37
37
  "@openzeppelin/contracts": "5.6.1",
@@ -497,6 +497,13 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
497
497
  // default peer symmetry assumption will not hold.
498
498
  salt = keccak256(abi.encode(sender, salt));
499
499
 
500
+ // Cache the project owner so the explicit-peer gate can check against the original authority, not a
501
+ // delegated operator. The default same-address peering invariant (peer == 0 or peer == address(this))
502
+ // does not need this stronger gate, but a non-symmetric explicit peer authorizes that arbitrary address
503
+ // to deliver outbox roots and mint project tokens — so it must require a permission strictly broader
504
+ // than ops automation's `DEPLOY_SUCKERS`.
505
+ address projectOwner = PROJECTS.ownerOf(projectId);
506
+
500
507
  // Iterate through the configurations and deploy the suckers.
501
508
  for (uint256 i; i < configurations.length;) {
502
509
  // Get the configuration being iterated over.
@@ -507,6 +514,18 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
507
514
  revert JBSuckerRegistry_InvalidDeployer({deployer: configuration.deployer});
508
515
  }
509
516
 
517
+ // If the configuration specifies a non-symmetric explicit peer, require the additional
518
+ // `SET_SUCKER_PEER` permission. Default peering (peer == 0 or peer == bytes32 of address(this)) is
519
+ // unaffected. Without this gate, a delegated operator with only `DEPLOY_SUCKERS` could register an
520
+ // attacker peer and use the resulting sucker's mint authority to deliver fabricated outbox roots.
521
+ // forge-lint: disable-next-line(unsafe-typecast)
522
+ bytes32 selfPeer = bytes32(uint256(uint160(address(this))));
523
+ if (configuration.peer != bytes32(0) && configuration.peer != selfPeer) {
524
+ _requirePermissionFrom({
525
+ account: projectOwner, projectId: projectId, permissionId: JBPermissionIds.SET_SUCKER_PEER
526
+ });
527
+ }
528
+
510
529
  // Create the sucker.
511
530
  IJBSucker sucker = configuration.deployer
512
531
  .createForSender({localProjectId: projectId, salt: salt, peer: configuration.peer});
@@ -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,50 @@ 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
+ }
313
+ }
283
314
 
284
- if (any2EvmMessage.destTokenAmounts.length == 1) {
285
- Client.EVMTokenAmount memory tokenAmount = any2EvmMessage.destTokenAmounts[0];
286
-
287
- if (localToken == address(BRIDGE_TOKEN) || localToken == tokenAmount.token) {
315
+ // After the validation block above, `deliveredToken != address(0)` iff a delivery was present,
316
+ // because the invariants ensure it equals `BRIDGE_TOKEN` (a non-zero ERC-20) whenever there is one.
317
+ if (deliveredToken != address(0)) {
318
+ if (localToken == address(BRIDGE_TOKEN) || localToken == deliveredToken) {
288
319
  // No swap needed — bridge token IS the local token.
289
- localAmount = tokenAmount.amount;
320
+ localAmount = deliveredAmount;
290
321
  } else {
291
322
  // Swap bridge token -> local token via best V3/V4 pool.
292
323
  // Wrapped in try-catch so a swap failure doesn't revert the entire CCIP message
293
324
  // (which would leave tokens stuck in the OffRamp). On failure, bridge tokens are
294
325
  // stored for later retry via `retrySwap` (written below, after nonce validation).
295
326
  try this.executeSwapExternal({
296
- tokenIn: tokenAmount.token, tokenOut: localToken, amount: tokenAmount.amount
327
+ tokenIn: deliveredToken, tokenOut: localToken, amount: deliveredAmount
297
328
  }) returns (
298
329
  uint256 swapped
299
330
  ) {
@@ -321,28 +352,23 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
321
352
  // Detect "already seen" without extra storage: a nonce has been processed if it has
322
353
  // either a batch range (batchEnd > 0) or a conversion rate / pending swap recorded.
323
354
  if (
324
- _batchEndOf[localToken][root.remoteRoot.nonce] == 0
325
- && _conversionRateOf[localToken][root.remoteRoot.nonce].leafTotal == 0
326
- && pendingSwapOf[localToken][root.remoteRoot.nonce].leafTotal == 0
355
+ _batchEndOf[localToken][nonce] == 0 && _conversionRateOf[localToken][nonce].leafTotal == 0
356
+ && pendingSwapOf[localToken][nonce].leafTotal == 0
327
357
  ) {
328
358
  // Record the batch range so _findNonceForLeafIndex can resolve leaf ownership
329
359
  // independently of nonce ordering. Each nonce is self-describing: [start, end).
330
360
  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;
361
+ _batchStartOf[localToken][nonce] = batchStart;
362
+ _batchEndOf[localToken][nonce] = batchEnd;
363
+ if (nonce > _highestReceivedNonce[localToken]) {
364
+ _highestReceivedNonce[localToken] = nonce;
335
365
  }
336
366
  }
337
367
 
338
368
  // Store pendingSwapOf for failed swaps now that nonce is validated.
339
369
  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
- });
370
+ pendingSwapOf[localToken][nonce] =
371
+ PendingSwap({bridgeToken: deliveredToken, bridgeAmount: deliveredAmount, leafTotal: leafTotal});
346
372
  }
347
373
 
348
374
  // Zero-output swap guard: When a swap succeeds but returns zero local tokens, the
@@ -354,25 +380,14 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
354
380
  // Route zero-output swaps into `pendingSwapOf` so the swap can be retried via
355
381
  // `retrySwap` once pool conditions improve. Only store the conversion rate when
356
382
  // 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
- }
383
+ if (leafTotal > 0 && !swapFailed) {
384
+ if (localAmount == 0 && deliveredAmount > 0) {
385
+ pendingSwapOf[localToken][nonce] = PendingSwap({
386
+ bridgeToken: deliveredToken, bridgeAmount: deliveredAmount, leafTotal: leafTotal
387
+ });
373
388
  } else {
374
- _conversionRateOf[localToken][root.remoteRoot.nonce] =
375
- ConversionRate({leafTotal: root.amount, localTotal: localAmount});
389
+ _conversionRateOf[localToken][nonce] =
390
+ ConversionRate({leafTotal: leafTotal, localTotal: localAmount});
376
391
  }
377
392
  }
378
393
  }
@@ -132,6 +132,7 @@ library JBSwapPoolLib {
132
132
  config: config,
133
133
  key: v4Key,
134
134
  normalizedTokenIn: normalizedIn,
135
+ originalTokenIn: tokenIn,
135
136
  amount: amount,
136
137
  minAmountOut: minAmountOut
137
138
  });
@@ -142,6 +143,7 @@ library JBSwapPoolLib {
142
143
  key: v4Key,
143
144
  normalizedTokenIn: normalizedIn,
144
145
  normalizedTokenOut: normalizedOut,
146
+ originalTokenIn: tokenIn,
145
147
  amount: amount
146
148
  });
147
149
  }
@@ -893,6 +895,7 @@ library JBSwapPoolLib {
893
895
  PoolKey memory key,
894
896
  address normalizedTokenIn,
895
897
  address normalizedTokenOut,
898
+ address originalTokenIn,
896
899
  uint256 amount
897
900
  )
898
901
  internal
@@ -909,7 +912,12 @@ library JBSwapPoolLib {
909
912
 
910
913
  // Execute the swap through the V4 PoolManager.
911
914
  amountOut = _executeV4Swap({
912
- config: config, key: key, normalizedTokenIn: normalizedTokenIn, amount: amount, minAmountOut: minOut
915
+ config: config,
916
+ key: key,
917
+ normalizedTokenIn: normalizedTokenIn,
918
+ originalTokenIn: originalTokenIn,
919
+ amount: amount,
920
+ minAmountOut: minOut
913
921
  });
914
922
  }
915
923
 
@@ -1001,6 +1009,7 @@ library JBSwapPoolLib {
1001
1009
  SwapConfig memory config,
1002
1010
  PoolKey memory key,
1003
1011
  address normalizedTokenIn,
1012
+ address originalTokenIn,
1004
1013
  uint256 amount,
1005
1014
  uint256 minAmountOut
1006
1015
  )
@@ -1013,6 +1022,16 @@ library JBSwapPoolLib {
1013
1022
  // Determine swap direction based on currency ordering in the pool key.
1014
1023
  bool zeroForOne = Currency.unwrap(key.currency0) == v4In;
1015
1024
 
1025
+ // Tell the unlock callback whether to consume any wrapped-native-token balance the caller may hold.
1026
+ // The pool's input side is native iff the swap input came from the wrapped-native ERC-20 (not the
1027
+ // NATIVE_TOKEN sentinel). If the caller's input was already native (NATIVE_TOKEN sentinel), the caller
1028
+ // holds raw ETH for THIS swap; any wrapped balance it holds is for unrelated reasons (e.g., backing other
1029
+ // claims) and must not be consumed here.
1030
+ address callbackWrappedNativeToken;
1031
+ if (v4In == address(0) && originalTokenIn != JBConstants.NATIVE_TOKEN) {
1032
+ callbackWrappedNativeToken = config.wrappedNativeToken;
1033
+ }
1034
+
1016
1035
  // Build the encoded unlock data in a scoped block to avoid stack-too-deep.
1017
1036
  bytes memory unlockData;
1018
1037
  {
@@ -1027,7 +1046,7 @@ library JBSwapPoolLib {
1027
1046
  int256 exactInputAmount = -int256(amount);
1028
1047
 
1029
1048
  unlockData = abi.encode(
1030
- key, zeroForOne, exactInputAmount, sqrtPriceLimitX96, minAmountOut, config.wrappedNativeToken
1049
+ key, zeroForOne, exactInputAmount, sqrtPriceLimitX96, minAmountOut, callbackWrappedNativeToken
1031
1050
  );
1032
1051
  }
1033
1052