@bananapus/suckers-v6 0.0.30 → 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 (39) 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 +6 -6
  5. package/src/JBSwapCCIPSucker.sol +63 -43
  6. package/src/libraries/JBSwapPoolLib.sol +48 -33
  7. package/test/ForkArbitrum.t.sol +12 -12
  8. package/test/ForkCelo.t.sol +12 -12
  9. package/test/ForkClaimMainnet.t.sol +7 -122
  10. package/test/ForkMainnet.t.sol +7 -128
  11. package/test/ForkOPStack.t.sol +3 -68
  12. package/test/ForkSwapMainnet.t.sol +3 -87
  13. package/test/audit/{codex-PeerDeterminism.t.sol → PeerDeterminism.t.sol} +6 -6
  14. package/test/audit/{2026-04-22-codex-nemesis-RegistryPeerAuthBreak.t.sol → RegistryPeerAuthBreak.t.sol} +17 -17
  15. package/test/audit/{2026-04-24-codex-nemesis-RegistryPeerMismatch.t.sol → RegistryPeerMismatch.t.sol} +11 -11
  16. package/test/audit/StaleNonceMetadataOverwrite.t.sol +318 -0
  17. package/test/fork/OptimismSuckerFork.t.sol +6 -6
  18. package/test/helpers/SuckerForkHelpers.sol +126 -0
  19. package/test/unit/deployer.t.sol +6 -6
  20. package/test/unit/merkle.t.sol +27 -27
  21. package/test/unit/multi_chain_evolution.t.sol +8 -10
  22. /package/test/audit/{codex-CCIPLegacyFormatCompatibility.t.sol → CCIPLegacyFormatCompatibility.t.sol} +0 -0
  23. /package/test/audit/{codex-CCIPWrappedNativeMisunwrap.t.sol → CCIPWrappedNativeMisunwrap.t.sol} +0 -0
  24. /package/test/audit/{codex-nemesis-DeprecatedRemovalUndercount.t.sol → DeprecatedRemovalUndercount.t.sol} +0 -0
  25. /package/test/audit/{CertikAIScan.t.sol → DeprecationBoundaryAndMapToken.t.sol} +0 -0
  26. /package/test/audit/{codex-FeeLocking.t.sol → FeeLocking.t.sol} +0 -0
  27. /package/test/audit/{2026-04-24-codex-nemesis-FreshRound.t.sol → FreshRound.t.sol} +0 -0
  28. /package/test/audit/{codex-MapTokensEnableOnlyValueStuck.t.sol → MapTokensEnableOnlyValueStuck.t.sol} +0 -0
  29. /package/test/audit/{codex-PeerSnapshotDesync.t.sol → PeerSnapshotDesync.t.sol} +0 -0
  30. /package/test/audit/{2026-04-22-codex-nemesis-PeerTopologyAuthBreak.t.sol → PeerTopologyAuthBreak.t.sol} +0 -0
  31. /package/test/audit/{2026-04-21-codex-nemesis-RegistryStaleDeprecatedMaxSurplus.t.sol → RegistryStaleDeprecatedMaxSurplus.t.sol} +0 -0
  32. /package/test/audit/{2026-04-24-codex-nemesis-RegistryStaleMaxAggregation.t.sol → RegistryStaleMaxAggregation.t.sol} +0 -0
  33. /package/test/audit/{codex-SwapBatchRateMixing.t.sol → SwapBatchRateMixing.t.sol} +0 -0
  34. /package/test/audit/{codex-NemesisSwapQueueOrder.t.sol → SwapQueueOrder.t.sol} +0 -0
  35. /package/test/audit/{codex-SwapZeroAmountBatchGap.t.sol → SwapZeroAmountBatchGap.t.sol} +0 -0
  36. /package/test/audit/{codex-nemesis-SwapZeroLocalTotalUnbackedClaim.t.sol → SwapZeroLocalTotalUnbackedClaim.t.sol} +0 -0
  37. /package/test/audit/{codex-ToRemoteFeeIrrecoverable.t.sol → ToRemoteFeeIrrecoverable.t.sol} +0 -0
  38. /package/test/audit/{2026-04-25-codex-nemesis-TransientClaimContext.t.sol → TransientClaimContext.t.sol} +0 -0
  39. /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.30",
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
@@ -874,12 +874,12 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
874
874
  // slither-disable-next-line calls-loop,unused-return
875
875
  IJBController(address(DIRECTORY.controllerOf(cachedProjectId)))
