@bananapus/suckers-v6 0.0.48 → 0.0.49

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.48",
3
+ "version": "0.0.49",
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.54",
34
- "@bananapus/permission-ids-v6": "^0.0.25",
33
+ "@bananapus/core-v6": "^0.0.57",
34
+ "@bananapus/permission-ids-v6": "^0.0.26",
35
35
  "@chainlink/contracts-ccip": "1.6.4",
36
36
  "@chainlink/local": "0.2.7",
37
37
  "@openzeppelin/contracts": "5.6.1",
@@ -255,12 +255,13 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
255
255
  // If the token is an ERC-20, bridge it to the peer.
256
256
  // If the amount is `0` then we do not need to bridge any ERC20.
257
257
  if (token != JBConstants.NATIVE_TOKEN && amount != 0) {
258
+ address gateway;
258
259
  uint256 tokenTransportCost;
259
260
  uint256 maxSubmissionCostERC20;
260
261
  {
261
262
  // Get the exact calldata length the gateway will create for the retryable ticket.
262
263
  // The Arbitrum Inbox validates maxSubmissionCost against this actual payload, not the user data.
263
- address gateway = GATEWAYROUTER.getGateway(token);
264
+ gateway = GATEWAYROUTER.getGateway(token);
264
265
  uint256 outboundCalldataLength =
265
266
  IL1ArbitrumGateway(gateway)
266
267
  .getOutboundCalldata({
@@ -299,6 +300,8 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
299
300
  gasPriceBid: maxFeePerGas,
300
301
  data: bytes(abi.encode(maxSubmissionCostERC20, bytes("")))
301
302
  });
303
+
304
+ SafeERC20.forceApprove({token: IERC20(token), spender: gateway, value: 0});
302
305
  } else {
303
306
  // Ensure we bridge enough for gas costs on L2 side
304
307
  if (transportPayment < callTransportCost) {
@@ -164,6 +164,8 @@ contract JBCeloSucker is JBOptimismSucker {
164
164
  minGasLimit: remoteToken.minGas,
165
165
  extraData: bytes("")
166
166
  });
167
+
168
+ SafeERC20.forceApprove({token: IERC20(bridgeToken), spender: address(OPBRIDGE), value: 0});
167
169
  }
168
170
 
169
171
  // Send the messenger message with nativeValue = 0.
@@ -126,6 +126,8 @@ contract JBOptimismSucker is JBSucker, IJBOptimismSucker {
126
126
  minGasLimit: remoteToken.minGas,
127
127
  extraData: bytes("")
128
128
  });
129
+
130
+ SafeERC20.forceApprove({token: IERC20(token), spender: address(OPBRIDGE), value: 0});
129
131
  } else {
130
132
  // Otherwise, the token is the native token, and the amount will be sent as `msg.value`.
131
133
  nativeValue = amount;
package/src/JBSucker.sol CHANGED
@@ -83,6 +83,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
83
83
  error JBSucker_NoRetainedToRemoteFee(address account);
84
84
  error JBSucker_NoRetainedTransportPaymentRefund(address account);
85
85
  error JBSucker_RefundFailed(address beneficiary, uint256 amount);
86
+ error JBSucker_RemoteTokenAlreadyMapped(bytes32 remoteToken, address localToken);
86
87
  error JBSucker_TokenAlreadyMapped(address localToken, bytes32 mappedTo);
87
88
  error JBSucker_TokenHasInvalidEmergencyHatchState(address token);
88
89
  error JBSucker_TokenNotMapped(address token);
@@ -175,6 +176,14 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
175
176
  /// @custom:param token The local terminal token to get the inbox for.
176
177
  mapping(address token => JBInboxTreeRoot root) internal _inboxOf;
177
178
 
179
+ /// @notice The local token that has reserved each remote token address in this sucker.
180
+ /// @dev Inbound roots are keyed by `root.token` on the destination chain. Within a single sucker, allowing two
181
+ /// local tokens to send roots to the same remote token would give them independent source nonces but one shared
182
+ /// destination inbox, causing stale rejections or root overwrites. Each sucker keeps its own reservation map, so
183
+ /// separate bridge lanes for the same asset pair can coexist.
184
+ /// @custom:param remoteToken The remote terminal token address encoded as bytes32.
185
+ mapping(bytes32 remoteToken => address localToken) internal _localTokenForRemoteToken;
186
+
178
187
  /// @notice The outbox merkle tree for a given token.
179
188
  /// @custom:param token The local terminal token to get the outbox for.
180
189
  mapping(address token => JBOutboxTree) internal _outboxOf;
@@ -1031,6 +1040,10 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1031
1040
  /// after outbox activity, the same local funds could be claimed against two different remote tokens. A
1032
1041
  /// misconfigured mapping therefore requires deploying a new sucker. Re-enabling a previously disabled mapping
1033
1042
  /// (back to the same remote token) is supported.
1043
+ /// @dev Remote tokens are also unique per local token within this sucker. The source side keeps separate
1044
+ /// outboxes/nonces per local token, but the destination side stores roots under the remote token address. Sharing
1045
+ /// one remote token across multiple local tokens in the same sucker would merge those inboxes on the destination
1046
+ /// chain. Separate suckers can still map the same local/remote token pair, letting users choose a bridge lane.
1034
1047
  /// @param map The local and remote terminal token addresses to map, and minimum amount/gas limits for bridging
1035
1048
  /// them.
1036
1049
  /// @param transportPaymentValue The amount of `msg.value` to send for the token mapping.
@@ -1068,6 +1081,16 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1068
1081
  revert JBSucker_TokenAlreadyMapped({localToken: token, mappedTo: currentMapping.addr});
1069
1082
  }
