@bananapus/suckers-v6 0.0.29 → 0.0.31

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 (55) hide show
  1. package/RISKS.md +8 -0
  2. package/package.json +1 -1
  3. package/src/JBArbitrumSucker.sol +3 -4
  4. package/src/JBSucker.sol +15 -16
  5. package/src/JBSwapCCIPSucker.sol +76 -50
  6. package/src/libraries/JBSuckerLib.sol +3 -3
  7. package/src/libraries/JBSwapPoolLib.sol +54 -33
  8. package/src/structs/JBMessageRoot.sol +3 -4
  9. package/test/AdversarialSuckerFork.t.sol +449 -0
  10. package/test/ForkArbitrum.t.sol +15 -12
  11. package/test/ForkCelo.t.sol +14 -12
  12. package/test/ForkClaimMainnet.t.sol +9 -124
  13. package/test/ForkMainnet.t.sol +7 -128
  14. package/test/ForkOPStack.t.sol +7 -70
  15. package/test/ForkSwap.t.sol +2 -2
  16. package/test/ForkSwapMainnet.t.sol +4 -88
  17. package/test/InteropCompat.t.sol +3 -3
  18. package/test/MultiSuckerFork.t.sol +529 -0
  19. package/test/SuckerAttacks.t.sol +4 -4
  20. package/test/SuckerCrossChainAdversarial.t.sol +22 -22
  21. package/test/SuckerDeepAttacks.t.sol +10 -10
  22. package/test/TestAuditGaps.sol +11 -11
  23. package/test/audit/{codex-CCIPLegacyFormatCompatibility.t.sol → CCIPLegacyFormatCompatibility.t.sol} +1 -1
  24. package/test/audit/{codex-CCIPWrappedNativeMisunwrap.t.sol → CCIPWrappedNativeMisunwrap.t.sol} +1 -1
  25. package/test/audit/DeprecatedSuckerDestination.t.sol +1 -1
  26. package/test/audit/{2026-04-24-codex-nemesis-FreshRound.t.sol → FreshRound.t.sol} +5 -10
  27. package/test/audit/{codex-PeerDeterminism.t.sol → PeerDeterminism.t.sol} +6 -6
  28. package/test/audit/{codex-PeerSnapshotDesync.t.sol → PeerSnapshotDesync.t.sol} +2 -2
  29. package/test/audit/{2026-04-22-codex-nemesis-PeerTopologyAuthBreak.t.sol → PeerTopologyAuthBreak.t.sol} +1 -1
  30. package/test/audit/{2026-04-22-codex-nemesis-RegistryPeerAuthBreak.t.sol → RegistryPeerAuthBreak.t.sol} +17 -17
  31. package/test/audit/{2026-04-24-codex-nemesis-RegistryPeerMismatch.t.sol → RegistryPeerMismatch.t.sol} +11 -11
  32. package/test/audit/StaleNonceMetadataOverwrite.t.sol +318 -0
  33. package/test/audit/{codex-SwapZeroAmountBatchGap.t.sol → SwapZeroAmountBatchGap.t.sol} +2 -2
  34. package/test/audit/{2026-04-25-codex-nemesis-TransientClaimContext.t.sol → TransientClaimContext.t.sol} +2 -1
  35. package/test/audit/TrustedForwarderSpoof.t.sol +2 -2
  36. package/test/audit/TrustedForwarderSpoofCCIP.t.sol +1 -1
  37. package/test/audit/ZeroOutputSwapPending.t.sol +2 -2
  38. package/test/fork/OptimismSuckerFork.t.sol +6 -6
  39. package/test/helpers/SuckerForkHelpers.sol +126 -0
  40. package/test/unit/ccip_native_interop.t.sol +7 -7
  41. package/test/unit/deployer.t.sol +6 -6
  42. package/test/unit/merkle.t.sol +27 -27
  43. package/test/unit/multi_chain_evolution.t.sol +8 -10
  44. package/test/unit/peer_chain_state.t.sol +1 -1
  45. /package/test/audit/{codex-nemesis-DeprecatedRemovalUndercount.t.sol → DeprecatedRemovalUndercount.t.sol} +0 -0
  46. /package/test/audit/{CertikAIScan.t.sol → DeprecationBoundaryAndMapToken.t.sol} +0 -0
  47. /package/test/audit/{codex-FeeLocking.t.sol → FeeLocking.t.sol} +0 -0
  48. /package/test/audit/{codex-MapTokensEnableOnlyValueStuck.t.sol → MapTokensEnableOnlyValueStuck.t.sol} +0 -0
  49. /package/test/audit/{2026-04-21-codex-nemesis-RegistryStaleDeprecatedMaxSurplus.t.sol → RegistryStaleDeprecatedMaxSurplus.t.sol} +0 -0
  50. /package/test/audit/{2026-04-24-codex-nemesis-RegistryStaleMaxAggregation.t.sol → RegistryStaleMaxAggregation.t.sol} +0 -0
  51. /package/test/audit/{codex-SwapBatchRateMixing.t.sol → SwapBatchRateMixing.t.sol} +0 -0
  52. /package/test/audit/{codex-NemesisSwapQueueOrder.t.sol → SwapQueueOrder.t.sol} +0 -0
  53. /package/test/audit/{codex-nemesis-SwapZeroLocalTotalUnbackedClaim.t.sol → SwapZeroLocalTotalUnbackedClaim.t.sol} +0 -0
  54. /package/test/audit/{codex-ToRemoteFeeIrrecoverable.t.sol → ToRemoteFeeIrrecoverable.t.sol} +0 -0
  55. /package/test/audit/{2026-04-22-codex-nemesis-ZeroOutputRetryClaim.t.sol → ZeroOutputRetryClaim.t.sol} +0 -0