876
876
  .mintTokensOf({
877
- projectId: cachedProjectId,
878
- tokenCount: projectTokenAmount,
879
- beneficiary: _toAddress(beneficiary),
880
- memo: "",
881
- useReservedPercent: false
882
- });
877
+ projectId: cachedProjectId,
878
+ tokenCount: projectTokenAmount,
879
+ beneficiary: _toAddress(beneficiary),
880
+ memo: "",
881
+ useReservedPercent: false
882
+ });
883
883
  }
884
884
 
885
885
  /// @notice Inserts a new leaf into the outbox merkle tree for the specified `token`.
@@ -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
+ }
313
326
  }
314
- }
315
327
 
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
- });
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
+ });
336
+ }
337
+
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
  }
@@ -183,40 +183,50 @@ library JBSwapPoolLib {
183
183
  /// @return Encoded output amount.
184
184
  function executeV4UnlockCallback(IPoolManager poolManager, bytes calldata data) external returns (bytes memory) {
185
185
  // Decode the swap parameters packed during _executeV4Swap.
186
- (PoolKey memory key, bool zeroForOne, int256 amountSpecified, uint160 sqrtPriceLimitX96, uint256 minAmountOut) =
187
- abi.decode(data, (PoolKey, bool, int256, uint160, uint256));
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));
188
194
 
189
- // Execute the swap through the V4 PoolManager.
190
- BalanceDelta delta = poolManager.swap({
191
- key: key,
192
- params: SwapParams({
193
- zeroForOne: zeroForOne, amountSpecified: amountSpecified, sqrtPriceLimitX96: sqrtPriceLimitX96
194
- }),
195
- hookData: ""
196
- });
197
-
198
- // V4 sign convention: negative = we owe (input), positive = we're owed (output).
199
- int128 delta0 = delta.amount0();
200
- int128 delta1 = delta.amount1();
201
195
  uint256 amountIn;
202
196
  uint256 amountOut;
203
197
 
204
- // Extract input and output amounts based on swap direction.
205
- if (zeroForOne) {
206
- amountIn = uint256(uint128(-delta0));
207
- amountOut = uint256(uint128(delta1));
208
- } else {
209
- amountIn = uint256(uint128(-delta1));
210
- amountOut = uint256(uint128(delta0));
211
- }
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
+ });
212
207
 
213
- // Enforce the minimum output from the TWAP quote.
214
- 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
+ }
215
224
 
216
225
  // Settle input (pay what we owe to the PoolManager).
217
226
  Currency inputCurrency = zeroForOne ? key.currency0 : key.currency1;
218
227
  if (Currency.unwrap(inputCurrency) == address(0)) {
219
- // 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);
220
230
  // slither-disable-next-line unused-return,arbitrary-send-eth
221
231
  poolManager.settle{value: amountIn}();
222
232
  } else {
@@ -832,17 +842,22 @@ library JBSwapPoolLib {
832
842
  // Determine swap direction based on currency ordering in the pool key.
833
843
  bool zeroForOne = Currency.unwrap(key.currency0) == v4In;
834
844
 
835
- // Compute the sqrt price limit from the expected amounts.
836
- uint160 sqrtPriceLimitX96 = JBSwapLib.sqrtPriceLimitFromAmounts({
837
- amountIn: amount, minimumAmountOut: minAmountOut, zeroForOne: zeroForOne
838
- });
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
+ });
839
852
 
840
- // V4 uses negative amounts for exact-input swaps.
841
- int256 exactInputAmount = -int256(amount);
853
+ // V4 uses negative amounts for exact-input swaps.
854
+ int256 exactInputAmount = -int256(amount);
855
+
856
+ unlockData = abi.encode(key, zeroForOne, exactInputAmount, sqrtPriceLimitX96, minAmountOut, config.weth);
857
+ }
842
858
 
843
859
  // Unlock the PoolManager and encode the swap parameters for the callback.
844
- bytes memory result =
845
- config.poolManager.unlock(abi.encode(key, zeroForOne, exactInputAmount, sqrtPriceLimitX96, minAmountOut));
860
+ bytes memory result = config.poolManager.unlock(unlockData);
846
861
 
847
862
  // Decode the output amount returned by the unlock callback.
848
863
  amountOut = abi.decode(result, (uint256));