1070
1083
 
1084
+ // A remote token can back only one local token's outbox in this sucker. Otherwise two independent source
1085
+ // nonces would race into the same destination inbox key (`root.token`), making one token's root stale or
1086
+ // overwriting the other. Other suckers have separate inbox/outbox storage and are unaffected.
1087
+ if (map.remoteToken != bytes32(0)) {
1088
+ address mappedLocalToken = _localTokenForRemoteToken[map.remoteToken];
1089
+ if (mappedLocalToken != address(0) && mappedLocalToken != token) {
1090
+ revert JBSucker_RemoteTokenAlreadyMapped({remoteToken: map.remoteToken, localToken: mappedLocalToken});
1091
+ }
1092
+ }
1093
+
1071
1094
  // No inbox guard needed here. Token remapping only affects the outbound (sending) path —
1072
1095
  // it changes where tokens get bridged TO. Existing inbox claims are resolved against the inbox merkle
1073
1096
  // tree keyed by the local token address. Changing the remote token doesn't invalidate those claims
@@ -1085,6 +1108,17 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1085
1108
  _sendRoot({transportPayment: transportPaymentValue, token: token, remoteToken: currentMapping});
1086
1109
  }
1087
1110
 
1111
+ // Update the reverse reservation if an unused local token is being remapped to a new remote token.
1112
+ if (
1113
+ map.remoteToken != bytes32(0) && currentMapping.addr != bytes32(0) && currentMapping.addr != map.remoteToken
1114
+ && _localTokenForRemoteToken[currentMapping.addr] == token
1115
+ ) {
1116
+ delete _localTokenForRemoteToken[currentMapping.addr];
1117
+ }
1118
+
1119
+ bytes32 remoteToken = map.remoteToken == bytes32(0) ? currentMapping.addr : map.remoteToken;
1120
+ if (remoteToken != bytes32(0)) _localTokenForRemoteToken[remoteToken] = token;
1121
+
1088
1122
  // Update the token mapping.
1089
1123
  _remoteTokenFor[token] = JBRemoteToken({
1090
1124
  enabled: map.remoteToken != bytes32(0),
@@ -1092,7 +1126,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1092
1126
  minGas: map.minGas,
1093
1127
  // This is done so that a token can be disabled and then enabled again
1094
1128
  // while ensuring the remoteToken never changes (unless it hasn't been used yet)
1095
- addr: map.remoteToken == bytes32(0) ? currentMapping.addr : map.remoteToken
1129
+ addr: remoteToken
1096
1130
  });
1097
1131
  }
1098
1132
 
@@ -18,6 +18,7 @@ import {IJBSuckerDeployer} from "./interfaces/IJBSuckerDeployer.sol";
18
18
  import {IJBSuckerRegistry} from "./interfaces/IJBSuckerRegistry.sol";
19
19
  import {JBSuckerDeployerConfig} from "./structs/JBSuckerDeployerConfig.sol";
20
20
  import {JBSuckersPair} from "./structs/JBSuckersPair.sol";