package/RISKS.md CHANGED
@@ -151,3 +151,11 @@ When the only available Uniswap pool for a cross-denomination swap is a hookless
151
151
  ### 10.9 Zero-value `prepare()` is allowed
152
152
 
153
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.
154
+
155
+ ### 10.10 Cross-chain currency uses standardized `JBCurrencyIds.ETH` (1), not local token addresses
156
+
157
+ Snapshot messages encode surplus and balance values using `JBCurrencyIds.ETH` (currency ID `1`) as the cross-chain currency identifier, not `uint32(uint160(JBConstants.NATIVE_TOKEN))` (currency ID `61166`). This is intentional: `NATIVE_TOKEN` (`0x000...EEEe`) is a local sentinel meaning "the native token on this chain," which may represent different assets on different networks (e.g., ETH on mainnet, MATIC on Polygon). A standardized semantic currency ID is required for cross-chain values to be comparable.
158
+
159
+ On the consuming side, contracts like `REVOwner` and `REVLoans` query sucker-reported values using `uint32(uint160(NATIVE_TOKEN))` — the local terminal convention. The `JBPrices` oracle in `JBSuckerLib.convertPeerValue` resolves the conversion between currency ID `1` (ETH) and currency ID `61166` (native token) when a price feed is registered for the pair. If no feed exists, `convertPeerValue` returns `0`, which is acceptable: it means the project has not configured cross-chain pricing for that token pair, and remote values are simply not factored into local calculations.
160
+
161
+ Projects that need accurate cross-chain surplus and supply accounting should register a price feed for the `1 ↔ 61166` pair via `JBPrices`. On chains where the native token is ETH, this is a 1:1 identity feed. On chains where the native token is not ETH, the feed should reflect the actual exchange rate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/suckers-v6",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -206,8 +206,8 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
206
206
  // slither-disable-next-line calls-loop,unused-return
207
207
  IArbL2GatewayRouter(address(GATEWAYROUTER))