@@ -199,12 +199,12 @@ contract ForkArbitrumDeployerTest is TestBaseWorkflow, IERC721Receiver {
199
199
 
200
200
  jbController()
201
201
  .launchProjectFor({
202
- owner: address(this),
203
- projectUri: "arb-fork-test",
204
- rulesetConfigurations: _rulesetConfigurations,
205
- terminalConfigurations: _terminalConfigurations,
206
- memo: ""
207
- });
202
+ owner: address(this),
203
+ projectUri: "arb-fork-test",
204
+ rulesetConfigurations: _rulesetConfigurations,
205
+ terminalConfigurations: _terminalConfigurations,
206
+ memo: ""
207
+ });
208
208
  }
209
209
 
210
210
  function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
@@ -340,12 +340,12 @@ contract ForkArbitrumNativeTransferTest is TestBaseWorkflow {
340
340
 
341
341
  jbController()
342
342
  .launchProjectFor({
343
- owner: multisig(),
344
- projectUri: "arb-native-fork-test",
345
- rulesetConfigurations: _rulesetConfigurations,
346
- terminalConfigurations: _terminalConfigurations,
347
- memo: ""
348
- });
343
+ owner: multisig(),
344
+ projectUri: "arb-native-fork-test",
345
+ rulesetConfigurations: _rulesetConfigurations,
346
+ terminalConfigurations: _terminalConfigurations,
347
+ memo: ""
348
+ });
349
349
  }
350
350
 
351
351
  /// @notice Test native ETH transfer from L1 → Arbitrum L2 via the real Arbitrum inbox.
@@ -212,12 +212,12 @@ contract ForkCeloTest is TestBaseWorkflow {
212
212
 
213
213
  jbController()
214
214
  .launchProjectFor({
215
- owner: multisig(),
216
- projectUri: "celo-fork-test-native",
217
- rulesetConfigurations: _rulesetConfigurations,
218
- terminalConfigurations: _terminalConfigurations,
219
- memo: ""
220
- });
215
+ owner: multisig(),
216
+ projectUri: "celo-fork-test-native",
217
+ rulesetConfigurations: _rulesetConfigurations,
218
+ terminalConfigurations: _terminalConfigurations,
219
+ memo: ""
220
+ });
221
221
  }
222
222
 
223
223
  /// @notice Launch a project on Celo that accepts WETH (ETH as ERC-20).
@@ -259,12 +259,12 @@ contract ForkCeloTest is TestBaseWorkflow {
259
259
 
260
260
  jbController()
261
261
  .launchProjectFor({
262
- owner: multisig(),
263
- projectUri: "celo-fork-test-weth",
264
- rulesetConfigurations: _rulesetConfigurations,
265
- terminalConfigurations: _terminalConfigurations,
266
- memo: ""
267
- });
262
+ owner: multisig(),
263
+ projectUri: "celo-fork-test-weth",
264
+ rulesetConfigurations: _rulesetConfigurations,
265
+ terminalConfigurations: _terminalConfigurations,
266
+ memo: ""
267
+ });
268
268
  }
269
269
 
270
270
  // ── Tests
@@ -3,6 +3,7 @@ pragma solidity 0.8.28;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
6
+ import {SuckerForkHelpers} from "./helpers/SuckerForkHelpers.sol";
6
7
  import {IJBSucker} from "../src/interfaces/IJBSucker.sol";
7
8
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
8
9
  import {JBPermissionsData} from "@bananapus/core-v6/src/structs/JBPermissionsData.sol";
@@ -22,7 +23,7 @@ import {JBSucker} from "../src/JBSucker.sol";
22
23
  import "forge-std/Test.sol";
23
24
  import {JBCCIPSuckerDeployer} from "src/deployers/JBCCIPSuckerDeployer.sol";
24
25
  import {JBCCIPSucker} from "../src/JBCCIPSucker.sol";
25
- import {CCIPLocalSimulatorFork, Register} from "@chainlink/local/src/ccip/CCIPLocalSimulatorFork.sol";
26
+ import {CCIPLocalSimulatorFork} from "@chainlink/local/src/ccip/CCIPLocalSimulatorFork.sol";
26
27
  import {CCIPHelper} from "../src/libraries/CCIPHelper.sol";
27
28
 
28
29
  /// @dev Captured leaf data from InsertToOutboxTree event.
