@bananapus/suckers-v6 0.0.27 → 0.0.29

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 (31) hide show
  1. package/RISKS.md +14 -2
  2. package/package.json +3 -3
  3. package/src/JBArbitrumSucker.sol +21 -6
  4. package/src/JBCCIPSucker.sol +4 -2
  5. package/src/JBSucker.sol +4 -1
  6. package/src/JBSuckerRegistry.sol +132 -12
  7. package/src/JBSwapCCIPSucker.sol +29 -5
  8. package/src/interfaces/IL1ArbitrumGateway.sol +23 -0
  9. package/src/libraries/JBCCIPLib.sol +19 -6
  10. package/test/ForkClaimMainnet.t.sol +5 -2
  11. package/test/ForkMainnet.t.sol +9 -8
  12. package/test/ForkSwapMainnet.t.sol +10 -8
  13. package/test/SuckerAttacks.t.sol +1 -1
  14. package/test/SuckerCrossChainAdversarial.t.sol +745 -0
  15. package/test/SuckerRegressions.t.sol +1 -1
  16. package/test/audit/2026-04-21-codex-nemesis-RegistryStaleDeprecatedMaxSurplus.t.sol +127 -0
  17. package/test/audit/2026-04-22-codex-nemesis-PeerTopologyAuthBreak.t.sol +112 -0
  18. package/test/audit/2026-04-22-codex-nemesis-RegistryPeerAuthBreak.t.sol +198 -0
  19. package/test/audit/2026-04-22-codex-nemesis-ZeroOutputRetryClaim.t.sol +162 -0
  20. package/test/audit/2026-04-24-codex-nemesis-FreshRound.t.sol +388 -0
  21. package/test/audit/2026-04-24-codex-nemesis-RegistryPeerMismatch.t.sol +112 -0
  22. package/test/audit/2026-04-24-codex-nemesis-RegistryStaleMaxAggregation.t.sol +88 -0
  23. package/test/audit/2026-04-25-codex-nemesis-TransientClaimContext.t.sol +257 -0
  24. package/test/audit/CertikAIScan.t.sol +352 -0
  25. package/test/audit/DeprecatedSuckerAggregateViews.t.sol +247 -0
  26. package/test/audit/ZeroOutputSwapPending.t.sol +218 -0
  27. package/test/audit/codex-nemesis-DeprecatedRemovalUndercount.t.sol +141 -0
  28. package/test/audit/codex-nemesis-SwapZeroLocalTotalUnbackedClaim.t.sol +179 -0
  29. package/test/unit/deployer.t.sol +9 -8
  30. package/test/unit/emergency.t.sol +2 -2
  31. package/test/unit/multi_chain_evolution.t.sol +2 -2
package/RISKS.md CHANGED
@@ -24,6 +24,7 @@ This file focuses on the bridge-like risks in the sucker system: merkle-root pro
24
24
  - **CCIP:** trusts `CCIP_ROUTER` identity plus `any2EvmMessage.sender` and `sourceChainSelector`. The router address is immutable at deploy time -- if Chainlink rotates routers, the sucker is bricked (no upgrade path).
25
25
  - **CREATE2 peer assumption.** `peer()` defaults to `address(this)`, assuming deterministic cross-chain deployment. Breaks if deployer address, init code, or factory nonce differs across chains. Incorrect peer = permanent fund loss (messages accepted from nobody, or routed to wrong address).
26
26
  - **Controller/terminal must exist on destination chain.** `_handleClaim` calls `controllerOf(projectId).mintTokensOf()`. If the project does not exist or has no controller on the remote chain, all claims permanently revert -- funds are stuck.
27
+ - **Registry allowlisting does not verify deployer singleton provenance.** `JBSuckerRegistry.deploySuckersFor()` only checks the deployer allowlist, not the singleton implementation behind the deployer. `configureSingleton()` can point an approved deployer at any JBSucker singleton. This is a privileged-only concern (both deployer configuration and registry allowlisting require governance). Defense: validate deployer configuration during registry allowlist reviews.
27
28
  - **No reentrancy guard.** The contract relies on state ordering (mark-executed-before-external-call) rather than explicit ReentrancyGuard. Correct today, but fragile to future refactors.
28
29
 
29
30
  ## 2. Merkle Tree Risks
@@ -41,6 +42,7 @@ This file focuses on the bridge-like risks in the sucker system: merkle-root pro
41
42
  - **CCIP: no guaranteed delivery order.** CCIP does not guarantee in-order delivery. The contract handles this by accepting any nonce > current, but concurrent `toRemote` calls for the same token could result in a later root arriving first, skipping an intermediate root.
42
43
  - **OP Stack: generally ordered** but the L1-to-L2 message must be relayed by an off-chain actor. A delayed or dropped relay permanently blocks claims until someone retries the relay.
43
44
  - **Message loss.** If an AMB silently fails (accepts the message on L1 but never delivers to L2), `numberOfClaimsSent` is incremented but the remote peer never receives the root. Those leaves are blocked from both remote claim and local emergency exit (conservative: locked, not double-spent). Recovery requires enabling the emergency hatch.
