@bananapus/suckers-v6 0.0.23 → 0.0.25

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.
Files changed (70) hide show
  1. package/RISKS.md +5 -1
  2. package/USER_JOURNEYS.md +13 -0
  3. package/foundry.toml +1 -0
  4. package/package.json +8 -5
  5. package/script/Deploy.s.sol +28 -2
  6. package/src/JBArbitrumSucker.sol +14 -13
  7. package/src/JBCCIPSucker.sol +111 -144
  8. package/src/JBOptimismSucker.sol +0 -5
  9. package/src/JBSucker.sol +517 -314
  10. package/src/JBSuckerRegistry.sol +186 -19
  11. package/src/JBSwapCCIPSucker.sol +600 -0
  12. package/src/deployers/JBArbitrumSuckerDeployer.sol +10 -0
  13. package/src/deployers/JBBaseSuckerDeployer.sol +3 -0
  14. package/src/deployers/JBCCIPSuckerDeployer.sol +21 -8
  15. package/src/deployers/JBCeloSuckerDeployer.sol +8 -0
  16. package/src/deployers/JBOptimismSuckerDeployer.sol +8 -0
  17. package/src/deployers/JBSuckerDeployer.sol +13 -0
  18. package/src/deployers/JBSwapCCIPSuckerDeployer.sol +117 -0
  19. package/src/interfaces/ICCIPRouter.sol +1 -1
  20. package/src/interfaces/IGeomeanOracle.sol +21 -0
  21. package/src/interfaces/IJBSucker.sol +28 -1
  22. package/src/interfaces/IJBSuckerRegistry.sol +36 -0
  23. package/src/interfaces/IJBSwapCCIPSuckerDeployer.sol +27 -0
  24. package/src/libraries/CCIPHelper.sol +109 -39
  25. package/src/libraries/JBCCIPLib.sol +188 -0
  26. package/src/libraries/JBRelayBeneficiary.sol +56 -0
  27. package/src/libraries/JBSuckerLib.sol +343 -0
  28. package/src/libraries/JBSwapLib.sol +144 -0
  29. package/src/libraries/JBSwapPoolLib.sol +875 -0
  30. package/src/structs/JBDenominatedAmount.sol +13 -0
  31. package/src/structs/JBMessageRoot.sol +18 -0
  32. package/test/ForkClaimMainnet.t.sol +1036 -0
  33. package/test/ForkMainnet.t.sol +243 -35
  34. package/test/ForkSwap.t.sol +445 -0
  35. package/test/ForkSwapMainnet.t.sol +523 -0
  36. package/test/InteropCompat.t.sol +37 -14
  37. package/test/SuckerAttacks.t.sol +31 -7
  38. package/test/SuckerDeepAttacks.t.sol +92 -15
  39. package/test/SuckerRegressions.t.sol +15 -3
  40. package/test/TestAuditGaps.sol +92 -14
  41. package/test/audit/ArbitrumL2ToRemoteFeeDoS.t.sol +9 -0
  42. package/test/audit/DeprecatedSuckerDestination.t.sol +11 -1
  43. package/test/audit/ToRemoteFeeFallback.t.sol +9 -0
  44. package/test/audit/TrustedForwarderSpoof.t.sol +14 -2
  45. package/test/audit/TrustedForwarderSpoofCCIP.t.sol +12 -3
  46. package/test/audit/codex-CCIPLegacyFormatCompatibility.t.sol +85 -0
  47. package/test/audit/codex-CCIPWrappedNativeMisunwrap.t.sol +196 -0
  48. package/test/audit/codex-FeeLocking.t.sol +328 -0
  49. package/test/audit/codex-NemesisSwapQueueOrder.t.sol +186 -0
  50. package/test/audit/codex-PeerDeterminism.t.sol +174 -0
  51. package/test/audit/codex-PeerSnapshotDesync.t.sol +162 -0
  52. package/test/audit/codex-SwapBatchRateMixing.t.sol +139 -0
  53. package/test/audit/codex-SwapZeroAmountBatchGap.t.sol +203 -0
  54. package/test/audit/codex-ToRemoteFeeIrrecoverable.t.sol +15 -0
  55. package/test/mocks/MockMessenger.sol +1 -1
  56. package/test/regression/MapTokensDust.t.sol +5 -0
  57. package/test/unit/ccip_native_interop.t.sol +56 -14
  58. package/test/unit/ccip_refund.t.sol +9 -1
  59. package/test/unit/deployer.t.sol +207 -0
  60. package/test/unit/emergency.t.sol +7 -2
  61. package/test/unit/fee_fallback.t.sol +16 -0
  62. package/test/unit/invariants.t.sol +3 -0
  63. package/test/unit/merkle.t.sol +16 -10
  64. package/test/unit/merkle_equivalence.t.sol +121 -0
  65. package/test/unit/multi_chain_evolution.t.sol +5 -3
  66. package/test/unit/peer_chain_state.t.sol +618 -0
  67. package/test/unit/relay_beneficiary.t.sol +141 -0
  68. package/test/unit/swap_ccip.t.sol +577 -0
  69. package/test/Fork.t.sol +0 -502
  70. package/test/ForkClaim.t.sol +0 -549