21
+ import {PeerValueScratch} from "./structs/PeerValueScratch.sol";
21
22
 
22
23
  /// @notice The canonical registry that deploys, tracks, and governs cross-chain suckers for Juicebox projects. It
23
24
  /// maintains an allowlist of approved deployer contracts, allows multiple active suckers per peer chain for bridge
@@ -210,10 +211,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
210
211
 
211
212
  // Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
212
213
  // so a linear scan is cheaper than a mapping.
213
- uint256[] memory chainIds = new uint256[](len);
214
- uint256[] memory maxBalances = new uint256[](len);
215
- bool[] memory hasActiveValue = new bool[](len);
216
- uint256 chainCount;
214
+ PeerValueScratch memory scratch = _peerValueScratch(len);
217
215
 
218
216
  for (uint256 i; i < len;) {
219
217
  (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
@@ -223,13 +221,11 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
223
221
  JBDenominatedAmount memory amt
224
222
  ) {
225
223
  uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
226
- chainCount = _recordPeerValue({
227
- chainIds: chainIds,
228
- values: maxBalances,
229
- hasActiveValue: hasActiveValue,
230
- chainCount: chainCount,
224
+ scratch.chainCount = _recordPeerValue({
225
+ scratch: scratch,
231
226
  chainId: chainId,
232
227
  value: amt.value,
228
+ snapshotTimestamp: _snapshotTimestampOf(allSuckers[i]),
233
229
  isActive: val == _SUCKER_EXISTS
234
230
  });
235
231
  } catch {}
@@ -239,9 +235,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
239
235
  }
240
236
  }
241
237
 
242
- // Sum the per-chain max values.
243
- for (uint256 k; k < chainCount;) {
244
- balance += maxBalances[k];
238
+ // Sum the per-chain selected values.
239
+ for (uint256 k; k < scratch.chainCount;) {
240
+ balance += scratch.values[k];
245
241
  unchecked {
246
242
  ++k;
247
243
  }
@@ -271,10 +267,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
271
267
 
272
268
  // Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
273
269
  // so a linear scan is cheaper than a mapping.
274
- uint256[] memory chainIds = new uint256[](len);
275
- uint256[] memory maxSurplus = new uint256[](len);
276
- bool[] memory hasActiveValue = new bool[](len);
277
- uint256 chainCount;
270
+ PeerValueScratch memory scratch = _peerValueScratch(len);
278
271
 
279
272
  for (uint256 i; i < len;) {
280
273
  (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
@@ -284,13 +277,11 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
284
277
  JBDenominatedAmount memory amt
285
278
  ) {
286
279
  uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
287
- chainCount = _recordPeerValue({
288
- chainIds: chainIds,
289
- values: maxSurplus,
290
- hasActiveValue: hasActiveValue,
291
- chainCount: chainCount,
280
+ scratch.chainCount = _recordPeerValue({
281
+ scratch: scratch,
292
282
  chainId: chainId,
293
283
  value: amt.value,
284
+ snapshotTimestamp: _snapshotTimestampOf(allSuckers[i]),
294
285
  isActive: val == _SUCKER_EXISTS
295
286
  });
296
287
  } catch {}
@@ -300,9 +291,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
300
291
  }
301
292
  }
302
293
 
303
- // Sum the per-chain max values.
304
- for (uint256 k; k < chainCount;) {
305
- surplus += maxSurplus[k];
294
+ // Sum the per-chain selected values.
295
+ for (uint256 k; k < scratch.chainCount;) {
296
+ surplus += scratch.values[k];
306
297
  unchecked {
307
298
  ++k;
308
299
  }
@@ -321,10 +312,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
321
312
 
322
313
  // Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
323
314
  // so a linear scan is cheaper than a mapping.
324
- uint256[] memory chainIds = new uint256[](len);
325
- uint256[] memory maxSupply = new uint256[](len);
326
- bool[] memory hasActiveValue = new bool[](len);
327
- uint256 chainCount;
315
+ PeerValueScratch memory scratch = _peerValueScratch(len);
328
316
 
329
317
  for (uint256 i; i < len;) {
330
318
  (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
@@ -332,13 +320,11 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
332
320
  if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
333
321
  try IJBSucker(allSuckers[i]).peerChainTotalSupply() returns (uint256 supply) {
334
322
  uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
335
- chainCount = _recordPeerValue({
336
- chainIds: chainIds,
337
- values: maxSupply,
338
- hasActiveValue: hasActiveValue,
339
- chainCount: chainCount,
323
+ scratch.chainCount = _recordPeerValue({
324
+ scratch: scratch,
340
325
  chainId: chainId,
341
326
  value: supply,
327
+ snapshotTimestamp: _snapshotTimestampOf(allSuckers[i]),
342
328
  isActive: val == _SUCKER_EXISTS
343
329
  });
344
330
  } catch {}
@@ -348,9 +334,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
348
334
  }
349
335
  }
350
336
 
351
- // Sum the per-chain max values.
352
- for (uint256 k; k < chainCount;) {
353
- totalSupply += maxSupply[k];
337
+ // Sum the per-chain selected values.
338
+ for (uint256 k; k < scratch.chainCount;) {
339
+ totalSupply += scratch.values[k];
354
340
  unchecked {
355
341
  ++k;
356
342
  }
@@ -378,58 +364,91 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
378
364
  return ERC2771Context._msgSender();
379
365
  }
380
366
 
367
+ /// @notice Allocates scratch arrays used to collapse many suckers into one aggregate value per peer chain.
368
+ /// @dev `len` is the number of suckers being scanned, which is the maximum possible number of distinct peer
369
+ /// chains. `chainCount` starts at zero and is incremented as new peer chains are discovered.
370
+ /// @param len The maximum number of peer-chain entries the aggregation can need.
371
+ /// @return scratch Empty scratch space sized for the current aggregation pass.
372
+ function _peerValueScratch(uint256 len) internal pure returns (PeerValueScratch memory scratch) {
373
+ // Allocate each parallel array up front so `_recordPeerValue` can update by index without resizing memory.
374
+ scratch.chainIds = new uint256[](len);
375
+ scratch.values = new uint256[](len);
376
+ scratch.snapshotTimestamps = new uint256[](len);
377
+ scratch.hasActiveValue = new bool[](len);
378
+ }
379
+
381
380
  /// @notice Records a project-scoped peer-chain aggregate value.
382
381
  /// @dev Callers pass scratch arrays sized from `_suckersOf[projectId].keys()`, so entries are already scoped to
383
382
  /// the project being aggregated. For each peer chain, active suckers replace deprecated suckers; deprecated
384
383
  /// values are only used as a migration fallback when no active sucker has reported for that chain.
385
- /// @param chainIds The peer-chain ids recorded so far.
386
- /// @param values The aggregate values recorded so far.
387
- /// @param hasActiveValue Whether the recorded value for each index came from an active sucker.
388
- /// @param chainCount The number of populated chain entries.
384
+ /// @param scratch The per-chain aggregate values and freshness keys recorded so far.
389
385
  /// @param chainId The peer-chain id to record.
390
386
  /// @param value The value to record.
387
+ /// @param snapshotTimestamp The snapshot freshness key to record.
391
388
  /// @param isActive Whether the value came from an active sucker.
392
389
  /// @return The updated number of populated chain entries.
393
390
  function _recordPeerValue(
394
- uint256[] memory chainIds,
395
- uint256[] memory values,
396
- bool[] memory hasActiveValue,
397
- uint256 chainCount,
391
+ PeerValueScratch memory scratch,
398
392
  uint256 chainId,
399
393
  uint256 value,
394
+ uint256 snapshotTimestamp,
400
395
  bool isActive
401
396
  )
402
397
  internal
403
398
  pure
404
399
  returns (uint256)
405
400
  {
406
- for (uint256 j; j < chainCount;) {
407
- if (chainIds[j] == chainId) {
401
+ for (uint256 j; j < scratch.chainCount;) {
402
+ if (scratch.chainIds[j] == chainId) {
408
403
  // Each sucker caches the entire remote chain's state (not a per-sucker share), so multiple
409
- // suckers targeting the same chain report redundant snapshots. MAX picks the freshest value
410
- // without double-counting SUM would inflate bonding curve denominators.
404
+ // suckers targeting the same chain report redundant snapshots. Prefer the freshest source-chain
405
+ // snapshot; use MAX only as a same-freshness tie-breaker or deprecated fallback.
411
406
  if (isActive) {
412
- if (!hasActiveValue[j] || value > values[j]) values[j] = value;
413
- hasActiveValue[j] = true;
414
- } else if (!hasActiveValue[j] && value > values[j]) {
407
+ if (
408
+ !scratch.hasActiveValue[j] || snapshotTimestamp > scratch.snapshotTimestamps[j]
409
+ || (snapshotTimestamp == scratch.snapshotTimestamps[j] && value > scratch.values[j])
410
+ ) {
411
+ scratch.values[j] = value;
412
+ scratch.snapshotTimestamps[j] = snapshotTimestamp;
413
+ }
414
+ scratch.hasActiveValue[j] = true;
415
+ } else if (
416
+ !scratch.hasActiveValue[j]
417
+ && (snapshotTimestamp > scratch.snapshotTimestamps[j]
418
+ || (snapshotTimestamp == scratch.snapshotTimestamps[j] && value > scratch.values[j]))
419
+ ) {
415
420
  // Deprecated suckers only fill the gap until an active value for this chain has been observed.
416
- values[j] = value;
421
+ scratch.values[j] = value;
422
+ scratch.snapshotTimestamps[j] = snapshotTimestamp;
417
423
  }
418
- return chainCount;
424
+ return scratch.chainCount;
419
425
  }
420
426
  unchecked {
421
427
  ++j;
422
428
  }
423
429
  }
424
430
 
425
- chainIds[chainCount] = chainId;
426
- values[chainCount] = value;
427
- hasActiveValue[chainCount] = isActive;
431
+ scratch.chainIds[scratch.chainCount] = chainId;
432
+ scratch.values[scratch.chainCount] = value;
433
+ scratch.snapshotTimestamps[scratch.chainCount] = snapshotTimestamp;
434
+ scratch.hasActiveValue[scratch.chainCount] = isActive;
428
435
  unchecked {
429
- return chainCount + 1;
436
+ return scratch.chainCount + 1;
430
437
  }
431
438
  }
432
439
 
440
+ /// @notice Reads a sucker's snapshot timestamp, returning zero if the sucker does not expose it.
441
+ /// @dev Older or malformed suckers should not brick aggregate registry views. A zero timestamp makes their value
442
+ /// lose to any successful fresh read for the same peer chain.
443
+ /// @param sucker The sucker to query.
444
+ /// @return timestamp The reported snapshot timestamp, or zero if the call fails.
445
+ function _snapshotTimestampOf(address sucker) internal view returns (uint256 timestamp) {
446
+ // Keep aggregate views available even if one registered sucker has a stale ABI or reverts unexpectedly.
447
+ try IJBSucker(sucker).snapshotTimestamp() returns (uint256 result) {
448
+ timestamp = result;
449
+ } catch {}
450
+ }
451
+
433
452
  //*********************************************************************//
434
453
  // ---------------------- public transactions ----------------------- //
435
454
  //*********************************************************************//
@@ -175,19 +175,15 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
175
175
  /// @custom:param nonce The CCIP nonce identifying the batch.
176
176
  mapping(address token => mapping(uint64 nonce => ConversionRate)) internal _conversionRateOf;
177
177
 
178
- /// @notice Highest nonce received so far per token. Used as upper bound for nonce iteration.
179
- /// @custom:param token The local token address.
180
- mapping(address token => uint64) internal _highestReceivedNonce;
181
-
182
178
  /// @notice Count of populated batch nonces per token. Appended exactly once per batch in
183
179
  /// `ccipReceive`, so it equals the number of received batches independent of CCIP ordering.
184
180
  /// @custom:param token The local token address.
185
181
  mapping(address token => uint64) internal _populatedNonceCount;
186
182
 
187
- /// @notice Populated batch nonces per token, indexed by insertion order. Enables the
188
- /// empty-midpoint fallback in `_findNonceForLeafIndex` to walk only the K populated nonces
189
- /// (O(K) worst case) instead of the full [lo, hi] nonce span (O(N) worst case under sparse
190
- /// adversarial patterns).
183
+ /// @notice Populated batch nonces per token, indexed by insertion order.
184
+ /// @dev `_findNonceForLeafIndex` walks this list directly. That bounds lookup by the number of
185
+ /// received batches, not by the highest nonce, so sparse or out-of-order CCIP delivery cannot
186
+ /// force the claim path to scan empty nonce slots.
191
187
  /// @custom:param token The local token address.
192
188
  /// @custom:param index The insertion index in [0, _populatedNonceCount[token]).
193
189
  mapping(address token => mapping(uint64 index => uint64 nonce)) internal _populatedNonceByIndex;
@@ -201,6 +197,10 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
201
197
  // ------------------- transient stored properties ------------------- //
202
198
  //*********************************************************************//
203
199
 
200
+ /// @dev Reentrancy guard for the initial `ccipReceive` swap. Prevents claims from consuming newly received
201
+ /// swap output before the batch's conversion rate has been recorded.
202
+ bool transient _ccipReceiveSwapLocked;
203
+
204
204
  /// @notice Leaf index + 1 of the claim currently in progress (set by the `claim` override).
205
205
  /// @dev Transient storage — auto-resets to 0 each transaction, saving ~9,800 gas per claim vs SSTORE.
206
206
  /// Value 0 means no active claim (bypass scaling); non-zero means leafIndex = value - 1.
@@ -344,13 +344,16 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
344
344
  // Wrapped in try-catch so a swap failure doesn't revert the entire CCIP message
345
345
  // (which would leave tokens stuck in the OffRamp). On failure, bridge tokens are
346
346
  // stored for later retry via `retrySwap` (written below, after nonce validation).
347
+ _ccipReceiveSwapLocked = true;
347
348
  try this.executeSwapExternal({
348
349
  tokenIn: deliveredToken, tokenOut: localToken, amount: deliveredAmount
349
350
  }) returns (
350
351
  uint256 swapped
351
352
  ) {
353
+ _ccipReceiveSwapLocked = false;
352
354
  localAmount = swapped;
353
355
  } catch {
356
+ _ccipReceiveSwapLocked = false;
354
357
  swapFailed = true;
355
358
  // localAmount stays 0 — pendingSwapOf and conversion rate are written
356
359
  // below, after fromRemote validates the nonce.
@@ -401,14 +404,6 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
401
404
  unchecked {
402
405
  _populatedNonceCount[localToken] = priorCount + 1;
403
406
  }
404
-
405
- // Track the highest nonce ever observed for this token. Read by
406
- // `_findNonceForLeafIndex` as the binary-search upper bound. Out-of-order
407
- // delivery keeps this monotonic — we only advance it when the new nonce is
408
- // strictly higher than the prior maximum.
409
- if (nonce > _highestReceivedNonce[localToken]) {
410
- _highestReceivedNonce[localToken] = nonce;
411
- }
412
407
  }
413
408
 
414
409
  // Store pendingSwapOf for failed swaps now that nonce is validated.
@@ -529,7 +524,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
529
524
  /// @param claimData The claim data containing the leaf and proof.
530
525
  function claim(JBClaim calldata claimData) public override {
531
526
  // Block claims during retrySwap to prevent zero-backed minting via reentrancy.
532
- if (_retrySwapLocked) revert JBSwapCCIPSucker_SwapPending(0);
527
+ if (_retrySwapLocked || _ccipReceiveSwapLocked) revert JBSwapCCIPSucker_SwapPending(0);
533
528
  _currentClaimLeafIndex = claimData.leaf.index + 1;
534
529
  super.claim(claimData);
535
530
  // Clear stale transient context to prevent leaking into same-tx emergency exits.
@@ -703,94 +698,36 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
703
698
  // ----------------------- internal views ---------------------------- //
704
699
  //*********************************************************************//
705
700
 
706
- /// @notice Find the nonce whose batch contains the given leaf index.
707
- /// @dev Binary search by nonce. Source-side `prepare()` appends batches in nonce order with each
708
- /// new batch's `batchStart` equal to the previous batch's `batchEnd`, so across populated
709
- /// destination slots `_batchStartOf` is strictly increasing in nonce the populated subset is
710
- /// monotonic even when CCIP delivery is out of order and leaves intermediate slots empty.
711
- /// Binary search exploits that monotonicity for O(log N) lookup.
712
- /// @dev Gap handling: when the midpoint slot is empty (CCIP out-of-order delivery or sparse
713
- /// attacker writes), the fallback walks `_populatedNonceByIndex` — the list of every nonce
714
- /// actually populated. That bounds the worst case at `O(K)` SLOADs, where `K` is the number
715
- /// of received batches, regardless of how sparse the populated set is inside `[1, maxNonce]`.
716
- /// The empty-slot scans that drove `O(N)` under the prior linear fallback are eliminated.
701
+ /// @notice Find the received nonce whose batch contains the given leaf index.
702
+ /// @dev Walks `_populatedNonceByIndex` instead of `[1, highestNonce]`. The populated list is the
703
+ /// only set that can contain a claimable batch, and it stays compact even when CCIP delivers
704
+ /// nonce 10 before nonce 2. This keeps lookup O(K) where K is received batches, avoids sparse
705
+ /// empty-slot scans, and keeps the deployable bytecode below the EIP-170 size limit.
717
706
  /// @param token The local token address.
718
707
  /// @param leafIndex The leaf index from the claim.
719
708
  /// @return The nonce of the batch containing this leaf, or 0 if no batches have been recorded.
720
709
  function _findNonceForLeafIndex(address token, uint256 leafIndex) internal view returns (uint64) {
721
- // `_highestReceivedNonce` upper-bounds any populated slot for this token; zero means
722
- // nothing has been received yet, so the leaf belongs to no batch.
723
- uint64 maxNonce = _highestReceivedNonce[token];
724
- if (maxNonce == 0) return 0;
725
-
726
- // Nonce 0 is reserved by inbox initialization and never holds a batch, so search [1, max].
727
- uint64 lo = 1;
728
- uint64 hi = maxNonce;
729
-
730
- // Wrap arithmetic in `unchecked` — `lo` and `hi` are always in `[1, maxNonce]` and the
731
- // edge-guards (`mid == lo` / `mid == hi` breaks) prevent the only ways `mid - 1` or
732
- // `mid + 1` could over/underflow. Skipping the compiler checks shaves enough bytecode to
733
- // stay under EIP-170 without changing semantics.
710
+ // No populated batches for this token means there is no conversion rate to apply. Preserve
711
+ // nonce 0 as the "unbatched" sentinel used by `_addToBalance`'s non-claim path.
712
+ uint64 count = _populatedNonceCount[token];
713
+ if (count == 0) return 0;
714
+
715
+ // Walk only nonces that actually received a batch. The array is insertion-ordered, not
716
+ // sorted, because CCIP can deliver batches out of nonce order; each entry still points to a
717
+ // self-contained `[batchStart, batchEnd)` range written before the append.
734
718
  unchecked {
735
- while (lo <= hi) {
736
- uint64 mid = lo + (hi - lo) / 2;
737
- // `batchEnd == 0` is the established sentinel for "no batch recorded" (see the
738
- // write guard in `ccipReceive`); a real batch always has `batchEnd > batchStart`.
739
- uint256 end = _batchEndOf[token][mid];
740
-
741
- // Empty midpoint from out-of-order CCIP delivery (the sender minted a higher nonce
742
- // than the inbox has yet received, leaving holes in `[1, maxNonce]`) or a sparse
743
- // pattern an attacker assembled by inflating `_highestReceivedNonce` without
744
- // populating intermediate slots. The earlier linear `[lo, hi]` scan walked every
745
- // empty slot, so worst-case cost was `O(maxNonce)` SLOADs — that's the residual
746
- // the audit flagged. Walk `_populatedNonceByIndex` instead: it's `K` entries (one
747
- // per received batch) and contains exactly the slots a real batch can cover.
748
- if (end == 0) {
749
- // Number of populated batch slots for this token — equal to the array length.
750
- // Maintained by `ccipReceive`'s append (one SSTORE per first-time receive).
751
- uint64 count = _populatedNonceCount[token];
752
-
753
- // Linear walk over the populated set. Bounded by `count`, not `maxNonce`, so
754
- // sparse adversarial patterns can't inflate this loop with empty slots.
755
- for (uint64 i; i < count; i++) {
756
- // Insertion-ordered nonce at index `i`. May be any value in `[1, maxNonce]`
757
- // because CCIP delivery is out-of-order; the array is not sorted by nonce.
758
- uint64 n = _populatedNonceByIndex[token][i];
759
-
760
- // Read end of this batch's leaf range. Always `> 0` here — we only push to
761
- // `_populatedNonceByIndex` after the corresponding `_batchEndOf` write.
762
- uint256 nEnd = _batchEndOf[token][n];
763
-
764
- // Coverage test: each batch's `[batchStart, batchEnd)` is non-overlapping
765
- // across populated nonces, so a hit is unique. The `[lo, hi]` window from
766
- // the binary search isn't applied — the binary-search invariant guarantees
767
- // the leaf can only live in a populated nonce, so an out-of-window match
768
- // would mean the binary search was already wrong; falling through to the
769
- // next iteration costs less than the extra two compares per iteration.
770
- if (leafIndex >= _batchStartOf[token][n] && leafIndex < nEnd) return n;
771
- }
772
-
773
- // No populated nonce contains `leafIndex` for this token. Break out of the
774
- // outer binary-search loop and fall through to the shared revert below.
775
- break;
776
- }
719
+ for (uint64 i; i < count; i++) {
720
+ uint64 nonce = _populatedNonceByIndex[token][i];
721
+ uint256 end = _batchEndOf[token][nonce];
777
722
 
778
- // Each batch covers [batchStart, batchEnd). Across populated nonces these ranges
779
- // are non-overlapping and strictly increasing, so the standard comparison applies.
780
- uint256 start = _batchStartOf[token][mid];
781
- if (leafIndex < start) {
782
- if (mid == lo) break; // Guard against `mid - 1` underflow at the lower edge.
783
- hi = mid - 1;
784
- } else if (leafIndex >= end) {
785
- if (mid == hi) break; // Mirror guard at the upper edge.
786
- lo = mid + 1;
787
- } else {
788
- return mid; // `start <= leafIndex < end`: leaf is inside this batch.
789
- }
723
+ // Ranges are non-overlapping across populated nonces. The first hit is therefore
724
+ // the unique conversion-rate batch for this claim leaf.
725
+ if (leafIndex >= _batchStartOf[token][nonce] && leafIndex < end) return nonce;
790
726
  }
791
727
  }
792
728
 
793
- // Window collapsed without a hit surface the same error the legacy linear scan used.
729
+ // Batches exist for the token, but none cover this leaf index; surface the same error used
730
+ // before the compact populated-nonce index was introduced.
794
731
  revert JBSwapCCIPSucker_BatchNotReceived({nonce: 0});
795
732
  }
796
733
  }
@@ -128,12 +128,6 @@ interface IJBSucker is IERC165 {
128
128
  /// @return chainId The remote chain ID.
129
129
  function peerChainId() external view returns (uint256 chainId);
130
130
 
131
- /// @notice The last known total token supply on the peer chain, updated each time a bridge message is received.
132
- /// @dev Used by data hooks to compute `effectiveTotalSupply = localSupply + sum(peerChainTotalSupply)` across all
133
- /// suckers, preventing cash out tax bypass on chains where a holder dominates the local supply.
134
- /// @return The peer chain's total supply.
135
- function peerChainTotalSupply() external view returns (uint256);
136
-
137
131
  /// @notice The aggregate peer chain balance, normalized to a desired currency and decimal precision using JBPrices.
138
132
  /// @dev The balance is stored as ETH-denominated (18 decimals) and converted to the requested currency/decimals
139
133
  /// using the local JBPrices oracle.
@@ -150,6 +144,12 @@ interface IJBSucker is IERC165 {
150
144
  /// @return A `JBDenominatedAmount` with the converted value.
151
145
  function peerChainSurplusOf(uint256 decimals, uint256 currency) external view returns (JBDenominatedAmount memory);
152
146
 
147
+ /// @notice The last known total token supply on the peer chain, updated each time a bridge message is received.
148
+ /// @dev Used by data hooks to compute `effectiveTotalSupply = localSupply + sum(peerChainTotalSupply)` across all
149
+ /// suckers, preventing cash out tax bypass on chains where a holder dominates the local supply.
150
+ /// @return The peer chain's total supply.
151
+ function peerChainTotalSupply() external view returns (uint256);
152
+
153
153
  /// @notice The ID of the project on the local chain that this sucker is associated with.
154
154
  /// @return The project ID.
155
155
  function projectId() external view returns (uint256);
@@ -159,6 +159,11 @@ interface IJBSucker is IERC165 {
159
159
  /// @return The remote token info.
160
160
  function remoteTokenFor(address token) external view returns (JBRemoteToken memory);
161
161
 
162
+ /// @notice The freshness key of the latest accepted peer-chain economic snapshot.
163
+ /// @dev Higher values are fresher. The key is source-chain monotonic, not a value magnitude.
164
+ /// @return The latest peer-chain snapshot freshness key.
165
+ function snapshotTimestamp() external view returns (uint256);
166
+
162
167
  /// @notice The current deprecation state of this sucker.
163
168
  /// @return The sucker state.
164
169
  function state() external view returns (JBSuckerState);