@@ -39,9 +40,8 @@ struct LeafData {
39
40
  /// @dev Tests the full round-trip: pay → prepare → toRemote (L1) → manual ccipReceive → claim (L2).
40
41
  /// Uses the dual-fork pattern from ForkMainnet.t.sol (real CCIP router on send side) combined with
41
42
  /// the manual ccipReceive pattern from ForkSwapMainnet.t.sol (prank as router on receive side).
42
- abstract contract CCIPSuckerClaimForkTestBase is TestBaseWorkflow {
43
+ abstract contract CCIPSuckerClaimForkTestBase is SuckerForkHelpers {
43
44
  CCIPLocalSimulatorFork ccipLocalSimulatorFork;
44
- JBRulesetMetadata _metadata;
45
45
 
46
46
  JBCCIPSuckerDeployer suckerDeployerL1;
47
47
  JBCCIPSuckerDeployer suckerDeployerL2;
@@ -61,13 +61,6 @@ abstract contract CCIPSuckerClaimForkTestBase is TestBaseWorkflow {
61
61
  function _l1ForkBlock() internal pure virtual returns (uint256);
62
62
  function _l2ForkBlock() internal pure virtual returns (uint256);
63
63
 
64
- // ── Token overrides (defaults: native token on both sides)
65
- // ────────────────────────────────
66
-
67
- function _terminalToken() internal view virtual returns (address) {
68
- return JBConstants.NATIVE_TOKEN;
69
- }
70
-
71
64
  function _remoteTerminalToken() internal view virtual returns (bytes32) {
72
65
  return bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN)));
73
66
  }
@@ -77,83 +70,22 @@ abstract contract CCIPSuckerClaimForkTestBase is TestBaseWorkflow {
77
70
  return true;
78
71
  }
79
72
 
80
- // ── LINK token addresses per mainnet chain
81
- // ────────────────────────────────
82
-
83
- function _linkTokenOf(uint256 chainId) internal pure returns (address) {
84
- if (chainId == 1) return 0x514910771AF9Ca656af840dff83E8264EcF986CA;
85
- if (chainId == 42_161) return 0xf97f4df75117a78c1A5a0DBb814Af92458539FB4;
86
- if (chainId == 10) return 0x350a791Bfc2C21F9Ed5d10980Dad2e2638ffa7f6;
87
- if (chainId == 8453) return 0x88Fb150BDc53A65fe94Dea0c9BA0a6dAf8C6e196;
88
- if (chainId == 4217) return 0x15C03488B29e27d62BAf10E30b0c474bf60E0264;
89
- revert("Unsupported chain for LINK token");
90
- }
91
-
92
- // ── Register mainnet network details
93
- // ──────────────────────────────────
94
-
95
- function _registerMainnetDetails(uint256 chainId) internal {
96
- Register.NetworkDetails memory details = Register.NetworkDetails({
97
- chainSelector: CCIPHelper.selectorOfChain(chainId),
98
- routerAddress: CCIPHelper.routerOfChain(chainId),
99
- linkAddress: _linkTokenOf(chainId),
100
- wrappedNativeAddress: CCIPHelper.wethOfChain(chainId),
101
- ccipBnMAddress: address(0),
102
- ccipLnMAddress: address(0),
103
- rmnProxyAddress: address(0),
104
- registryModuleOwnerCustomAddress: address(0),
105
- tokenAdminRegistryAddress: address(0)
106
- });
107
- ccipLocalSimulatorFork.setNetworkDetails(chainId, details);
108
- }
109
-
110
- /// @dev Deploy mock ERC20 at the terminal token address if it has no code on the current fork.
111
- function _ensureTerminalTokenExists() internal {
112
- address token = _terminalToken();
113
- if (token != JBConstants.NATIVE_TOKEN && token.code.length == 0) {
114
- vm.etch(token, CCIPHelper.wethOfChain(block.chainid).code);
115
- }
116
- }
117
-
118
73
  // ── Setup
119
74
  // ─────────────────────────────────────────────────────────────
120
75
 
121
76
  function setUp() public override {
122
77
  // ── L1
123
- // ────────────────────────────────────────────────────────────
124
78
  l1Fork = vm.createSelectFork(_l1RpcUrl(), _l1ForkBlock());
125
79
  _ensureTerminalTokenExists();
126
80
 
127
81
  ccipLocalSimulatorFork = new CCIPLocalSimulatorFork();
128
82
  vm.makePersistent(address(ccipLocalSimulatorFork));
129
83
 
130
- // Register mainnet network details (CCIPLocalSimulatorFork only ships with testnets).
131
- _registerMainnetDetails(_l1ChainId());
132
- _registerMainnetDetails(_l2ChainId());
133
-
134
- _metadata = JBRulesetMetadata({
135
- reservedPercent: JBConstants.MAX_RESERVED_PERCENT / 2,
136
- cashOutTaxRate: 0,
137
- baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
138
- pausePay: false,
139
- pauseCreditTransfers: false,
140
- allowOwnerMinting: true,
141
- allowSetCustomToken: false,
142
- allowTerminalMigration: false,
143
- allowSetTerminals: false,
144
- allowSetController: false,
145
- allowAddAccountingContext: true,
146
- allowAddPriceFeed: true,
147
- ownerMustSendPayouts: false,
148
- holdFees: false,
149
- useTotalSurplusForCashOuts: true,
150
- useDataHookForPay: false,
151
- useDataHookForCashOut: false,
152
- dataHook: address(0),
153
- metadata: 0
154
- });
84
+ _registerMainnetDetails(ccipLocalSimulatorFork, _l1ChainId());
85
+ _registerMainnetDetails(ccipLocalSimulatorFork, _l2ChainId());
86
+
87
+ _initMetadata();
155
88
 
156
- // Deploy full JB infrastructure on L1.
157
89
  super.setUp();
158
90
  vm.stopPrank();
159
91
 
@@ -196,11 +128,9 @@ abstract contract CCIPSuckerClaimForkTestBase is TestBaseWorkflow {
196
128
  vm.stopPrank();
197
129
 
198
130
  // ── L2
199
- // ────────────────────────────────────────────────────────────
200
131
  l2Fork = vm.createSelectFork(_l2RpcUrl(), _l2ForkBlock());
201
132
  _ensureTerminalTokenExists();
202
133
 
203
- // Deploy full JB infrastructure on L2.
204
134
  super.setUp();
205
135
  vm.stopPrank();
206
136
 
@@ -243,51 +173,6 @@ abstract contract CCIPSuckerClaimForkTestBase is TestBaseWorkflow {
243
173
  vm.mockCall(address(0), abi.encodeCall(IJBSuckerRegistry.toRemoteFee, ()), abi.encode(uint256(0)));
244
174
  }
245
175
 
246
- /// @notice Launch a project that accepts `_terminalToken()`.
247
- function _launchProject() internal {
248
- address token = _terminalToken();
249
-
250
- // Ensure baseCurrency matches the terminal token so no price feed is needed.
251
- _metadata.baseCurrency = uint32(uint160(token));
252
-
253
- JBCurrencyAmount[] memory _surplusAllowances = new JBCurrencyAmount[](1);
254
- _surplusAllowances[0] = JBCurrencyAmount({amount: 5 * 10 ** 18, currency: uint32(uint160(token))});
255
-
256
- JBFundAccessLimitGroup[] memory _fundAccessLimitGroup = new JBFundAccessLimitGroup[](1);
257
- _fundAccessLimitGroup[0] = JBFundAccessLimitGroup({
258
- terminal: address(jbMultiTerminal()),
259
- token: token,
260
- payoutLimits: new JBCurrencyAmount[](0),
261
- surplusAllowances: _surplusAllowances
262
- });
263
-
264
- JBRulesetConfig[] memory _rulesetConfigurations = new JBRulesetConfig[](1);
265
- _rulesetConfigurations[0].mustStartAtOrAfter = 0;
266
- _rulesetConfigurations[0].duration = 0;
267
- _rulesetConfigurations[0].weight = 1000 * 10 ** 18;
268
- _rulesetConfigurations[0].weightCutPercent = 0;
269
- _rulesetConfigurations[0].approvalHook = IJBRulesetApprovalHook(address(0));
270
- _rulesetConfigurations[0].metadata = _metadata;
271
- _rulesetConfigurations[0].splitGroups = new JBSplitGroup[](0);
272
- _rulesetConfigurations[0].fundAccessLimitGroups = _fundAccessLimitGroup;
273
-
274
- JBAccountingContext[] memory _tokensToAccept = new JBAccountingContext[](1);
275
- _tokensToAccept[0] = JBAccountingContext({token: token, decimals: 18, currency: uint32(uint160(token))});
276
-
277
- JBTerminalConfig[] memory _terminalConfigurations = new JBTerminalConfig[](1);
278
- _terminalConfigurations[0] =
279
- JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: _tokensToAccept});
280
-
281
- jbController()
282
- .launchProjectFor({
283
- owner: multisig(),
284
- projectUri: "claim-fork-test",
285
- rulesetConfigurations: _rulesetConfigurations,
286
- terminalConfigurations: _terminalConfigurations,
287
- memo: ""
288
- });
289
- }
290
-
291
176
  // ── Helpers
292
177
  // ─────────────────────────────────────────────────────────────
293
178