208
208
  .outboundTransfer({
209
- l1Token: _toAddress(remoteToken.addr), to: peerAddress, amount: amount, data: bytes("")
210
- });
209
+ l1Token: _toAddress(remoteToken.addr), to: peerAddress, amount: amount, data: bytes("")
210
+ });
211
211
  } else {
212
212
  // Otherwise, the token is the native token, and the amount will be sent as `msg.value`.
213
213
  nativeValue = amount;
@@ -269,8 +269,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
269
269
  IL1ArbitrumGateway(gateway)
270
270
  .getOutboundCalldata({
271
271
  _token: token, _from: address(this), _to: _peerAddress(), _amount: amount, _data: bytes("")
272
- })
273
- .length;
272
+ }).length;
274
273
  // slither-disable-next-line calls-loop
275
274
  maxSubmissionCostERC20 = ARBINBOX.calculateRetryableSubmissionFee({
276
275
  dataLength: outboundCalldataLength, baseFee: maxFeePerGas
package/src/JBSucker.sol CHANGED
@@ -178,11 +178,10 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
178
178
  /// @dev The `currency` and `decimals` fields describe the denomination; `value` is the balance amount.
179
179
  JBDenominatedAmount private _peerChainBalance;
180
180
 
181
- /// @notice Outbound project-wide snapshot counter. Incremented each time `_sendRoot` is called.
182
- uint64 private _snapshotNonce;
183
-
184
- /// @notice The highest snapshot nonce received from the peer chain. Used to reject stale shared-state updates.
185
- uint64 private _peerSnapshotNonce;
181
+ /// @notice The `block.timestamp` from the source chain when the most recent accepted peer snapshot was taken.
182
+ /// @dev Only snapshots with a strictly newer source timestamp are accepted, preventing stale rollbacks.
183
+ /// Returns 0 if no snapshot has been received yet.
184
+ uint256 public snapshotTimestamp;
186
185
 
187
186
  //*********************************************************************//
188
187
  // ---------------------------- constructor -------------------------- //
@@ -400,12 +399,12 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
400
399
  emit StaleRootRejected({token: localToken, receivedNonce: root.remoteRoot.nonce, currentNonce: inbox.nonce});
401
400
  }
402
401
 
403
- // --- Project-wide shared state update (gated by snapshot nonce) ---
404
- // The snapshot nonce is a project-wide counter independent of per-token outbox nonces.
402
+ // --- Project-wide shared state update (gated by source timestamp) ---
403
+ // Only accept snapshots whose source timestamp is strictly newer than the last accepted one.
405
404
  // This prevents a staler per-token message from rolling back shared state (surplus, balance, supply)
406
405
  // that was already updated by a fresher message for a different token.
407
- if (root.snapshotNonce > _peerSnapshotNonce) {
408
- _peerSnapshotNonce = root.snapshotNonce;
406
+ if (root.sourceTimestamp > snapshotTimestamp) {
407
+ snapshotTimestamp = root.sourceTimestamp;
409
408
 
410
409
  // Update unconditionally — a legitimate zero supply must clear phantom cached supply.
411
410
  peerChainTotalSupply = root.sourceTotalSupply;
@@ -875,12 +874,12 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
875
874
  // slither-disable-next-line calls-loop,unused-return
876
875
  IJBController(address(DIRECTORY.controllerOf(cachedProjectId)))
877
876
  .mintTokensOf({
878
- projectId: cachedProjectId,
879
- tokenCount: projectTokenAmount,
880
- beneficiary: _toAddress(beneficiary),
881
- memo: "",
882
- useReservedPercent: false
883
- });
877
+ projectId: cachedProjectId,
878
+ tokenCount: projectTokenAmount,
879
+ beneficiary: _toAddress(beneficiary),
880
+ memo: "",
881
+ useReservedPercent: false
882
+ });
884
883
  }
885
884
 
886
885
  /// @notice Inserts a new leaf into the outbox merkle tree for the specified `token`.