45
+ - **Aggregate balance accounting.** `amountToAddToBalanceOf(token)` computes `balanceOf(this) - outbox.balance`, making all contract-held tokens fungible claim backing. This is intentional: all funds serve the same project, so refunded ETH (from failed Arbitrum retryable tickets) and stale native deliveries correctly become project-claimable. The tradeoff is that later roots can consume liquidity from earlier failed batches, but the project is the ultimate beneficiary in all cases.
44
46
  - **`fromRemote` does not revert on stale nonce.** By design, stale/duplicate messages are silently ignored (emitting `StaleRootRejected`). This prevents fund loss on native token transfers where reverting would lose the ETH, but means monitoring must watch for this event to detect bridge issues.
45
47
 
46
48
  ## 4. Token Mapping Risks
@@ -62,7 +64,7 @@ This file focuses on the bridge-like risks in the sucker system: merkle-root pro
62
64
  - **Renounced registry ownership risk.** If the registry owner calls `renounceOwnership()`, `setToRemoteFee()` becomes permanently uncallable and the fee is frozen at its current value across all suckers. This is a deliberate trade-off: it allows the registry owner to credibly commit to a fee level, but eliminates the ability to respond to future ETH price changes. The fee is still capped at `MAX_TO_REMOTE_FEE`, so the maximum downside is bounded.
63
65
  - **Immutable fee project.** `FEE_PROJECT_ID` is set at construction and cannot be changed. If the fee project is abandoned or its terminal removed, there is no way to redirect fees without deploying new suckers.
64
66
  - **Cross-reference: sucker registration path.** Suckers are deployed via `JBSuckerRegistry.deploySuckersFor`, which requires `DEPLOY_SUCKERS` permission from the project owner. The registry's `deploy` function uses `CREATE2` with a deployer-specific salt. The sucker's `peer()` address is deterministic — a misconfigured peer means the sucker accepts messages from the wrong remote address. See [nana-omnichain-deployers-v6 RISKS.md](../nana-omnichain-deployers-v6/RISKS.md) for deployer-level risks.
65
- - **Registry aggregate views fail open.** `JBSuckerRegistry.remoteBalanceOf`, `remoteSurplusOf`, and `remoteTotalSupplyOf` sum values across active suckers but silently skip any sucker that reverts. This preserves liveness for dashboards and cross-chain estimates, but it means the returned aggregate can understate remote state whenever one peer is broken, censored, deprecated incorrectly, or simply expensive to query.
67
+ - **Registry aggregate views fail open.** `JBSuckerRegistry.remoteBalanceOf`, `remoteSurplusOf`, and `remoteTotalSupplyOf` include both active and deprecated suckers with per-chain deduplication (max per chain, not sum). When multiple suckers target the same peer chain (e.g., during migration), the highest reported value is used. The views silently skip any sucker that reverts. This preserves liveness for dashboards and cross-chain estimates, but it means the returned aggregate can understate remote state whenever one peer is broken, censored, or simply expensive to query.
66
68
 
67
69
  ## 6. Deprecation Lifecycle
68
70
 
@@ -83,6 +85,8 @@ This file focuses on the bridge-like risks in the sucker system: merkle-root pro
83
85
  - **Claim vs emergency exit use separate bitmap slots.** Emergency exit uses `_executedFor[keccak256(abi.encode(terminalToken))]` while regular claims use `_executedFor[terminalToken]`. This means a leaf that was emergency-exited locally could theoretically also be claimed remotely if the root was already sent -- double-spend is prevented only by the `numberOfClaimsSent` check.
84
86
  - **`numberOfClaimsSent` is the critical guard.** Emergency exit reverts if `outbox.numberOfClaimsSent != 0 && outbox.numberOfClaimsSent - 1 >= index`. The `numberOfClaimsSent != 0` precondition prevents underflow when no root has ever been sent — in that case, all leaves are available for emergency exit. This means leaves at indices below `numberOfClaimsSent` cannot be emergency-exited (they may have been sent to the remote peer). If `_sendRootOverAMB` silently fails, these leaves are permanently locked.
85
87
  - **Emergency exit decrements `outbox.balance`.** If emergency exits drain the outbox balance below the amount that was already sent to the bridge, the accounting becomes inconsistent. The contract guards against this by only allowing exit for unsent leaves.
88
+ - **Emergency exit recipient is the leaf beneficiary.** `exitThroughEmergencyHatch()` refunds to `claimData.leaf.beneficiary`, not the original `prepare()` caller. The depositor chose this beneficiary when preparing the bridge; the leaf structure does not store the depositor address. If Alice prepares for Bob and the bridge fails, Bob gets the emergency refund, not Alice. This is the intended behavior: the depositor delegated their claim to the beneficiary.
89
+ - **`numberOfClaimsSent` advancement timing.** `_sendRoot()` sets `numberOfClaimsSent` before `_sendRootOverAMB()` completes. If the L1 transaction succeeds but L2 delivery fails, those leaves are blocked from emergency exit. Mitigations: Arbitrum retryable tickets can be manually re-executed on L2; Optimism messages can be re-relayed by anyone. If delivery permanently fails, `enableEmergencyHatchFor()` combined with project owner intervention can recover. Adding a rollback mechanism would introduce double-spend risk (leaf claimable on both chains). Current design is conservative: locked funds are preferable to double-spent funds.
86
90
  - **Emergency hatch + minting.** Emergency exit calls `_handleClaim`, which mints project tokens via the controller. If the controller or token contract is broken/missing, emergency exits also revert -- there is no "raw withdrawal" of terminal tokens without minting.