package/RISKS.md CHANGED
@@ -127,6 +127,10 @@ The registry owner can adjust `toRemoteFee` via `JBSuckerRegistry.setToRemoteFee
127
127
 
128
128
  The fee is paid to `FEE_PROJECT_ID` (the protocol project), not to the sucker's own `projectId()`. This centralizes fee collection, but it is still only best-effort: if the fee project's native terminal is missing or its `pay` call reverts, the fee ETH stays in the sucker contract and is later recoverable through the normal claim path. The sucker's project does not directly benefit from the anti-spam fee.
129
129
 
130
- ### 10.5 `mapTokens` refunds ETH on enable-only batches
130
+ ### 10.5 Hookless V4 spot pricing is sandwich-vulnerable by design
131
+
132
+ When the only available Uniswap pool for a cross-denomination swap is a hookless V4 pool (no V3 pool exists), `_getV4Quote` falls back to the instantaneous spot tick from `POOL_MANAGER.getSlot0()` instead of a TWAP oracle. This tick is manipulable via sandwich attacks, allowing an attacker to skew the `minAmountOut` and extract value from the swap. The sigmoid slippage model limits the damage but operates on a corrupted baseline. This is an accepted tradeoff: reverting when no TWAP is available would cause the CCIP message to fail, leaving bridged tokens stuck in the CCIP router until manual retry. Getting some value (even at a worse rate) is preferred over a stuck bridge message, especially since this path only triggers when no V3 pool with built-in TWAP exists at all. The hooked V4 path also falls back to spot if the hook's `observe()` reverts, with the same tradeoff.
133
+
134
+ ### 10.6 `mapTokens` refunds ETH on enable-only batches
131
135
 
132
136
  `mapTokens()` only uses `msg.value` when one or more mappings are being disabled and need transport payment for the final root flush. If every mapping in the batch is enable-only (`numberToDisable == 0`), the full `msg.value` is refunded to `_msgSender()`. If the refund transfer fails (e.g., the caller is a non-payable contract), the call reverts with `JBSucker_RefundFailed`. When disables are present, any dust remainder from integer division (`msg.value % numberToDisable`) is also refunded on a best-effort basis.
package/USER_JOURNEYS.md CHANGED
@@ -66,6 +66,19 @@
66
66
  2. Use `exitThroughEmergencyHatch(...)` with the relevant claim data.
67
67
  3. Treat emergency execution slots as distinct state that still must not allow the same economic position to be claimed twice.
68
68
 
69
+ ## Journey 6: Create A Proxy Project And Route Payments Through It
70
+
71
+ **Starting state:** a project exists on the home chain with an ERC-20 token deployed, and wants to let users on other chains acquire proxy tokens backed by real project tokens.
72
+
73
+ **Success:** a proxy project is created once, and payments routed through it mint proxy tokens 1:1 with the real tokens deposited.
74
+
75
+ **Flow**
76
+ 1. Call `JBSuckerTerminal.createProxy(realProjectId, name, symbol, salt)` to launch a locked proxy project with a permanent 1:1 ruleset, backed by the real project's ERC-20 token.
77
+ 2. Anyone can then call `JBSuckerTerminal.pay(proxyProjectId, token, amount, beneficiary, ...)` to pay the real project and automatically receive proxy tokens.
78
+ 3. Suckers registered for the proxy project get mint permission through `JBSuckerTerminal`'s data hook, enabling cross-chain bridging of proxy token positions.
79
+
80
+ **Failure cases that matter:** calling `createProxy` on a project without an ERC-20 deployed, attempting to create a second proxy for the same project, and paying with a token that has no primary terminal on the real project.
81
+
69
82
  ## Hand-Offs
70
83
 
71
84
  - Use [nana-omnichain-deployers-v6](../nana-omnichain-deployers-v6/USER_JOURNEYS.md) when a project wants suckers packaged into its launch flow instead of deployed separately.
package/foundry.toml CHANGED
@@ -29,3 +29,4 @@ arbitrum = "${RPC_ARBITRUM_MAINNET}"
29
29
  optimism = "${RPC_OPTIMISM_MAINNET}"
30
30
  base = "${RPC_BASE_MAINNET}"
31
31
  celo = "${RPC_CELO_MAINNET}"
32
+ tempo = "${RPC_TEMPO_MAINNET}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/suckers-v6",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,12 +19,15 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@arbitrum/nitro-contracts": "^1.2.1",
22
- "@bananapus/core-v6": "^0.0.32",
23
- "@bananapus/permission-ids-v6": "^0.0.15",
24
- "@chainlink/contracts-ccip": "^1.5.0",
25
- "@chainlink/local": "github:smartcontractkit/chainlink-local",
22
+ "@bananapus/core-v6": "^0.0.34",
23
+ "@bananapus/permission-ids-v6": "^0.0.17",
24
+ "@chainlink/contracts-ccip": "^1.6.0",
25
+ "@chainlink/local": "github:smartcontractkit/chainlink-local#v0.2.7",
26
26
  "@openzeppelin/contracts": "^5.6.1",
27
27
  "@prb/math": "^4.1.0",
28
+ "@uniswap/v3-core": "github:Uniswap/v3-core#0.8",
29
+ "@uniswap/v3-periphery": "github:Uniswap/v3-periphery#0.8",
30
+ "@uniswap/v4-core": "^1.0.2",
28
31
  "solady": "^0.1.26"
29
32
  },
30
33
  "devDependencies": {
@@ -52,6 +52,8 @@ contract DeployScript is Script, Sphinx {
52
52
  bytes32 ARB_OP_SALT = "_SUCKER_ARB_OP_V6_";
53
53
  // forge-lint: disable-next-line(mixed-case-variable)
54
54
  bytes32 OP_BASE_SALT = "_SUCKER_OP_BASE_V6_";
55
+ // forge-lint: disable-next-line(mixed-case-variable)
56
+ bytes32 TEMPO_SALT = "_SUCKER_ETH_TEMPO_V6_";
55
57
 
56
58
  // forge-lint: disable-next-line(mixed-case-variable)
57
59
  IJBSuckerRegistry REGISTRY;
@@ -62,8 +64,9 @@ contract DeployScript is Script, Sphinx {
62
64
  function configureSphinx() public override {
63
65
  // TODO: Update to contain JB Emergency Developers
64
66
  sphinxConfig.projectName = "nana-suckers-v6";
65
- sphinxConfig.mainnets = ["ethereum", "optimism", "base", "arbitrum"];
66
- sphinxConfig.testnets = ["ethereum_sepolia", "optimism_sepolia", "base_sepolia", "arbitrum_sepolia"];
67
+ sphinxConfig.mainnets = ["ethereum", "optimism", "base", "arbitrum", "tempo"];
68
+ sphinxConfig.testnets =
69
+ ["ethereum_sepolia", "optimism_sepolia", "base_sepolia", "arbitrum_sepolia", "tempo_moderato"];
67
70
  }
68
71
 
69
72
  function run() public {
@@ -580,6 +583,16 @@ contract DeployScript is Script, Sphinx {
580
583
  })
581
584
  )
582
585
  );
586
+
587
+ // Tempo
588
+ PRE_APPROVED_DEPLOYERS.push(
589
+ address(
590
+ _deployCCIPSuckerFor({
591
+ salt: TEMPO_SALT,
592
+ remoteChainId: block.chainid == 1 ? CCIPHelper.TEMPO_ID : CCIPHelper.TEMPO_MOD_ID
593
+ })
594
+ )
595
+ );
583
596
  }
584
597
 
585
598
  // Check if we should do the L2 portion.
@@ -678,6 +691,19 @@ contract DeployScript is Script, Sphinx {
678
691
  )
679
692
  );
680
693
  }
694
+
695
+ // Tempo / Tempo Moderato.
696
+ if (block.chainid == 4217 || block.chainid == 42_431) {
697
+ // Tempo -> ETH.
698
+ PRE_APPROVED_DEPLOYERS.push(
699
+ address(
700
+ _deployCCIPSuckerFor({
701
+ salt: TEMPO_SALT,
702
+ remoteChainId: block.chainid == 4217 ? CCIPHelper.ETH_ID : CCIPHelper.ETH_SEP_ID
703
+ })
704
+ )
705
+ );
706
+ }
681
707
  }
682
708
 
683
709
  // forge-lint: disable-next-line(mixed-case-function)
@@ -12,7 +12,6 @@ import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
12
12
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
13
13
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
14
14
  import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
15
- import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol";
16
15
 
17
16
  import {JBSucker} from "./JBSucker.sol";
18
17
  import {JBArbitrumSuckerDeployer} from "./deployers/JBArbitrumSuckerDeployer.sol";
@@ -25,13 +24,9 @@ import {IJBArbitrumSucker} from "./interfaces/IJBArbitrumSucker.sol";
25
24
  import {ARBChains} from "./libraries/ARBChains.sol";
26
25
  import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
27
26
  import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
28
- import {MerkleLib} from "./utils/MerkleLib.sol";
29
27
 
30
28
  /// @notice A `JBSucker` implementation to suck tokens between two chains connected by an Arbitrum bridge.
31
29
  contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
32
- using BitMaps for BitMaps.BitMap;
33
- using MerkleLib for MerkleLib.Tree;
34
-
35
30
  //*********************************************************************//
36
31
  // --------------------------- custom errors ------------------------- //
37
32
  //*********************************************************************//
@@ -98,7 +93,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
98
93
  /// @return valid A flag if the sender is a valid representative of the remote peer.
99
94
  function _isRemotePeer(address sender) internal view override returns (bool) {
100
95
  // Convert the bytes32 peer to an address for comparison with EVM bridge contracts.
101
- address peerAddress = _toAddress(peer());
96
+ address peerAddress = _peerAddress();
102
97
 
103
98
  // If we are the L1 peer,
104
99
  if (LAYER == JBLayer.L1) {
@@ -125,7 +120,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
125
120
  )
126
121
  internal
127
122
  {
128
- address peerAddress = _toAddress(peer());
123
+ address peerAddress = _peerAddress();
129
124
  // slither-disable-next-line unused-return,calls-loop
130
125
  ARBINBOX.unsafeCreateRetryableTicket{value: callTransportCost + nativeValue}({
131
126
  to: peerAddress,
@@ -139,6 +134,14 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
139
134
  });
140
135
  }
141
136
 
137
+ /// @notice Approves the Arbitrum gateway to spend `amount` of `token`.
138
+ /// @param token The ERC-20 token to approve.
139
+ /// @param amount The amount to approve.
140
+ function _approveGateway(address token, uint256 amount) internal {
141
+ // slither-disable-next-line calls-loop
142
+ SafeERC20.forceApprove({token: IERC20(token), spender: GATEWAYROUTER.getGateway(token), value: amount});
143
+ }
144
+
142
145
  /// @notice Uses the L1/L2 gateway to send the root and assets over the bridge to the peer.
143
146
  /// @param transportPayment the amount of `msg.value` that is going to get paid for sending this message.
144
147
  /// @param token The token to bridge the outbox tree for.
@@ -191,13 +194,12 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
191
194
  uint256 nativeValue;
192
195
 
193
196
  // Cache peer address to avoid redundant calls.
194
- address peerAddress = _toAddress(peer());
197
+ address peerAddress = _peerAddress();
195
198
 
196
199
  // If the token is an ERC-20, bridge it to the peer.
197
200
  // If the amount is `0` then we do not need to bridge any ERC20.
198
201
  if (token != JBConstants.NATIVE_TOKEN && amount != 0) {
199
- // slither-disable-next-line calls-loop
200
- SafeERC20.forceApprove({token: IERC20(token), spender: GATEWAYROUTER.getGateway(token), value: amount});
202
+ _approveGateway({token: token, amount: amount});
201
203
 
202
204
  // Convert bytes32 types to address at the Arbitrum bridge API boundary.
203
205
  // slither-disable-next-line calls-loop,unused-return
@@ -274,8 +276,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
274
276
  }
275
277
 
276
278
  // Approve the tokens to be bridged.
277
- // slither-disable-next-line calls-loop
278
- SafeERC20.forceApprove({token: IERC20(token), spender: GATEWAYROUTER.getGateway(token), value: amount});
279
+ _approveGateway({token: token, amount: amount});
279
280
 
280
281
  // Perform the ERC-20 bridge transfer. Convert bytes32 peer to address at the Arbitrum bridge API boundary.
281
282
  // slither-disable-start out-of-order-retryable
@@ -283,7 +284,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
283
284
  IArbL1GatewayRouter(address(GATEWAYROUTER)).outboundTransferCustomRefund{value: tokenTransportCost}({
284
285
  token: token,
285
286
  refundTo: _msgSender(),
286
- to: _toAddress(peer()),
287
+ to: _peerAddress(),
287
288
  amount: amount,
288
289
  maxGas: remoteToken.minGas,
289
290
  gasPriceBid: maxFeePerGas,
@@ -1,36 +1,41 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity 0.8.28;
3
3
 
4
+ // External packages (alphabetized)
4
5
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
5
6
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
6
7
  import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
7
8
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
8
- import {IAny2EVMMessageReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IAny2EVMMessageReceiver.sol";
9
- import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
10
- import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
11
- import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
12
- import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol";
13
-
9
+ import {IAny2EVMMessageReceiver} from "@chainlink/contracts-ccip/contracts/interfaces/IAny2EVMMessageReceiver.sol";
10
+ import {Client} from "@chainlink/contracts-ccip/contracts/libraries/Client.sol";
11
+ // Local: base contracts
14
12
  import {JBSucker} from "./JBSucker.sol";
13
+
14
+ // Local: deployers
15
15
  import {JBCCIPSuckerDeployer} from "./deployers/JBCCIPSuckerDeployer.sol";
16
- import {IJBSuckerRegistry} from "./interfaces/IJBSuckerRegistry.sol";
17
- import {ICCIPRouter, IWrappedNativeToken} from "./interfaces/ICCIPRouter.sol";
16
+
17
+ // Local: interfaces (alphabetized)
18
+ import {ICCIPRouter} from "./interfaces/ICCIPRouter.sol";
18
19
  import {IJBCCIPSuckerDeployer} from "./interfaces/IJBCCIPSuckerDeployer.sol";
20
+ import {IJBSuckerRegistry} from "./interfaces/IJBSuckerRegistry.sol";
21
+
22
+ // Local: libraries (alphabetized)
23
+ import {CCIPHelper} from "./libraries/CCIPHelper.sol";
24
+ import {JBCCIPLib} from "./libraries/JBCCIPLib.sol";
25
+
26
+ // Local: structs (alphabetized)
19
27
  import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
20
28
  import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
21
29
  import {JBTokenMapping} from "./structs/JBTokenMapping.sol";
22
- import {MerkleLib} from "./utils/MerkleLib.sol";
23
30
 
24
31
  /// @notice A `JBSucker` implementation to suck tokens between chains with Chainlink CCIP
25
32
  contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
26
- using MerkleLib for MerkleLib.Tree;
27
- using BitMaps for BitMaps.BitMap;
28
-
29
33
  //*********************************************************************//
30
34
  // --------------------------- custom errors ------------------------- //
31
35
  //*********************************************************************//
32
36
 
33
37
  error JBCCIPSucker_InvalidRouter(address router);
38
+ error JBCCIPSucker_UnknownMessageType(uint8 messageType);
34
39
 
35
40
  //*********************************************************************//
36
41
  // ------------------------------ events ----------------------------- //
@@ -43,6 +48,13 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
43
48
  /// @param amount The amount of the failed refund (permanently stuck in this contract).
44
49
  event TransportPaymentRefundFailed(address indexed recipient, uint256 amount);
45
50
 
51
+ //*********************************************************************//
52
+ // ----------------------- internal constants ------------------------ //
53
+ //*********************************************************************//
54
+
55
+ /// @notice Message type prefix for root messages (fromRemote).
56
+ uint8 internal constant _CCIP_MSG_TYPE_ROOT = 0;
57
+
46
58
  //*********************************************************************//
47
59
  // --------------- public immutable stored properties ---------------- //
48
60
  //*********************************************************************//
@@ -60,10 +72,13 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
60
72
  // ---------------------------- constructor -------------------------- //
61
73
  //*********************************************************************//
62
74
 
63
- /// @param deployer A contract that deploys the clones for this contracts.
75
+ /// @param deployer A contract that deploys the clones for this contract.
64
76
  /// @param directory A contract storing directories of terminals and controllers for each project.
65
77
  /// @param tokens A contract that manages token minting and burning.
66
78
  /// @param permissions A contract storing permissions.
79
+ /// @param feeProjectId The ID of the project that receives fees.
80
+ /// @param registry The sucker registry that tracks deployed suckers.
81
+ /// @param trustedForwarder The trusted forwarder for ERC-2771 meta-transactions.
67
82
  constructor(
68
83
  JBCCIPSuckerDeployer deployer,
69
84
  IJBDirectory directory,
@@ -75,10 +90,16 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
75
90
  )
76
91
  JBSucker(directory, permissions, tokens, feeProjectId, registry, trustedForwarder)
77
92
  {
93
+ // Read the remote chain ID from the deployer.
78
94
  REMOTE_CHAIN_ID = IJBCCIPSuckerDeployer(deployer).ccipRemoteChainId();
95
+
96
+ // Read the CCIP chain selector from the deployer.
79
97
  REMOTE_CHAIN_SELECTOR = IJBCCIPSuckerDeployer(deployer).ccipRemoteChainSelector();
98
+
99
+ // Read the CCIP router from the deployer.
80
100
  CCIP_ROUTER = IJBCCIPSuckerDeployer(deployer).ccipRouter();
81
101
 
102
+ // Ensure the CCIP router is not the zero address.
82
103
  if (address(CCIP_ROUTER) == address(0)) revert JBCCIPSucker_InvalidRouter(address(CCIP_ROUTER));
83
104
  }
84
105
 
@@ -87,9 +108,8 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
87
108
  //*********************************************************************//
88
109
 
89
110
  /// @notice Returns the chain on which the peer is located.
90
- /// @return chainId of the peer.
111
+ /// @return chainId The chain ID of the peer.
91
112
  function peerChainId() external view virtual override returns (uint256 chainId) {
92
- // Return the remote chain id
93
113
  return REMOTE_CHAIN_ID;
94
114
  }
95
115
 
@@ -97,23 +117,22 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
97
117
  // ------------------------- public views ---------------------------- //
98
118
  //*********************************************************************//
99
119
 
100
- /// @notice Return the current router
101
- /// @return CCIP router address
102
- function getRouter() public view returns (address) {
120
+ /// @notice Returns the address of the current CCIP router.
121
+ /// @return router The CCIP router address.
122
+ function getRouter() public view returns (address router) {
103
123
  return address(CCIP_ROUTER);
104
124
  }
105
125
 
106
- /// @notice IERC165 supports an interfaceId
107
- /// @param interfaceId The interfaceId to check
108
- /// @return true if the interfaceId is supported
109
- /// @dev Should indicate whether the contract implements IAny2EVMMessageReceiver
110
- /// e.g. return interfaceId == type(IAny2EVMMessageReceiver).interfaceId || interfaceId == type(IERC165).interfaceId
126
+ /// @notice Checks whether this contract supports a given interface.
127
+ /// @param interfaceId The interface ID to check.
128
+ /// @return supported Whether the interface is supported.
129
+ /// @dev Should indicate whether the contract implements IAny2EVMMessageReceiver.
111
130
  /// This allows CCIP to check if ccipReceive is available before calling it.
112
131
  /// If this returns false or reverts, only tokens are transferred to the receiver.
113
132
  /// If this returns true, tokens are transferred and ccipReceive is called atomically.
114
133
  /// Additionally, if the receiver address does not have code associated with
115
134
  /// it at the time of execution (EXTCODESIZE returns 0), only tokens will be transferred.
116
- function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
135
+ function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool supported) {
117
136
  return interfaceId == type(IAny2EVMMessageReceiver).interfaceId || super.supportsInterface(interfaceId);
118
137
  }
119
138
 
@@ -128,70 +147,40 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
128
147
  /// reverting on invalid sender/peer data is correct here because accepting and silently discarding a
129
148
  /// malformed message would lose the bridged tokens with no recovery path. A revert keeps tokens in the
130
149
  /// CCIP router where they can be retried or recovered.
131
- function ccipReceive(Client.Any2EVMMessage calldata any2EvmMessage) external override {
150
+ function ccipReceive(Client.Any2EVMMessage calldata any2EvmMessage) external virtual override {
132
151
  // Use msg.sender (not _msgSender()) because the CCIP router never uses ERC2771 meta-transactions.
133
152
  // Using _msgSender() would allow a trusted forwarder to spoof the router address via the
134
153
  // ERC-2771 calldata suffix.
135
154
  if (msg.sender != address(CCIP_ROUTER)) revert JBSucker_NotPeer(_toBytes32(msg.sender));
136
155
 
137
- // Decode the message root from the peer
138
- JBMessageRoot memory root = abi.decode(any2EvmMessage.data, (JBMessageRoot));
156
+ // Decode the sender address from the CCIP message.
139
157
  address origin = abi.decode(any2EvmMessage.sender, (address));
140
158
 
141
159
  // Make sure that the message came from our peer.
142
- if (origin != _toAddress(peer()) || any2EvmMessage.sourceChainSelector != REMOTE_CHAIN_SELECTOR) {
160
+ if (origin != _peerAddress() || any2EvmMessage.sourceChainSelector != REMOTE_CHAIN_SELECTOR) {
143
161
  revert JBSucker_NotPeer(_toBytes32(origin));
144
162
  }
145
163
 
146
- // By design, CCIP guarantees token delivery matches the sent amount. Validating the
147
- // delivered amount against the message would add gas cost for a check that the bridge itself enforces.
148
- // If CCIP's guarantee fails, the bridge itself is compromised.
149
- //
150
- // We intentionally do NOT validate root.amount against destTokenAmounts[0].amount here.
151
- // CCIP fees are paid separately (via feeToken), so delivered amounts should always match what was sent.
152
- // If we reverted on a mismatch, the tokens already transferred by CCIP would be locked in the router
153
- // with no recovery path — a concrete fund-loss risk that outweighs the theoretical defense-in-depth
154
- // benefit against a CCIP-level failure or peer compromise.
155
-
156
- // We either send no tokens or a single token.
157
- if (any2EvmMessage.destTokenAmounts.length == 1) {
158
- // The sucker only handles ERC-20s or native. CCIP delivers wrapped native (WETH).
159
- Client.EVMTokenAmount memory tokenAmount = any2EvmMessage.destTokenAmounts[0];
160
- // Unwrap WETH -> ETH only when the root says the token is NATIVE_TOKEN.
161
- // When root.token is an ERC-20 address (e.g., bridging to a chain where ETH is an ERC-20), no unwrap.
162
- if (root.token == _toBytes32(JBConstants.NATIVE_TOKEN)) {
163
- // We can (safely) assume that the token that is set in the `destTokenAmounts` is a valid wrapped
164
- // native.
165
- // If this ends up not being the case then our sanity check to see if we unwrapped the native asset will
166
- // fail.
167
- // forge-lint: disable-next-line(mixed-case-variable)
168
- IWrappedNativeToken wrapped_native = IWrappedNativeToken(tokenAmount.token);
169
- uint256 balanceBefore = _balanceOf({token: JBConstants.NATIVE_TOKEN, addr: address(this)});
170
-
171
- // Withdraw the wrapped native asset.
172
- wrapped_native.withdraw(tokenAmount.amount);
173
-
174
- // Sanity check the unwrapping of the native asset.
175
- // slither-disable-next-line incorrect-equality
176
- assert(
177
- balanceBefore + tokenAmount.amount
178
- == _balanceOf({token: JBConstants.NATIVE_TOKEN, addr: address(this)})
179
- );
180
- }
181
- }
164
+ // Discriminate message type: abi.encode(uint8 type, bytes payload).
165
+ (uint8 messageType, bytes memory payload) = JBCCIPLib.decodeTypedMessage(any2EvmMessage.data);
182
166
 
183
- // Call ourselves to process the root.
184
- this.fromRemote(root);
185
- }
167
+ // Handle root messages (merkle tree updates with bridged assets).
168
+ if (messageType == _CCIP_MSG_TYPE_ROOT) {
169
+ // Decode the root message from the payload.
170
+ JBMessageRoot memory root = abi.decode(payload, (JBMessageRoot));
186
171
 
187
- //*********************************************************************//
188
- // ------------------------ internal views --------------------------- //
189
- //*********************************************************************//
172
+ // Only unwrap WETH -> ETH when the root targets native token (not when claiming WETH as ERC-20).
173
+ if (root.token == bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN)))) {
174
+ JBCCIPLib.unwrapReceivedTokens({
175
+ ccipRouter: CCIP_ROUTER, destTokenAmounts: any2EvmMessage.destTokenAmounts
176
+ });
177
+ }
190
178
 
191
- /// @notice Unused in this context.
192
- function _isRemotePeer(address sender) internal view override returns (bool _valid) {
193
- // NOTICE: We do not check if its the `peer` here, as this contract is supposed to be the caller *NOT* the peer.
194
- return sender == address(this);
179
+ // Forward the root message to this contract's fromRemote handler.
180
+ this.fromRemote(root);
181
+ } else {
182
+ revert JBCCIPSucker_UnknownMessageType(messageType);
183
+ }
195
184
  }
196
185
 
197
186
  //*********************************************************************//
@@ -199,14 +188,16 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
199
188
  //*********************************************************************//
200
189
 
201
190
  /// @notice Uses CCIP to send the root and assets over the bridge to the peer.
202
- /// @dev CCIP transport payment refund failures emit a `TransportPaymentRefundFailed` event by design rather
203
- /// than reverting. After `ccipSend` commits the bridge message and transfers tokens, reverting the transaction
204
- /// would leave the CCIP message in-flight with no corresponding on-chain state update — the tokens would be
205
- /// gone, the merkle root never processed, and the outbox inconsistent. Emitting an event preserves
206
- /// observability while preventing a single failed refund from blocking the entire bridge operation.
207
- /// @param transportPayment the amount of `msg.value` that is going to get paid for sending this message.
191
+ /// @dev Delegates CCIP message construction and sending to JBCCIPLib (via DELEGATECALL) to reduce bytecode.
192
+ /// @dev Supports two fee modes:
193
+ /// - `transportPayment > 0`: pay CCIP fees in native ETH (existing behavior).
194
+ /// - `transportPayment == 0`: pay CCIP fees in LINK from the sucker's pre-funded balance.
195
+ /// This enables chains with no meaningful native token (e.g. Tempo) to use CCIP.
196
+ /// @param transportPayment The amount of `msg.value` that is going to get paid for sending this message.
208
197
  /// @param token The token to bridge the outbox tree for.
198
+ /// @param amount The amount of tokens to bridge.
209
199
  /// @param remoteToken Information about the remote token being bridged to.
200
+ /// @param sucker_message The message root to send to the remote peer.
210
201
  // forge-lint: disable-next-line(mixed-case-function)
211
202
  function _sendRootOverAMB(
212
203
  uint256 transportPayment,
@@ -218,88 +209,64 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
218
209
  JBMessageRoot memory sucker_message
219
210
  )
220
211
  internal
212
+ virtual
221
213
  override
222
214
  {
223
- // Make sure we are attempting to pay the bridge
224
- if (transportPayment == 0) revert JBSucker_ExpectedMsgValue();
225
-
215
+ // Start with the base gas limit for cross-chain calls.
226
216
  uint256 gasLimit = MESSENGER_BASE_GAS_LIMIT;
227
217
  Client.EVMTokenAmount[] memory tokenAmounts;
218
+
228
219
  if (amount != 0) {
229
- // If we also do an asset transfer then we increase the min required gas amount.
220
+ // Add extra gas for the ERC-20 token transfer on the remote chain.
230
221
  gasLimit += remoteToken.minGas;
231
222
 
232
- // Wrap native ETH -> WETH for CCIP bridging. CCIP only transports ERC-20s.
233
- // This is why `_validateTokenMapping` enforces minGas for native tokens too.
234
- if (token == JBConstants.NATIVE_TOKEN) {
235
- // Get the wrapped native token.
236
- // slither-disable-next-line calls-loop
237
- // forge-lint: disable-next-line(mixed-case-variable)
238
- IWrappedNativeToken wrapped_native = CCIP_ROUTER.getWrappedNative();
239
- // Deposit the wrapped native asset.
240
- // slither-disable-next-line calls-loop,arbitrary-send-eth
241
- wrapped_native.deposit{value: amount}();
242
- // Update the token to be the wrapped native asset.
243
- token = address(wrapped_native);
244
- }
245
-
246
- // Set the token amounts
247
- tokenAmounts = new Client.EVMTokenAmount[](1);
248
- tokenAmounts[0] = Client.EVMTokenAmount({token: token, amount: amount});
249
-
250
- // approve the Router to spend tokens on contract's behalf. It will spend the amount of the given token
251
- SafeERC20.forceApprove({token: IERC20(token), spender: address(CCIP_ROUTER), value: amount});
223
+ // Wrap native ETH -> WETH if needed, build the CCIP token amounts array, and approve the router.
224
+ // slither-disable-next-line unused-return
225
+ (tokenAmounts,) = JBCCIPLib.prepareTokenAmounts({ccipRouter: CCIP_ROUTER, token: token, amount: amount});
226
+ } else {
227
+ // No tokens to bridge — use an empty array.
228
+ tokenAmounts = new Client.EVMTokenAmount[](0);
252
229
  }
253
230
 
254
- // Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message
255
- // CCIP requires EVM addresses, so convert the bytes32 peer to an address for the receiver field.
256
- Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
257
- receiver: abi.encode(_toAddress(peer())),
258
- data: abi.encode(sucker_message),
231
+ // Determine fee payment mode: native ETH or LINK token.
232
+ // When transportPayment == 0, we pay in LINK from the sucker's pre-funded balance.
233
+ // This enables chains with no meaningful native token (e.g. Tempo).
234
+ address feeToken = transportPayment == 0 ? CCIPHelper.linkOfChain(block.chainid) : address(0);
235
+
236
+ // Build and send the CCIP message with the root payload.
237
+ // slither-disable-next-line reentrancy-events
238
+ (bool refundFailed, uint256 refundAmount) = JBCCIPLib.sendCCIPMessage({
239
+ ccipRouter: CCIP_ROUTER,
240
+ remoteChainSelector: REMOTE_CHAIN_SELECTOR,
241
+ peerAddress: _peerAddress(),
242
+ transportPayment: transportPayment,
243
+ feeToken: feeToken,
244
+ gasLimit: gasLimit,
245
+ encodedPayload: abi.encode(_CCIP_MSG_TYPE_ROOT, abi.encode(sucker_message)),
259
246
  tokenAmounts: tokenAmounts,
260
- extraArgs: Client._argsToBytes(
261
- // Additional arguments, setting gas limit
262
- Client.EVMExtraArgsV1({gasLimit: gasLimit})
263
- ),
264
- // Set the feeToken to a feeTokenAddress, indicating specific asset will be used for fees,
265
- // We pay in the native asset.
266
- feeToken: address(0)
247
+ refundRecipient: _msgSender()
267
248
  });
268
249
 
269
- // Get the fee required to send the CCIP message
270
- // slither-disable-next-line calls-loop
271
- uint256 fees = CCIP_ROUTER.getFee({destinationChainSelector: REMOTE_CHAIN_SELECTOR, message: message});
250
+ // Emit an event if the excess transport payment refund failed.
251
+ if (refundFailed) emit TransportPaymentRefundFailed(_msgSender(), refundAmount);
252
+ }
272
253
 
273
- if (fees > transportPayment) {
274
- revert JBSucker_InsufficientMsgValue(transportPayment, fees);
275
- }
254
+ //*********************************************************************//
255
+ // ------------------------ internal views --------------------------- //
256
+ //*********************************************************************//
276
257
 
277
- // slither-disable-next-line calls-loop,unused-return
278
- CCIP_ROUTER.ccipSend{value: fees}({destinationChainSelector: REMOTE_CHAIN_SELECTOR, message: message});
279
-
280
- // Refund remaining balance. We use a low-level call that does not revert on failure because
281
- // `ccipSend` above has already committed the bridge message and transferred the tokens. If we
282
- // reverted here (e.g. because the caller is a non-payable contract), the entire transaction
283
- // would roll back — but the CCIP message is already in-flight. The tokens would be gone, the
284
- // merkle root never gets processed, and the outbox state is inconsistent.
285
- //
286
- // If the refund fails, the ETH (transportPayment - fees) will be permanently stuck in this
287
- // contract. There is no sweep or recovery function — `_addToBalance` only
288
- // moves funds tracked via `fromRemote`, not arbitrary ETH. This is an accepted tradeoff:
289
- // stuck dust from a fee overpayment is far less harmful than bricking the entire bridge
290
- // operation. The event provides observability so it doesn't go unnoticed.
291
- //
292
- uint256 refundAmount = transportPayment - fees;
293
- if (refundAmount != 0) {
294
- // slither-disable-next-line calls-loop,msg-value-loop,reentrancy-events
295
- (bool sent,) = _msgSender().call{value: refundAmount}("");
296
- if (!sent) emit TransportPaymentRefundFailed(_msgSender(), refundAmount);
297
- }
258
+ /// @notice Checks whether the given sender is a remote peer. Unused in this context.
259
+ /// @param sender The address to check.
260
+ /// @return _valid Whether the sender is a remote peer.
261
+ function _isRemotePeer(address sender) internal view override returns (bool _valid) {
262
+ // We do not check if it is the `peer` here, as this contract is supposed to be the caller *NOT* the peer.
263
+ return sender == address(this);
298
264
  }
299
265
 
300
- /// @notice Allow sucker implementations to add/override mapping rules to suite their specific needs.
266
+ /// @notice Validates a token mapping. Allows CCIP-specific mapping rules.
301
267
  /// @dev Unlike OP/Arbitrum suckers (which share ETH as native on both chains), this CCIP sucker can connect
302
268
  /// chains with different native tokens. This means `NATIVE_TOKEN` may map to an ERC-20 on the remote chain.
269
+ /// @param map The token mapping to validate.
303
270
  ///
304
271
  /// Example: ETH mainnet (native = ETH) <-> Celo (native = CELO, ETH is an ERC-20).
305
272
  /// - On mainnet: `mapToken({localToken: NATIVE_TOKEN, remoteToken: celoETH_address})`
@@ -7,7 +7,6 @@ import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
7
7
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
8
8
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
9
9
  import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
10
- import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol";
11
10
 
12
11
  import {JBSucker} from "./JBSucker.sol";
13
12
  import {JBOptimismSuckerDeployer} from "./deployers/JBOptimismSuckerDeployer.sol";
@@ -17,13 +16,9 @@ import {IOPMessenger} from "./interfaces/IOPMessenger.sol";
17
16
  import {IOPStandardBridge} from "./interfaces/IOPStandardBridge.sol";
18
17
  import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
19
18
  import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
20
- import {MerkleLib} from "./utils/MerkleLib.sol";
21
19
 
22
20
  /// @notice A `JBSucker` implementation to suck tokens between two chains connected by an OP Bridge.
23
21
  contract JBOptimismSucker is JBSucker, IJBOptimismSucker {
24
- using BitMaps for BitMaps.BitMap;
25
- using MerkleLib for MerkleLib.Tree;
26
-
27
22
  //*********************************************************************//
28
23
  // --------------- public immutable stored properties ---------------- //
29
24
  //*********************************************************************//