@@ -1489,7 +1488,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1489
1488
  nonce: nonce,
1490
1489
  root: root,
1491
1490
  messageVersion: MESSAGE_VERSION,
1492
- snapshotNonce: ++_snapshotNonce
1491
+ sourceTimestamp: block.timestamp
1493
1492
  });
1494
1493
 
1495
1494
  // Send the root over the AMB (positional args — slither IR parser crashes on named args here).
@@ -273,6 +273,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
273
273
 
274
274
  address localToken = _toAddress(root.token);
275
275
  uint256 localAmount;
276
+ bool swapFailed;
276
277
 
277
278
  if (any2EvmMessage.destTokenAmounts.length == 1) {
278
279
  Client.EVMTokenAmount memory tokenAmount = any2EvmMessage.destTokenAmounts[0];
@@ -284,7 +285,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
284
285
  // Swap bridge token -> local token via best V3/V4 pool.
285
286
  // Wrapped in try-catch so a swap failure doesn't revert the entire CCIP message
286
287
  // (which would leave tokens stuck in the OffRamp). On failure, bridge tokens are
287
- // stored for later retry via `retrySwap`.
288
+ // stored for later retry via `retrySwap` (written below, after nonce validation).
288
289
  // slither-disable-next-line reentrancy-benign,reentrancy-events
289
290
  try this.executeSwapExternal({
290
291
  tokenIn: tokenAmount.token, tokenOut: localToken, amount: tokenAmount.amount
@@ -293,59 +294,78 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
293
294
  ) {
294
295
  localAmount = swapped;
295
296
  } catch {
296
- // Store for later retry. Merkle root and batch range still get stored below.
297
- pendingSwapOf[localToken][root.remoteRoot.nonce] = PendingSwap({
298
- bridgeToken: tokenAmount.token, bridgeAmount: tokenAmount.amount, leafTotal: root.amount
299
- });
300
- // localAmount stays 0 — the conversion rate code below will set
301
- // localTotal: 0, gating claims until retrySwap succeeds.
297
+ swapFailed = true;
298
+ // localAmount stays 0 — pendingSwapOf and conversion rate are written
299
+ // below, after fromRemote validates the nonce.
302
300
  }
303
301
  }
304
302
  }
305
303
 