87
91
 
88
92
  ## 8. DoS Vectors
@@ -130,7 +134,11 @@ The fee is paid to `FEE_PROJECT_ID` (the protocol project), not to the sucker's
130
134
 
131
135
  ### 10.5 Registry aggregate views prioritize liveness over completeness
132
136
 
133
- `JBSuckerRegistry.remoteBalanceOf`, `remoteSurplusOf`, and `remoteTotalSupplyOf` intentionally use `try/catch` around each sucker and silently ignore peers that revert. This is accepted because a single bad peer should not brick every cross-chain dashboard or estimator. The trade-off is that these read surfaces are best-effort only: consumers must treat them as lower bounds, not exact reconciled totals, unless they independently verify that every active sucker responded successfully.
137
+ `JBSuckerRegistry.remoteBalanceOf`, `remoteSurplusOf`, and `remoteTotalSupplyOf` intentionally use `try/catch` around each sucker and silently ignore peers that revert. Both active and deprecated suckers are included, with per-chain deduplication: when multiple suckers target the same peer chain (e.g., during a migration window), the highest reported value is used to avoid double-counting. This is accepted because a single bad peer should not brick every cross-chain dashboard or estimator. The trade-off is that these read surfaces are best-effort only: consumers must treat them as lower bounds, not exact reconciled totals, unless they independently verify that every active sucker responded successfully.
138
+
139
+ ### 10.8 Zero-output swap batches route to pendingSwapOf
140
+
141
+ When `JBSwapCCIPSucker.ccipReceive` receives bridge tokens and the swap succeeds but returns zero local tokens (e.g., due to extreme price impact or dust amounts), the batch is routed to `pendingSwapOf` for later retry via `retrySwap`. Without this, claims would proceed with zero terminal backing, minting unbacked project tokens. The trade-off is that these batches require a manual `retrySwap` call once pool conditions improve. Anyone can call `retrySwap` — it is permissionless.
134
142
 
135
143
  ### 10.6 Hookless V4 spot pricing is sandwich-vulnerable by design
136
144
 
@@ -139,3 +147,7 @@ When the only available Uniswap pool for a cross-denomination swap is a hookless
139
147
  ### 10.7 `mapTokens` refunds ETH on enable-only batches
140
148
 