306
- // Record the batch range so _findNonceForLeafIndex can resolve leaf ownership
307
- // independently of nonce ordering. Each nonce is self-describing: [start, end).
308
- if (batchEnd > 0) {
309
- _batchStartOf[localToken][root.remoteRoot.nonce] = batchStart;
310
- _batchEndOf[localToken][root.remoteRoot.nonce] = batchEnd;
311
- if (root.remoteRoot.nonce > _highestReceivedNonce[localToken]) {
312
- _highestReceivedNonce[localToken] = root.remoteRoot.nonce;
304
+ // Capture the inbox nonce before fromRemote to detect whether the root was accepted.
305
+ uint64 inboxNonceBefore = _inboxOf[localToken].nonce;
306
+
307
+ // Store the inbox merkle root for later claims.
308
+ // Must be called BEFORE writing batch metadata and conversion rates so that stale
309
+ // (duplicate/replayed) roots that fromRemote silently rejects do not overwrite
310
+ // metadata from the original accepted delivery.
311
+ this.fromRemote(root);
312
+
313
+ // Only write batch metadata and conversion rates if fromRemote accepted this root.
314
+ // fromRemote updates _inboxOf[localToken].nonce when the root's nonce is strictly
315
+ // greater than the current inbox nonce; stale roots are silently rejected.
316
+ // We detect acceptance by checking if the nonce actually changed.
317
+ if (_inboxOf[localToken].nonce > inboxNonceBefore) {
318
+ // Record the batch range so _findNonceForLeafIndex can resolve leaf ownership
319
+ // independently of nonce ordering. Each nonce is self-describing: [start, end).
320
+ if (batchEnd > 0) {
321
+ _batchStartOf[localToken][root.remoteRoot.nonce] = batchStart;
322
+ _batchEndOf[localToken][root.remoteRoot.nonce] = batchEnd;
323
+ if (root.remoteRoot.nonce > _highestReceivedNonce[localToken]) {
324
+ _highestReceivedNonce[localToken] = root.remoteRoot.nonce;
325
+ }
326
+ }
327
+
328
+ // Store pendingSwapOf for failed swaps now that nonce is validated.
329
+ if (swapFailed) {
330
+ Client.EVMTokenAmount memory failedTokenAmount = any2EvmMessage.destTokenAmounts[0];
331
+ pendingSwapOf[localToken][root.remoteRoot.nonce] = PendingSwap({
332
+ bridgeToken: failedTokenAmount.token,
333
+ bridgeAmount: failedTokenAmount.amount,
334
+ leafTotal: root.amount
335
+ });
313
336
  }
314
- }
315
337
 
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.
325
- if (root.amount > 0) {
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
- });
338
+ // Zero-output swap guard: When a swap succeeds but returns zero local tokens, the
339
+ // batch must NOT be marked claimable. Without this guard, `_addToBalance` would see
340
+ // `pendingSwapOf.bridgeAmount == 0` (no pending swap stored) and allow claims to
341
+ // proceed — minting the full bridged project-token amount while adding zero terminal
342
+ // backing, breaking cross-chain solvency.
343
+ //
344
+ // Route zero-output swaps into `pendingSwapOf` so the swap can be retried via
345
+ // `retrySwap` once pool conditions improve. Only store the conversion rate when
346
+ // the swap produced a positive local amount.
347
+ if (root.amount > 0 && !swapFailed) {
348
+ if (localAmount == 0 && any2EvmMessage.destTokenAmounts.length == 1) {
349
+ Client.EVMTokenAmount memory zeroSwapTokenAmount = any2EvmMessage.destTokenAmounts[0];
350
+ // Only route to pending if there were actual bridge tokens delivered.
351
+ // If bridgeAmount is also 0 (zero-value batch), store the conversion rate
352
+ // normally — there is nothing to retry.
353
+ if (zeroSwapTokenAmount.amount > 0) {
354
+ pendingSwapOf[localToken][root.remoteRoot.nonce] = PendingSwap({
355
+ bridgeToken: zeroSwapTokenAmount.token,
356
+ bridgeAmount: zeroSwapTokenAmount.amount,
357
+ leafTotal: root.amount
358
+ });
359
+ } else {
360
+ _conversionRateOf[localToken][root.remoteRoot.nonce] =
361
+ ConversionRate({leafTotal: root.amount, localTotal: 0});
362
+ }
337
363
  } else {
338
364
  _conversionRateOf[localToken][root.remoteRoot.nonce] =
339
- ConversionRate({leafTotal: root.amount, localTotal: 0});
365
+ ConversionRate({leafTotal: root.amount, localTotal: localAmount});
340
366
  }
341
- } else {
342
- _conversionRateOf[localToken][root.remoteRoot.nonce] =
343
- ConversionRate({leafTotal: root.amount, localTotal: localAmount});
344
367
  }
345
368
  }
346
-
347
- // Store the inbox merkle root for later claims.
348
- this.fromRemote(root);
349
369
  } else {
350
370
  revert JBCCIPSucker_UnknownMessageType(messageType);
351
371
  }
@@ -405,10 +425,13 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
405
425
  PendingSwap memory pending = pendingSwapOf[localToken][nonce];
406
426
  if (pending.bridgeAmount == 0) revert JBSwapCCIPSucker_NoPendingSwap();
407
427
 
408
- // slither-disable-next-line reentrancy-no-eth,reentrancy-benign,reentrancy-events
428
+ // slither-disable-next-line reentrancy-no-eth,reentrancy-benign,reentrancy-events,reentrancy-eth
409
429
  uint256 localAmount =
410
430
  _executeSwap({tokenIn: pending.bridgeToken, tokenOut: localToken, amount: pending.bridgeAmount});
411
431
 
432
+ // Revert on zero output — matches outbound guard at toRemote.
433
+ if (localAmount == 0) revert JBSwapCCIPSucker_SwapFailed();
434
+
412
435
  // Update the conversion rate so claims can proceed, then clear the pending swap.
413
436
  _conversionRateOf[localToken][nonce] = ConversionRate({leafTotal: pending.leafTotal, localTotal: localAmount});
414
437
  delete pendingSwapOf[localToken][nonce];
@@ -430,7 +453,10 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
430
453
  if (_retrySwapLocked) revert JBSwapCCIPSucker_SwapPending(0);
431
454
  // slither-disable-next-line events-maths
432
455
  _currentClaimLeafIndex = claimData.leaf.index + 1;
456
+ // slither-disable-next-line reentrancy-eth
433
457
  super.claim(claimData);
458
+ // Clear stale transient context to prevent leaking into same-tx emergency exits.
459
+ _currentClaimLeafIndex = 0;
434
460
  }
435
461
 
436
462
  //*********************************************************************//
@@ -453,14 +479,14 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
453
479
  if (_currentClaimLeafIndex != 0) {
454
480
  uint64 nonce = _findNonceForLeafIndex({token: token, leafIndex: _currentClaimLeafIndex - 1});
455
481
  if (nonce != 0) {
482
+ // Gate on pending swaps — if a swap failed and hasn't been retried yet,
483
+ // claims must wait. This check must come BEFORE the leafTotal gate so that
484
+ // failed swaps (where _conversionRateOf was never written) still block claims.
485
+ if (pendingSwapOf[token][nonce].bridgeAmount > 0) {
486
+ revert JBSwapCCIPSucker_SwapPending(nonce);
487
+ }
456
488
  ConversionRate storage rate = _conversionRateOf[token][nonce];
457
489
  if (rate.leafTotal > 0) {
458
- // Gate on pending swaps — if a swap failed and hasn't been retried yet,
459
- // claims must wait. Check pendingSwapOf rather than localTotal == 0 to
460
- // distinguish a pending swap from a legitimately zero-output swap.
461
- if (pendingSwapOf[token][nonce].bridgeAmount > 0) {
462
- revert JBSwapCCIPSucker_SwapPending(nonce);
463
- }
464
490
  amount = amount * rate.localTotal / rate.leafTotal;
465
491
  }
466
492
  }
@@ -219,7 +219,7 @@ library JBSuckerLib {
219
219
  /// @param nonce The outbox nonce for this send.
220
220
  /// @param root The merkle root of the outbox tree.
221
221
  /// @param messageVersion The message format version.
222
- /// @param snapshotNonce The snapshot nonce (caller should pre-increment).
222
+ /// @param sourceTimestamp The `block.timestamp` on the source chain when the snapshot is taken.
223
223
  /// @return message The constructed JBMessageRoot.
224
224
  function buildSnapshotMessage(
225
225
  IJBDirectory directory,
@@ -229,7 +229,7 @@ library JBSuckerLib {
229
229
  uint64 nonce,
230
230
  bytes32 root,
231
231
  uint8 messageVersion,
232
- uint64 snapshotNonce
232
+ uint256 sourceTimestamp
233
233
  )
234
234
  external
235
235
  view
@@ -268,7 +268,7 @@ library JBSuckerLib {
268
268
  sourceDecimals: _ETH_DECIMALS,
269
269
  sourceSurplus: ethSurplus,
270
270
  sourceBalance: ethBalance,
271
- snapshotNonce: snapshotNonce
271
+ sourceTimestamp: sourceTimestamp
272
272
  });
273
273
  }
274
274
 
@@ -167,6 +167,12 @@ library JBSwapPoolLib {
167
167
  IWrappedNativeToken(config.weth).withdraw(amountOut);
168
168
  }
169
169
  }