141
149
  `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.
150
+
151
+ ### 10.9 Zero-value `prepare()` is allowed
152
+
153
+ `prepare()` does not reject `projectTokenCount == 0`. A zero-value check would be trivially bypassed by passing `1` instead, so it provides no real protection against remap-window consumption. The cost to create a leaf with `projectTokenCount = 1` is negligible (1 wei of project tokens). The one-time remap window is protected by the token mapping's `enabled` flag and the outbox tree count, not by minimum deposit requirements.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/suckers-v6",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@arbitrum/nitro-contracts": "^1.2.1",
22
- "@bananapus/core-v6": "^0.0.34",
23
- "@bananapus/permission-ids-v6": "^0.0.17",
22
+ "@bananapus/core-v6": "^0.0.36",
23
+ "@bananapus/permission-ids-v6": "^0.0.19",
24
24
  "@chainlink/contracts-ccip": "^1.6.0",
25
25
  "@chainlink/local": "github:smartcontractkit/chainlink-local#v0.2.7",
26
26
  "@openzeppelin/contracts": "^5.6.1",
@@ -20,6 +20,7 @@ import {JBLayer} from "./enums/JBLayer.sol";
20
20
  import {IArbGatewayRouter} from "./interfaces/IArbGatewayRouter.sol";
21
21
  import {IArbL1GatewayRouter} from "./interfaces/IArbL1GatewayRouter.sol";
22
22
  import {IArbL2GatewayRouter} from "./interfaces/IArbL2GatewayRouter.sol";
23
+ import {IL1ArbitrumGateway} from "./interfaces/IL1ArbitrumGateway.sol";
23
24
  import {IJBArbitrumSucker} from "./interfaces/IJBArbitrumSucker.sol";
24
25
  import {ARBChains} from "./libraries/ARBChains.sol";
25
26
  import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
@@ -256,12 +257,26 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
256
257
  // If the token is an ERC-20, bridge it to the peer.
257
258
  // If the amount is `0` then we do not need to bridge any ERC20.
258
259
  if (token != JBConstants.NATIVE_TOKEN && amount != 0) {
259
- // Calculate the cost of the ERC-20 transfer. (96 is the length of the abi encoded `data`)
260
- // slither-disable-next-line calls-loop
261
- uint256 maxSubmissionCostERC20 =
262
- ARBINBOX.calculateRetryableSubmissionFee({dataLength: 96, baseFee: maxFeePerGas});
263
-
264
- uint256 tokenTransportCost = maxSubmissionCostERC20 + (remoteToken.minGas * maxFeePerGas);
260
+ uint256 tokenTransportCost;
261
+ uint256 maxSubmissionCostERC20;
262
+ {
263
+ // Get the exact calldata length the gateway will create for the retryable ticket.
264
+ // The Arbitrum Inbox validates maxSubmissionCost against this actual payload, not the user data.
265
+ // slither-disable-next-line calls-loop
266
+ address gateway = GATEWAYROUTER.getGateway(token);
267
+ // slither-disable-next-line calls-loop
268
+ uint256 outboundCalldataLength =
269
+ IL1ArbitrumGateway(gateway)
270
+ .getOutboundCalldata({
271
+ _token: token, _from: address(this), _to: _peerAddress(), _amount: amount, _data: bytes("")
272
+ })
273
+ .length;
274
+ // slither-disable-next-line calls-loop
275
+ maxSubmissionCostERC20 = ARBINBOX.calculateRetryableSubmissionFee({
276
+ dataLength: outboundCalldataLength, baseFee: maxFeePerGas
277
+ });
278
+ tokenTransportCost = maxSubmissionCostERC20 + (remoteToken.minGas * maxFeePerGas);
279
+ }
265
280
 
266
281
  // Ensure we bridge enough for gas costs on L2 side
267
282
  if (transportPayment < callTransportCost + tokenTransportCost) {
@@ -228,8 +228,9 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
228
228
  }
229
229
 
230
230
  // Determine fee payment mode: native ETH or LINK token.
231
- // When transportPayment == 0, we pay in LINK from the sucker's pre-funded balance.
232
- // This enables chains with no meaningful native token (e.g. Tempo).
231
+ // When transportPayment == 0, we pay in LINK pulled from the caller via transferFrom.
232
+ // This enables chains with no meaningful native token (e.g. Tempo) while keeping
233
+ // toRemote permissionless — the caller provides LINK inline with their bridge intent.
233
234
  address feeToken = transportPayment == 0 ? CCIPHelper.linkOfChain(block.chainid) : address(0);
234
235
 
235
236
  // Build and send the CCIP message with the root payload.
@@ -240,6 +241,7 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
240
241
  peerAddress: _peerAddress(),
241
242
  transportPayment: transportPayment,
242
243
  feeToken: feeToken,
244
+ feeTokenPayer: feeToken != address(0) ? _msgSender() : address(0),
243
245
  gasLimit: gasLimit,
244
246
  encodedPayload: abi.encode(_CCIP_MSG_TYPE_ROOT, abi.encode(sucker_message)),
245
247
  tokenAmounts: tokenAmounts,
package/src/JBSucker.sol CHANGED
@@ -561,7 +561,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
561
561
  uint256 nextEarliestDeprecationTime = block.timestamp + _maxMessagingDelay();
562
562
 
563
563
  // The deprecation can be entirely disabled *or* it has to be later than the earliest possible time.
564
- if (timestamp != 0 && timestamp < nextEarliestDeprecationTime) {
564
+ if (timestamp != 0 && timestamp <= nextEarliestDeprecationTime) {
565
565
  revert JBSucker_DeprecationTimestampTooSoon({
566
566
  givenTime: timestamp, minimumTime: nextEarliestDeprecationTime
567
567
  });
@@ -982,6 +982,9 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
982
982
  // (e.g., the bridge is non-functional), the project owner can call `enableEmergencyHatchFor` to allow
983
983
  // local withdrawals via `exitThroughEmergencyHatch`.
984
984
  if (map.remoteToken == bytes32(0) && _outboxOf[token].numberOfClaimsSent != _outboxOf[token].tree.count) {
985
+ // Disable before external call to prevent reentrancy via prepare().
986
+ // _sendRoot uses the `currentMapping` parameter, not storage, so this is safe.
987
+ _remoteTokenFor[token].enabled = false;
985
988
  _sendRoot({transportPayment: transportPaymentValue, token: token, remoteToken: currentMapping});
986
989
  }
987
990
 
@@ -191,7 +191,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
191
191
  }
192
192
 
193
193
  /// @notice The cumulative balance across all remote peer chains for a project, denominated in a given currency.
194
- /// @dev Sums `peerChainBalanceOf` from each active sucker. Silently skips suckers that revert.
194
+ /// @dev Includes both active and deprecated suckers to prevent undercounting during migration windows.
195
+ /// Uses per-chain deduplication (max per chain, not sum) to prevent double-counting when both a deprecated and
196
+ /// active sucker exist for the same chain. Silently skips suckers that revert.
195
197
  /// @param projectId The ID of the project.
196
198
  /// @param decimals The decimal precision for the returned value.
197
199
  /// @param currency The currency to normalize to.
@@ -207,25 +209,65 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
207
209
  returns (uint256 balance)
208
210
  {
209
211
  address[] memory allSuckers = _suckersOf[projectId].keys();
210
- for (uint256 i; i < allSuckers.length;) {
212
+ uint256 len = allSuckers.length;
213
+
214
+ // Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
215
+ // so a linear scan is cheaper than a mapping.
216
+ uint256[] memory chainIds = new uint256[](len);
217
+ uint256[] memory maxBalances = new uint256[](len);
218
+ uint256 chainCount;
219
+
220
+ for (uint256 i; i < len;) {
211
221
  // slither-disable-next-line unused-return
212
222
  (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
213
- if (val == _SUCKER_EXISTS) {
223
+ // Include both active and deprecated suckers in aggregate economic views.
224
+ if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
214
225
  // slither-disable-next-line calls-loop
215
226
  try IJBSucker(allSuckers[i]).peerChainBalanceOf(decimals, currency) returns (
216
227
  JBDenominatedAmount memory amt
217
228
  ) {
218
- balance += amt.value;
229
+ // slither-disable-next-line calls-loop
230
+ uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
231
+ // Per-chain max: if multiple suckers target the same chain (deprecated + active
232
+ // during migration), take the highest value to avoid double-counting.
233
+ bool found;
234
+ for (uint256 j; j < chainCount;) {
235
+ if (chainIds[j] == chainId) {
236
+ if (amt.value > maxBalances[j]) maxBalances[j] = amt.value;
237
+ found = true;
238
+ break;
239
+ }
240
+ unchecked {
241
+ ++j;
242
+ }
243
+ }
244
+ if (!found) {
245
+ chainIds[chainCount] = chainId;
246
+ maxBalances[chainCount] = amt.value;
247
+ unchecked {
248
+ ++chainCount;
249
+ }
250
+ }
219
251
  } catch {}
220
252
  }
221
253
  unchecked {
222
254
  ++i;
223
255
  }
224
256
  }
257
+
258
+ // Sum the per-chain max values.
259
+ for (uint256 k; k < chainCount;) {
260
+ balance += maxBalances[k];
261
+ unchecked {
262
+ ++k;
263
+ }
264
+ }
225
265
  }
226
266
 
227
267
  /// @notice The cumulative surplus across all remote peer chains for a project, denominated in a given currency.
228
- /// @dev Sums `peerChainSurplusOf` from each active sucker. Silently skips suckers that revert.
268
+ /// @dev Includes both active and deprecated suckers to prevent undercounting during migration windows.
269
+ /// Uses per-chain deduplication (max per chain, not sum) to prevent double-counting when both a deprecated and
270
+ /// active sucker exist for the same chain. Silently skips suckers that revert.
229
271
  /// @param projectId The ID of the project.
230
272
  /// @param decimals The decimal precision for the returned value.
231
273
  /// @param currency The currency to normalize to.
@@ -241,42 +283,120 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
241
283
  returns (uint256 surplus)
242
284
  {
243
285
  address[] memory allSuckers = _suckersOf[projectId].keys();
244
- for (uint256 i; i < allSuckers.length;) {
286
+ uint256 len = allSuckers.length;
287
+
288
+ // Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
289
+ // so a linear scan is cheaper than a mapping.
290
+ uint256[] memory chainIds = new uint256[](len);
291
+ uint256[] memory maxSurplus = new uint256[](len);
292
+ uint256 chainCount;
293
+
294
+ for (uint256 i; i < len;) {
245
295
  // slither-disable-next-line unused-return
246
296
  (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
247
- if (val == _SUCKER_EXISTS) {
297
+ // Include both active and deprecated suckers in aggregate economic views.
298
+ if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
248
299
  // slither-disable-next-line calls-loop
249
300
  try IJBSucker(allSuckers[i]).peerChainSurplusOf(decimals, currency) returns (
250
301
  JBDenominatedAmount memory amt
251
302
  ) {
252
- surplus += amt.value;
303
+ // slither-disable-next-line calls-loop
304
+ uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
305
+ // Per-chain max: if multiple suckers target the same chain (deprecated + active
306
+ // during migration), take the highest value to avoid double-counting.
307
+ bool found;
308
+ for (uint256 j; j < chainCount;) {
309
+ if (chainIds[j] == chainId) {
310
+ if (amt.value > maxSurplus[j]) maxSurplus[j] = amt.value;
311
+ found = true;
312
+ break;
313
+ }
314
+ unchecked {
315
+ ++j;
316
+ }
317
+ }
318
+ if (!found) {
319
+ chainIds[chainCount] = chainId;
320
+ maxSurplus[chainCount] = amt.value;
321
+ unchecked {
322
+ ++chainCount;
323
+ }
324
+ }
253
325
  } catch {}
254
326
  }
255
327
  unchecked {
256
328
  ++i;
257
329
  }
258
330
  }
331
+
332
+ // Sum the per-chain max values.
333
+ for (uint256 k; k < chainCount;) {
334
+ surplus += maxSurplus[k];
335
+ unchecked {
336
+ ++k;
337
+ }
338
+ }
259
339
  }
260
340
 
261
341
  /// @notice The cumulative total supply across all remote peer chains for a project.
262
- /// @dev Sums `peerChainTotalSupply` from each active sucker. Silently skips suckers that revert.
342
+ /// @dev Includes both active and deprecated suckers to prevent undercounting during migration windows.
343
+ /// Uses per-chain deduplication (max per chain, not sum) to prevent double-counting when both a deprecated and
344
+ /// active sucker exist for the same chain. Silently skips suckers that revert.
263
345
  /// @param projectId The ID of the project.
264
346
  /// @return totalSupply The combined peer chain total supply.
265
347
  function remoteTotalSupplyOf(uint256 projectId) external view override returns (uint256 totalSupply) {
266
348
  address[] memory allSuckers = _suckersOf[projectId].keys();
267
- for (uint256 i; i < allSuckers.length;) {
349
+ uint256 len = allSuckers.length;
350
+
351
+ // Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
352
+ // so a linear scan is cheaper than a mapping.
353
+ uint256[] memory chainIds = new uint256[](len);
354
+ uint256[] memory maxSupply = new uint256[](len);
355
+ uint256 chainCount;
356
+
357
+ for (uint256 i; i < len;) {
268
358
  // slither-disable-next-line unused-return
269
359
  (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
270
- if (val == _SUCKER_EXISTS) {
360
+ // Include both active and deprecated suckers in aggregate economic views.
361
+ if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
271
362
  // slither-disable-next-line calls-loop
272
363
  try IJBSucker(allSuckers[i]).peerChainTotalSupply() returns (uint256 supply) {
273
- totalSupply += supply;
364
+ // slither-disable-next-line calls-loop
365
+ uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
366
+ // Per-chain max: if multiple suckers target the same chain (deprecated + active
367
+ // during migration), take the highest value to avoid double-counting.
368
+ bool found;
369
+ for (uint256 j; j < chainCount;) {
370
+ if (chainIds[j] == chainId) {
371
+ if (supply > maxSupply[j]) maxSupply[j] = supply;
372
+ found = true;
373
+ break;
374
+ }
375
+ unchecked {
376
+ ++j;
377
+ }
378
+ }
379
+ if (!found) {
380
+ chainIds[chainCount] = chainId;
381
+ maxSupply[chainCount] = supply;
382
+ unchecked {
383
+ ++chainCount;
384
+ }
385
+ }
274
386
  } catch {}
275
387
  }
276
388
  unchecked {
277
389
  ++i;
278
390
  }
279
391
  }
392
+
393
+ // Sum the per-chain max values.
394
+ for (uint256 k; k < chainCount;) {
395
+ totalSupply += maxSupply[k];
396
+ unchecked {
397
+ ++k;
398
+ }
399
+ }
280
400
  }
281
401
 
282
402
  //*********************************************************************//
@@ -313,12 +313,35 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
313
313
  }
314
314
  }
315
315
 
316
- // Store conversion rate whenever the batch has leaf-denomination tokens, even if
317
- // localAmount == 0 (swap rounded to zero). Without this, claims for this nonce would
318
- // skip scaling and use the raw leaf amount, draining backing from other batches.
316
+ // Zero-output swap guard: When a swap succeeds but returns zero local tokens, the
317
+ // batch must NOT be marked claimable. Without this guard, `_addToBalance` would see
318
+ // `pendingSwapOf.bridgeAmount == 0` (no pending swap stored) and allow claims to
319
+ // proceed — minting the full bridged project-token amount while adding zero terminal
320
+ // backing, breaking cross-chain solvency.
321
+ //
322
+ // Route zero-output swaps into `pendingSwapOf` so the swap can be retried via
323
+ // `retrySwap` once pool conditions improve. Only store the conversion rate when
324
+ // the swap produced a positive local amount.
319
325
  if (root.amount > 0) {
320
- _conversionRateOf[localToken][root.remoteRoot.nonce] =
321
- ConversionRate({leafTotal: root.amount, localTotal: localAmount});
326
+ if (localAmount == 0 && any2EvmMessage.destTokenAmounts.length == 1) {
327
+ Client.EVMTokenAmount memory zeroSwapTokenAmount = any2EvmMessage.destTokenAmounts[0];
328
+ // Only route to pending if there were actual bridge tokens delivered.
329
+ // If bridgeAmount is also 0 (zero-value batch), store the conversion rate
330
+ // normally — there is nothing to retry.
331
+ if (zeroSwapTokenAmount.amount > 0) {
332
+ pendingSwapOf[localToken][root.remoteRoot.nonce] = PendingSwap({
333
+ bridgeToken: zeroSwapTokenAmount.token,
334
+ bridgeAmount: zeroSwapTokenAmount.amount,
335
+ leafTotal: root.amount
336
+ });
337
+ } else {
338
+ _conversionRateOf[localToken][root.remoteRoot.nonce] =
339
+ ConversionRate({leafTotal: root.amount, localTotal: 0});
340
+ }
341
+ } else {
342
+ _conversionRateOf[localToken][root.remoteRoot.nonce] =
343
+ ConversionRate({leafTotal: root.amount, localTotal: localAmount});
344
+ }
322
345
  }
323
346
 
324
347
  // Store the inbox merkle root for later claims.
@@ -563,6 +586,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
563
586
  peerAddress: _toAddress(peer()),
564
587
  transportPayment: transportPayment,
565
588
  feeToken: feeToken,
589
+ feeTokenPayer: feeToken != address(0) ? _msgSender() : address(0),
566
590
  gasLimit: MESSENGER_BASE_GAS_LIMIT + remoteToken.minGas,
567
591
  encodedPayload: encodedPayload,
568
592
  tokenAmounts: tokenAmounts,
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ /// @notice Interface for Arbitrum L1 gateways to query the exact calldata they construct for retryable tickets.
5
+ interface IL1ArbitrumGateway {
6
+ /// @notice Returns the calldata that the gateway will submit as a retryable ticket to the Inbox.
7
+ /// @param _token The L1 token address.
8
+ /// @param _from The sender on L1.
9
+ /// @param _to The recipient on L2.
10
+ /// @param _amount The amount of tokens being bridged.
11
+ /// @param _data Additional data (forwarded to the L2 gateway).
12
+ /// @return The ABI-encoded calldata for `finalizeInboundTransfer` on the L2 gateway.
13
+ function getOutboundCalldata(
14
+ address _token,
15
+ address _from,
16
+ address _to,
17
+ uint256 _amount,
18
+ bytes calldata _data
19
+ )
20
+ external
21
+ view
22
+ returns (bytes memory);
23
+ }
@@ -68,13 +68,14 @@ library JBCCIPLib {
68
68
  /// @dev Runs via DELEGATECALL. Handles EVM2AnyMessage construction, getFee, ccipSend, and refund.
69
69
  /// @dev Supports two fee modes:
70
70
  /// - `transportPayment > 0`: pay CCIP fees in native ETH (existing behavior).
71
- /// - `transportPayment == 0`: pay CCIP fees in LINK from the sucker's pre-funded balance.
71
+ /// - `transportPayment == 0`: pay CCIP fees in LINK pulled from the caller via transferFrom.
72
72
  /// This enables chains with no meaningful native token (e.g. Tempo) to use CCIP.
73
73
  /// @param ccipRouter The CCIP router.
74
74
  /// @param remoteChainSelector The CCIP chain selector for the remote chain.
75
75
  /// @param peerAddress The peer sucker address on the remote chain.
76
76
  /// @param transportPayment The ETH transport payment available (0 for LINK fee mode).
77
77
  /// @param feeToken The fee token address: address(0) for native ETH, LINK address for LINK fee mode.
78
+ /// @param feeTokenPayer The address to pull LINK fees from via transferFrom (0 to use sucker's own balance).
78
79
  /// @param gasLimit The gas limit for the CCIP message.
79
80
  /// @param encodedPayload The ABI-encoded payload (e.g., abi.encode(type, data)).
80
81
  /// @param tokenAmounts The token amounts to bridge (from prepareTokenAmounts).
@@ -87,6 +88,7 @@ library JBCCIPLib {
87
88
  address peerAddress,
88
89
  uint256 transportPayment,
89
90
  address feeToken,
91
+ address feeTokenPayer,
90
92
  uint256 gasLimit,
91
93
  bytes memory encodedPayload,
92
94
  Client.EVMTokenAmount[] memory tokenAmounts,
@@ -95,6 +97,10 @@ library JBCCIPLib {
95
97
  external
96
98
  returns (bool refundFailed, uint256 refundAmount)
97
99
  {
100
+ // Cache to reduce stack pressure.
101
+ address router = address(ccipRouter);
102
+ uint64 chainSel = remoteChainSelector;
103
+
98
104
  // Build the CCIP message.
99
105
  Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
100
106
  receiver: abi.encode(peerAddress),
@@ -106,10 +112,17 @@ library JBCCIPLib {
106
112
 
107
113
  // Get the CCIP fee for sending the message.
108
114
  // slither-disable-next-line calls-loop
109
- uint256 fees = ccipRouter.getFee({destinationChainSelector: remoteChainSelector, message: message});
115
+ uint256 fees = ICCIPRouter(router).getFee({destinationChainSelector: chainSel, message: message});
110
116
 
111
117
  if (feeToken != address(0)) {
112
- // LINK fee path: approve the router to spend LINK from the sucker's balance.
118
+ // LINK fee path: pull the fee from the caller so toRemote stays permissionless.
119
+ // The caller must approve LINK to the sucker before calling toRemote.
120
+ if (feeTokenPayer != address(0)) {
121
+ // slither-disable-next-line arbitrary-send-erc20
122
+ IERC20(feeToken).safeTransferFrom({from: feeTokenPayer, to: address(this), value: fees});
123
+ }
124
+
125
+ // Approve the router to spend LINK (fee + any bridged LINK amount).
113
126
  // When the fee token is also a bridged token (e.g. LINK on Tempo), the approval
114
127
  // must cover both the bridged amount and the fee. prepareTokenAmounts() already
115
128
  // approved the bridged amount, but forceApprove replaces (not adds), so we must
@@ -121,14 +134,14 @@ library JBCCIPLib {
121
134
  break;
122
135
  }
123
136
  }
124
- SafeERC20.forceApprove({token: IERC20(feeToken), spender: address(ccipRouter), value: totalApproval});
137
+ SafeERC20.forceApprove({token: IERC20(feeToken), spender: router, value: totalApproval});
125
138
 
126
139
  // slither-disable-next-line calls-loop,unused-return
127
- ccipRouter.ccipSend({destinationChainSelector: remoteChainSelector, message: message});
140
+ ICCIPRouter(router).ccipSend({destinationChainSelector: chainSel, message: message});
128
141
  } else {
129
142
  // Native ETH fee path.
130
143
  // slither-disable-next-line calls-loop,unused-return,arbitrary-send-eth
131
- ccipRouter.ccipSend{value: fees}({destinationChainSelector: remoteChainSelector, message: message});
144
+ ICCIPRouter(router).ccipSend{value: fees}({destinationChainSelector: chainSel, message: message});
132
145
 
133
146
  // Calculate the excess transport payment to refund.
134
147
  refundAmount = transportPayment - fees;
@@ -417,9 +417,12 @@ abstract contract CCIPSuckerClaimForkTestBase is TestBaseWorkflow {
417
417
  vm.prank(rootSender);
418
418
  suckerL1.toRemote{value: ccipFeeAmount}(token);
419
419
  } else {
420
- // LINK fee path: pre-fund sucker with LINK, call toRemote with msg.value = 0.
420
+ // LINK fee path: caller provides LINK inline approve + transferFrom.
421
421
  address linkToken = _linkTokenOf(block.chainid);
422
- deal(linkToken, address(suckerL1), IERC20(linkToken).balanceOf(address(suckerL1)) + 100 ether);
422
+ uint256 linkForFees = 100 ether;
423
+ deal(linkToken, rootSender, linkForFees);
424
+ vm.prank(rootSender);
425
+ IERC20(linkToken).approve({spender: address(suckerL1), value: linkForFees});
423
426
  vm.prank(rootSender);
424
427
  suckerL1.toRemote(token);
425
428
  }
@@ -344,21 +344,22 @@ abstract contract CCIPSuckerMainnetForkTestBase is TestBaseWorkflow {
344
344
  assertLt(rootSender.balance, ccipFeeAmount, "CCIP fees should have been deducted");
345
345
  assertGt(rootSender.balance, 0, "Excess native should be returned");
346
346
  } else {
347
- // LINK fee path: pre-fund sucker with LINK, call toRemote with msg.value = 0.
347
+ // LINK fee path: caller provides LINK inline approve + transferFrom.
348
348
  // On Tempo, CALLVALUE always returns 0, so transportPayment = 0, triggering LINK fee mode.
349
349
  address linkToken = _linkTokenOf(block.chainid);
350
350
  uint256 linkForFees = 100 ether;
351
- deal(linkToken, address(suckerL1), IERC20(linkToken).balanceOf(address(suckerL1)) + linkForFees);
352
- uint256 suckerLinkBefore = IERC20(linkToken).balanceOf(address(suckerL1));
353
-
351
+ deal(linkToken, rootSender, linkForFees);
352
+ uint256 senderLinkBefore = IERC20(linkToken).balanceOf(rootSender);
353
+ vm.prank(rootSender);
354
+ IERC20(linkToken).approve({spender: address(suckerL1), value: linkForFees});
354
355
  vm.prank(rootSender);
355
356
  suckerL1.toRemote(token);
356
357
 
357
- // Verify LINK was consumed for CCIP fees.
358
+ // Verify LINK was consumed from the caller for CCIP fees.
358
359
  assertLt(
359
- IERC20(linkToken).balanceOf(address(suckerL1)),
360
- suckerLinkBefore,
361
- "LINK should have been consumed for CCIP fees"
360
+ IERC20(linkToken).balanceOf(rootSender),
361
+ senderLinkBefore,
362
+ "LINK should have been consumed from caller for CCIP fees"
362
363
  );
363
364
  }
364
365
 
@@ -310,19 +310,21 @@ abstract contract SwapCCIPSuckerForkTestBase is TestBaseWorkflow {
310
310
  assertLt(rootSender.balance, ccipFeeAmount, "CCIP fees should have been deducted");
311
311
  assertGt(rootSender.balance, 0, "Excess native should be returned");
312
312
  } else {
313
- // LINK fee path: pre-fund sucker with LINK, call toRemote with msg.value = 0.
313
+ // LINK fee path: caller provides LINK inline approve + transferFrom.
314
314
  address linkToken = CCIPHelper.linkOfChain(block.chainid);
315
- deal(linkToken, address(suckerL1), IERC20(linkToken).balanceOf(address(suckerL1)) + 100 ether);
316
- uint256 suckerLinkBefore = IERC20(linkToken).balanceOf(address(suckerL1));
317
-
315
+ uint256 linkForFees = 100 ether;
316
+ deal(linkToken, rootSender, linkForFees);
317
+ uint256 senderLinkBefore = IERC20(linkToken).balanceOf(rootSender);
318
+ vm.prank(rootSender);
319
+ IERC20(linkToken).approve({spender: address(suckerL1), value: linkForFees});
318
320
  vm.prank(rootSender);
319
321
  suckerL1.toRemote(token);
320
322
 
321
- // Verify LINK was consumed for CCIP fees.
323
+ // Verify LINK was consumed from the caller for CCIP fees.
322
324
  assertLt(
323
- IERC20(linkToken).balanceOf(address(suckerL1)),
324
- suckerLinkBefore,
325
- "LINK should have been consumed for CCIP fees"
325
+ IERC20(linkToken).balanceOf(rootSender),
326
+ senderLinkBefore,
327
+ "LINK should have been consumed from caller for CCIP fees"
326
328
  );
327
329
  }
328
330
 
@@ -473,7 +473,7 @@ contract SuckerAttacks is Test {
473
473
  // The sucker uses outbox.balance for validation, so large claims should fail
474
474
 
475
475
  // Set up deprecated state for emergency exit
476
- uint256 deprecationTimestamp = block.timestamp + 14 days;
476
+ uint256 deprecationTimestamp = block.timestamp + 14 days + 1;
477
477
  // forge-lint: disable-next-line(unsafe-typecast)
478
478
  sucker.setDeprecation(uint40(deprecationTimestamp));
479
479
  vm.warp(deprecationTimestamp);