170
+
171
+ // V4 outputs native ETH for WETH-paired pools. If the caller requested WETH (not NATIVE_TOKEN),
172
+ // wrap the received ETH so the caller gets the token they expect.
173
+ if (isV4 && tokenOut != JBConstants.NATIVE_TOKEN && normalizedOut == config.weth) {
174
+ IWrappedNativeToken(config.weth).deposit{value: amountOut}();
175
+ }
170
176
  }
171
177
 
172
178
  /// @notice Execute the body of a V4 unlock callback. Called via DELEGATECALL from the sucker's
@@ -177,40 +183,50 @@ library JBSwapPoolLib {
177
183
  /// @return Encoded output amount.
178
184
  function executeV4UnlockCallback(IPoolManager poolManager, bytes calldata data) external returns (bytes memory) {
179
185
  // Decode the swap parameters packed during _executeV4Swap.
180
- (PoolKey memory key, bool zeroForOne, int256 amountSpecified, uint160 sqrtPriceLimitX96, uint256 minAmountOut) =
181
- abi.decode(data, (PoolKey, bool, int256, uint160, uint256));
182
-
183
- // Execute the swap through the V4 PoolManager.
184
- BalanceDelta delta = poolManager.swap({
185
- key: key,
186
- params: SwapParams({
187
- zeroForOne: zeroForOne, amountSpecified: amountSpecified, sqrtPriceLimitX96: sqrtPriceLimitX96
188
- }),
189
- hookData: ""
190
- });
186
+ (
187
+ PoolKey memory key,
188
+ bool zeroForOne,
189
+ int256 amountSpecified,
190
+ uint160 sqrtPriceLimitX96,
191
+ uint256 minAmountOut,
192
+ address weth
193
+ ) = abi.decode(data, (PoolKey, bool, int256, uint160, uint256, address));
191
194
 
192
- // V4 sign convention: negative = we owe (input), positive = we're owed (output).
193
- int128 delta0 = delta.amount0();
194
- int128 delta1 = delta.amount1();
195
195
  uint256 amountIn;
196
196
  uint256 amountOut;
197
197
 
198
- // Extract input and output amounts based on swap direction.
199
- if (zeroForOne) {
200
- amountIn = uint256(uint128(-delta0));
201
- amountOut = uint256(uint128(delta1));
202
- } else {
203
- amountIn = uint256(uint128(-delta1));
204
- amountOut = uint256(uint128(delta0));
205
- }
198
+ {
199
+ // Execute the swap through the V4 PoolManager.
200
+ BalanceDelta delta = poolManager.swap({
201
+ key: key,
202
+ params: SwapParams({
203
+ zeroForOne: zeroForOne, amountSpecified: amountSpecified, sqrtPriceLimitX96: sqrtPriceLimitX96
204
+ }),
205
+ hookData: ""
206
+ });
206
207
 
207
- // Enforce the minimum output from the TWAP quote.
208
- if (amountOut < minAmountOut) revert JBSwapPoolLib_SlippageExceeded(amountOut, minAmountOut);
208
+ // V4 sign convention: negative = we owe (input), positive = we're owed (output).
209
+ int128 delta0 = delta.amount0();
210
+ int128 delta1 = delta.amount1();
211
+
212
+ // Extract input and output amounts based on swap direction.
213
+ if (zeroForOne) {
214
+ amountIn = uint256(uint128(-delta0));
215
+ amountOut = uint256(uint128(delta1));
216
+ } else {
217
+ amountIn = uint256(uint128(-delta1));
218
+ amountOut = uint256(uint128(delta0));
219
+ }
220
+
221
+ // Enforce the minimum output from the TWAP quote.
222
+ if (amountOut < minAmountOut) revert JBSwapPoolLib_SlippageExceeded(amountOut, minAmountOut);
223
+ }
209
224
 
210
225
  // Settle input (pay what we owe to the PoolManager).
211
226
  Currency inputCurrency = zeroForOne ? key.currency0 : key.currency1;
212
227
  if (Currency.unwrap(inputCurrency) == address(0)) {
213
- // Native ETH: settle by sending ETH value directly.
228
+ // Native ETH: unwrap WETH if needed, then settle by sending ETH value directly.
229
+ if (weth != address(0)) IWrappedNativeToken(weth).withdraw(amountIn);
214
230
  // slither-disable-next-line unused-return,arbitrary-send-eth
215
231
  poolManager.settle{value: amountIn}();
216
232
  } else {
@@ -826,17 +842,22 @@ library JBSwapPoolLib {
826
842
  // Determine swap direction based on currency ordering in the pool key.
827
843
  bool zeroForOne = Currency.unwrap(key.currency0) == v4In;
828
844
 
829
- // Compute the sqrt price limit from the expected amounts.
830
- uint160 sqrtPriceLimitX96 = JBSwapLib.sqrtPriceLimitFromAmounts({
831
- amountIn: amount, minimumAmountOut: minAmountOut, zeroForOne: zeroForOne
832
- });
845
+ // Build the encoded unlock data in a scoped block to avoid stack-too-deep.
846
+ bytes memory unlockData;
847
+ {
848
+ // Compute the sqrt price limit from the expected amounts.
849
+ uint160 sqrtPriceLimitX96 = JBSwapLib.sqrtPriceLimitFromAmounts({
850
+ amountIn: amount, minimumAmountOut: minAmountOut, zeroForOne: zeroForOne
851
+ });
852
+
853
+ // V4 uses negative amounts for exact-input swaps.
854
+ int256 exactInputAmount = -int256(amount);
833
855
 
834
- // V4 uses negative amounts for exact-input swaps.
835
- int256 exactInputAmount = -int256(amount);
856
+ unlockData = abi.encode(key, zeroForOne, exactInputAmount, sqrtPriceLimitX96, minAmountOut, config.weth);
857
+ }
836
858
 
837
859
  // Unlock the PoolManager and encode the swap parameters for the callback.
838
- bytes memory result =
839
- config.poolManager.unlock(abi.encode(key, zeroForOne, exactInputAmount, sqrtPriceLimitX96, minAmountOut));
860
+ bytes memory result = config.poolManager.unlock(unlockData);
840
861
 
841
862
  // Decode the output amount returned by the unlock callback.
842
863
  amountOut = abi.decode(result, (uint256));
@@ -17,9 +17,8 @@ import {JBInboxTreeRoot} from "./JBInboxTreeRoot.sol";
17
17
  /// `sourceDecimals` precision.
18
18
  /// @custom:member sourceBalance The total recorded balance on the source chain, denominated in `sourceCurrency` at
19
19
  /// `sourceDecimals` precision.
20
- /// @custom:member snapshotNonce A project-wide counter that orders shared-state snapshots independently of per-token
21
- /// outbox nonces. Used by the receiving chain to reject stale surplus/balance/supply updates without blocking
22
- /// token-local inbox root updates.
20
+ /// @custom:member sourceTimestamp The `block.timestamp` on the source chain when the snapshot was taken. Used by the
21
+ /// receiving chain to reject stale surplus/balance/supply updates without blocking token-local inbox root updates.
23
22
  struct JBMessageRoot {
24
23
  uint8 version;
25
24
  bytes32 token;
@@ -30,5 +29,5 @@ struct JBMessageRoot {
30
29
  uint8 sourceDecimals;
31
30
  uint256 sourceSurplus;
32
31
  uint256 sourceBalance;
33
- uint64 snapshotNonce;
32
+ uint256 sourceTimestamp;
34
33
  }