@bananapus/suckers-v6 0.0.29 → 0.0.30

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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/JBSucker.sol +9 -10
  3. package/src/JBSwapCCIPSucker.sol +13 -7
  4. package/src/libraries/JBSuckerLib.sol +3 -3
  5. package/src/libraries/JBSwapPoolLib.sol +6 -0
  6. package/src/structs/JBMessageRoot.sol +3 -4
  7. package/test/AdversarialSuckerFork.t.sol +449 -0
  8. package/test/ForkArbitrum.t.sol +3 -0
  9. package/test/ForkCelo.t.sol +2 -0
  10. package/test/ForkClaimMainnet.t.sol +2 -2
  11. package/test/ForkOPStack.t.sol +4 -2
  12. package/test/ForkSwap.t.sol +2 -2
  13. package/test/ForkSwapMainnet.t.sol +1 -1
  14. package/test/InteropCompat.t.sol +3 -3
  15. package/test/MultiSuckerFork.t.sol +529 -0
  16. package/test/SuckerAttacks.t.sol +4 -4
  17. package/test/SuckerCrossChainAdversarial.t.sol +22 -22
  18. package/test/SuckerDeepAttacks.t.sol +10 -10
  19. package/test/TestAuditGaps.sol +11 -11
  20. package/test/audit/2026-04-22-codex-nemesis-PeerTopologyAuthBreak.t.sol +1 -1
  21. package/test/audit/2026-04-22-codex-nemesis-RegistryPeerAuthBreak.t.sol +1 -1
  22. package/test/audit/2026-04-24-codex-nemesis-FreshRound.t.sol +5 -10
  23. package/test/audit/2026-04-24-codex-nemesis-RegistryPeerMismatch.t.sol +1 -1
  24. package/test/audit/2026-04-25-codex-nemesis-TransientClaimContext.t.sol +2 -1
  25. package/test/audit/DeprecatedSuckerDestination.t.sol +1 -1
  26. package/test/audit/TrustedForwarderSpoof.t.sol +2 -2
  27. package/test/audit/TrustedForwarderSpoofCCIP.t.sol +1 -1
  28. package/test/audit/ZeroOutputSwapPending.t.sol +2 -2
  29. package/test/audit/codex-CCIPLegacyFormatCompatibility.t.sol +1 -1
  30. package/test/audit/codex-CCIPWrappedNativeMisunwrap.t.sol +1 -1
  31. package/test/audit/codex-PeerSnapshotDesync.t.sol +2 -2
  32. package/test/audit/codex-SwapZeroAmountBatchGap.t.sol +2 -2
  33. package/test/unit/ccip_native_interop.t.sol +7 -7
  34. package/test/unit/peer_chain_state.t.sol +1 -1
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.30",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
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;
@@ -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).
@@ -405,10 +405,13 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
405
405
  PendingSwap memory pending = pendingSwapOf[localToken][nonce];
406
406
  if (pending.bridgeAmount == 0) revert JBSwapCCIPSucker_NoPendingSwap();
407
407
 
408
- // slither-disable-next-line reentrancy-no-eth,reentrancy-benign,reentrancy-events
408
+ // slither-disable-next-line reentrancy-no-eth,reentrancy-benign,reentrancy-events,reentrancy-eth
409
409
  uint256 localAmount =
410
410
  _executeSwap({tokenIn: pending.bridgeToken, tokenOut: localToken, amount: pending.bridgeAmount});
411
411
 
412
+ // Revert on zero output — matches outbound guard at toRemote.
413
+ if (localAmount == 0) revert JBSwapCCIPSucker_SwapFailed();
414
+
412
415
  // Update the conversion rate so claims can proceed, then clear the pending swap.
413
416
  _conversionRateOf[localToken][nonce] = ConversionRate({leafTotal: pending.leafTotal, localTotal: localAmount});
414
417
  delete pendingSwapOf[localToken][nonce];
@@ -430,7 +433,10 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
430
433
  if (_retrySwapLocked) revert JBSwapCCIPSucker_SwapPending(0);
431
434
  // slither-disable-next-line events-maths
432
435
  _currentClaimLeafIndex = claimData.leaf.index + 1;
436
+ // slither-disable-next-line reentrancy-eth
433
437
  super.claim(claimData);
438
+ // Clear stale transient context to prevent leaking into same-tx emergency exits.
439
+ _currentClaimLeafIndex = 0;
434
440
  }
435
441
 
436
442
  //*********************************************************************//
@@ -453,14 +459,14 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
453
459
  if (_currentClaimLeafIndex != 0) {
454
460
  uint64 nonce = _findNonceForLeafIndex({token: token, leafIndex: _currentClaimLeafIndex - 1});
455
461
  if (nonce != 0) {
462
+ // Gate on pending swaps — if a swap failed and hasn't been retried yet,
463
+ // claims must wait. This check must come BEFORE the leafTotal gate so that
464
+ // failed swaps (where _conversionRateOf was never written) still block claims.
465
+ if (pendingSwapOf[token][nonce].bridgeAmount > 0) {
466
+ revert JBSwapCCIPSucker_SwapPending(nonce);
467
+ }
456
468
  ConversionRate storage rate = _conversionRateOf[token][nonce];
457
469
  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
470
  amount = amount * rate.localTotal / rate.leafTotal;
465
471
  }
466
472
  }
@@ -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
@@ -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
  }
@@ -0,0 +1,449 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {CCIPSuckerClaimForkTestBase, LeafData} from "./ForkClaimMainnet.t.sol";
5
+ import {JBSucker} from "../src/JBSucker.sol";
6
+ import {JBClaim} from "../src/structs/JBClaim.sol";
7
+ import {JBLeaf} from "../src/structs/JBLeaf.sol";
8
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
9
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
10
+
11
+ // forge-lint: disable-next-line(unaliased-plain-import)
12
+ import "forge-std/Test.sol";
13
+
14
+ /// @notice Adversarial fork tests for the CCIP sucker: multi-root, stale-proof, and double-send scenarios.
15
+ /// @dev Extends `CCIPSuckerClaimForkTestBase` (Ethereum -> Arbitrum) and exercises edge cases around
16
+ /// the append-only outbox merkle tree, sequential root delivery, and inbox nonce gating.
17
+ ///
18
+ /// The outbox merkle tree is **append-only**: `prepare()` inserts leaves and `toRemote()` reads the
19
+ /// current cumulative root without clearing the tree. This means nonce=2's root is the root of the
20
+ /// tree containing ALL leaves (including those from nonce=1). Proofs must always be computed against
21
+ /// the LATEST delivered root on the inbox side.
22
+ contract AdversarialSuckerForkTest is CCIPSuckerClaimForkTestBase {
23
+ // ── Chain-specific overrides (Ethereum -> Arbitrum, same as EthArbClaimForkTest)
24
+ // ──────────────────────────────────────────────────────────────────────────────
25
+
26
+ function _l1RpcUrl() internal pure override returns (string memory) {
27
+ return "ethereum";
28
+ }
29
+
30
+ function _l2RpcUrl() internal pure override returns (string memory) {
31
+ return "arbitrum";
32
+ }
33
+
34
+ function _l1ChainId() internal pure override returns (uint256) {
35
+ return 1;
36
+ }
37
+
38
+ function _l2ChainId() internal pure override returns (uint256) {
39
+ return 42_161;
40
+ }
41
+
42
+ function _l1ForkBlock() internal pure override returns (uint256) {
43
+ return 21_700_000;
44
+ }
45
+
46
+ function _l2ForkBlock() internal pure override returns (uint256) {
47
+ return 300_000_000;
48
+ }
49
+
50
+ // ════════════════════════════════════════════════════════════════════════
51
+ // Test 1: Claim with stale root after newer root delivered
52
+ // ════════════════════════════════════════════════════════════════════════
53
+ //
54
+ // Scenario:
55
+ // - User A prepares (leaf 0, tree count = 1)
56
+ // - toRemote sends root with nonce=1 (root of 1-leaf tree)
57
+ // - Deliver nonce=1 to L2 inbox
58
+ // - User B prepares (leaf 1, tree count = 2)
59
+ // - toRemote sends root with nonce=2 (root of 2-leaf tree)
60
+ // - Deliver nonce=2 to L2 inbox (overwrites inbox root)
61
+ // - Attempt to claim User A's leaf using the STALE proof from nonce=1's tree
62
+ //
63
+ // Expected: The stale proof FAILS because the inbox root is now nonce=2's root (2-leaf tree),
64
+ // and the zero proof (valid for a single-leaf tree) does not match. The outbox tree is
65
+ // append-only, so nonce=2's root is the cumulative root of both leaves -- a different value
66
+ // from nonce=1's root.
67
+ //
68
+ // After the stale proof fails, we verify User A CAN claim with a proof computed against
69
+ // the 2-leaf tree (using leafB.hashed as the sibling at proof[0]).
70
+ //
71
+ // ════════════════════════════════════════════════════════════════════════
72
+
73
+ function test_adversarial_claimAgainstStaleRootAfterNewerRoot() external {
74
+ address userA = makeAddr("userA");
75
+ address userB = makeAddr("userB");
76
+ uint256 amountToSend = 0.05 ether;
77
+
78
+ // ── L1: User A pays and prepares ──
79
+ vm.selectFork(l1Fork);
80
+ address token = _terminalToken();
81
+ _mapTerminalToken();
82
+
83
+ LeafData memory leafA = _mapPayAndPrepare(userA, amountToSend);
84
+ assertEq(leafA.index, 0, "User A should be at index 0");
85
+
86
+ // toRemote: sends nonce=1 root (1-leaf tree).
87
+ (bytes32 root1, uint64 nonce1) = _sendToRemote();
88
+ assertEq(nonce1, 1, "First toRemote should produce nonce 1");
89
+ assertEq(root1, leafA.root, "Root 1 should match leaf A's root from InsertToOutboxTree");
90
+
91
+ // ── Deliver nonce=1 to L2 ──
92
+ vm.selectFork(l2Fork);
93
+ token = _terminalToken();
94
+ _deliverToL2(root1, nonce1, leafA.terminalTokenAmount);
95
+ assertEq(suckerL1.inboxOf(token).root, root1, "Inbox should hold root from nonce=1");
96
+
97
+ // ── L1: User B pays and prepares (tree now has 2 leaves) ──
98
+ vm.selectFork(l1Fork);
99
+ LeafData memory leafB = _mapPayAndPrepare(userB, amountToSend);
100
+ assertEq(leafB.index, 1, "User B should be at index 1");
101
+
102
+ // toRemote: sends nonce=2 root (2-leaf tree, cumulative).
103
+ (bytes32 root2, uint64 nonce2) = _sendToRemote();
104
+ assertEq(nonce2, 2, "Second toRemote should produce nonce 2");
105
+ assertTrue(root2 != root1, "Root 2 (2-leaf) should differ from root 1 (1-leaf)");
106
+
107
+ // ── Deliver nonce=2 to L2 (overwrites inbox root) ──
108
+ // IMPORTANT: _deliverToL2 uses vm.deal which SETS the balance (not adds).
109
+ // Since we need to claim BOTH leaves after nonce=2 delivery, we must provide
110
+ // the cumulative amount so the sucker can fund both claims.
111
+ vm.selectFork(l2Fork);
112
+ token = _terminalToken();
113
+ _deliverToL2(root2, nonce2, leafA.terminalTokenAmount + leafB.terminalTokenAmount);
114
+ assertEq(suckerL1.inboxOf(token).root, root2, "Inbox should now hold root from nonce=2");
115
+
116
+ // ── Attempt to claim User A's leaf with STALE proof (from nonce=1's 1-leaf tree) ──
117
+ // The zero proof is correct for a 1-leaf tree but NOT for a 2-leaf tree.
118
+ vm.expectPartialRevert(JBSucker.JBSucker_InvalidProof.selector);
119
+ suckerL1.claim(
120
+ JBClaim({
121
+ token: token,
122
+ leaf: JBLeaf({
123
+ index: leafA.index,
124
+ beneficiary: leafA.beneficiary,
125
+ projectTokenCount: leafA.projectTokenCount,
126
+ terminalTokenAmount: leafA.terminalTokenAmount
127
+ }),
128
+ proof: _zeroProof() // Stale proof: valid for root1 but NOT root2.
129
+ })
130
+ );
131
+
132
+ // ── User A CAN claim with the correct proof against the 2-leaf tree ──
133
+ // In a 2-leaf tree, the sibling of leaf A (index 0) is leaf B's hash at proof[0].
134
+ bytes32[32] memory correctProofA = _zeroProof();
135
+ correctProofA[0] = leafB.hashed;
136
+
137
+ suckerL1.claim(
138
+ JBClaim({
139
+ token: token,
140
+ leaf: JBLeaf({
141
+ index: leafA.index,
142
+ beneficiary: leafA.beneficiary,
143
+ projectTokenCount: leafA.projectTokenCount,
144
+ terminalTokenAmount: leafA.terminalTokenAmount
145
+ }),
146
+ proof: correctProofA
147
+ })
148
+ );
149
+ assertEq(
150
+ jbTokens().totalBalanceOf(userA, 1),
151
+ leafA.projectTokenCount,
152
+ "User A should receive tokens after claiming with correct 2-leaf proof"
153
+ );
154
+
155
+ // ── User B claims normally against the 2-leaf tree ──
156
+ bytes32[32] memory correctProofB = _zeroProof();
157
+ correctProofB[0] = leafA.hashed;
158
+
159
+ suckerL1.claim(
160
+ JBClaim({
161
+ token: token,
162
+ leaf: JBLeaf({
163
+ index: leafB.index,
164
+ beneficiary: leafB.beneficiary,
165
+ projectTokenCount: leafB.projectTokenCount,
166
+ terminalTokenAmount: leafB.terminalTokenAmount
167
+ }),
168
+ proof: correctProofB
169
+ })
170
+ );
171
+ assertEq(
172
+ jbTokens().totalBalanceOf(userB, 1),
173
+ leafB.projectTokenCount,
174
+ "User B should receive tokens after claiming with correct 2-leaf proof"
175
+ );
176
+ }
177
+
178
+ // ════════════════════════════════════════════════════════════════════════
179
+ // Test 2: Multiple sequential roots before any claims
180
+ // ════════════════════════════════════════════════════════════════════════
181
+ //
182
+ // Scenario:
183
+ // - User A prepares (leaf 0)
184
+ // - toRemote (nonce=1, root of 1-leaf tree)
185
+ // - User B prepares (leaf 1)
186
+ // - toRemote (nonce=2, root of 2-leaf tree)
187
+ // - Deliver nonce=1 to L2 (inbox root = root1)
188
+ // - Deliver nonce=2 to L2 (inbox root = root2, overwrites root1)
189
+ // - Attempt to claim User A with nonce=1 proof (zero proof) -> should FAIL
190
+ // - Claim User A with nonce=2 proof (leafB sibling) -> should SUCCEED
191
+ // - Claim User B with nonce=2 proof (leafA sibling) -> should SUCCEED
192
+ //
193
+ // Key insight: Even though nonce=1 was delivered first, nonce=2 overwrites the inbox root.
194
+ // All claims must use proofs against nonce=2's root. The append-only tree ensures leaf A
195
+ // is still in nonce=2's tree, just with a different proof path.
196
+ //
197
+ // ════════════════════════════════════════════════════════════════════════
198
+
199
+ function test_adversarial_multipleRootsBeforeClaims() external {
200
+ address userA = makeAddr("userA");
201
+ address userB = makeAddr("userB");
202
+ uint256 amountToSend = 0.05 ether;
203
+
204
+ // ── L1: User A prepares ──
205
+ vm.selectFork(l1Fork);
206
+ address token = _terminalToken();
207
+ _mapTerminalToken();
208
+
209
+ LeafData memory leafA = _mapPayAndPrepare(userA, amountToSend);
210
+
211
+ // toRemote nonce=1 (1-leaf tree).
212
+ (bytes32 root1, uint64 nonce1) = _sendToRemote();
213
+
214
+ // ── L1: User B prepares ──
215
+ LeafData memory leafB = _mapPayAndPrepare(userB, amountToSend);
216
+
217
+ // toRemote nonce=2 (2-leaf tree).
218
+ (bytes32 root2, uint64 nonce2) = _sendToRemote();
219
+
220
+ // ── Deliver both roots to L2 in order ──
221
+ vm.selectFork(l2Fork);
222
+ token = _terminalToken();
223
+
224
+ // Deliver nonce=1 first.
225
+ _deliverToL2(root1, nonce1, leafA.terminalTokenAmount);
226
+ assertEq(suckerL1.inboxOf(token).root, root1, "Inbox should initially hold root1");
227
+
228
+ // Deliver nonce=2 (overwrites). Use cumulative amount since both claims happen after this.
229
+ _deliverToL2(root2, nonce2, leafA.terminalTokenAmount + leafB.terminalTokenAmount);
230
+ assertEq(suckerL1.inboxOf(token).root, root2, "Inbox should now hold root2 after nonce=2 delivery");
231
+
232
+ // ── Attempt to claim User A with nonce=1's proof (zero proof) ──
233
+ // This MUST fail because inbox root is now root2 (2-leaf tree).
234
+ vm.expectPartialRevert(JBSucker.JBSucker_InvalidProof.selector);
235
+ suckerL1.claim(
236
+ JBClaim({
237
+ token: token,
238
+ leaf: JBLeaf({
239
+ index: leafA.index,
240
+ beneficiary: leafA.beneficiary,
241
+ projectTokenCount: leafA.projectTokenCount,
242
+ terminalTokenAmount: leafA.terminalTokenAmount
243
+ }),
244
+ proof: _zeroProof()
245
+ })
246
+ );
247
+
248
+ // ── Claim User A with correct proof against the 2-leaf tree ──
249
+ bytes32[32] memory proofA = _zeroProof();
250
+ proofA[0] = leafB.hashed;
251
+
252
+ suckerL1.claim(
253
+ JBClaim({
254
+ token: token,
255
+ leaf: JBLeaf({
256
+ index: leafA.index,
257
+ beneficiary: leafA.beneficiary,
258
+ projectTokenCount: leafA.projectTokenCount,
259
+ terminalTokenAmount: leafA.terminalTokenAmount
260
+ }),
261
+ proof: proofA
262
+ })
263
+ );
264
+ assertEq(
265
+ jbTokens().totalBalanceOf(userA, 1),
266
+ leafA.projectTokenCount,
267
+ "User A claim should succeed with nonce=2 proof"
268
+ );
269
+
270
+ // ── Claim User B (only exists in nonce=2's tree) ──
271
+ bytes32[32] memory proofB = _zeroProof();
272
+ proofB[0] = leafA.hashed;
273
+
274
+ suckerL1.claim(
275
+ JBClaim({
276
+ token: token,
277
+ leaf: JBLeaf({
278
+ index: leafB.index,
279
+ beneficiary: leafB.beneficiary,
280
+ projectTokenCount: leafB.projectTokenCount,
281
+ terminalTokenAmount: leafB.terminalTokenAmount
282
+ }),
283
+ proof: proofB
284
+ })
285
+ );
286
+ assertEq(
287
+ jbTokens().totalBalanceOf(userB, 1),
288
+ leafB.projectTokenCount,
289
+ "User B claim should succeed with nonce=2 proof"
290
+ );
291
+ }
292
+
293
+ // ════════════════════════════════════════════════════════════════════════
294
+ // Test 3: Double toRemote (empty outbox)
295
+ // ════════════════════════════════════════════════════════════════════════
296
+ //
297
+ // Scenario:
298
+ // - User prepares
299
+ // - toRemote succeeds (outbox cleared: balance=0, numberOfClaimsSent=tree.count)
300
+ // - toRemote again immediately — outbox is empty
301
+ // - Expected: reverts with JBSucker_NothingToSend
302
+ //
303
+ // The guard is: `outbox.balance == 0 && outbox.tree.count == outbox.numberOfClaimsSent`
304
+ // After the first toRemote, balance is cleared and numberOfClaimsSent is set to tree.count,
305
+ // so the second call hits this guard and reverts.
306
+ //
307
+ // ════════════════════════════════════════════════════════════════════════
308
+
309
+ function test_adversarial_doubleToRemote() external {
310
+ address user = makeAddr("user");
311
+
312
+ // ── L1: User pays and prepares ──
313
+ vm.selectFork(l1Fork);
314
+ _mapTerminalToken();
315
+ _mapPayAndPrepare(user, 0.05 ether);
316
+
317
+ // First toRemote: should succeed.
318
+ _sendToRemote();
319
+
320
+ // Second toRemote: outbox is empty, should revert.
321
+ address token = _terminalToken();
322
+ address rootSender = makeAddr("doubleRootSender");
323
+ uint256 ccipFeeAmount = 1 ether;
324
+ vm.deal(rootSender, ccipFeeAmount);
325
+
326
+ vm.expectRevert(abi.encodeWithSelector(JBSucker.JBSucker_NothingToSend.selector));
327
+ vm.prank(rootSender);
328
+ suckerL1.toRemote{value: ccipFeeAmount}(token);
329
+ }
330
+
331
+ // ════════════════════════════════════════════════════════════════════════
332
+ // Test 4: Prepare after toRemote starts new batch
333
+ // ════════════════════════════════════════════════════════════════════════
334
+ //
335
+ // Scenario:
336
+ // - User A prepares (leaf 0)
337
+ // - toRemote sends root (nonce=1, 1-leaf tree)
338
+ // - User B prepares (leaf 1 in the SAME append-only tree -- new "batch")
339
+ // - toRemote sends root (nonce=2, 2-leaf tree)
340
+ // - Deliver nonce=1 to L2, then deliver nonce=2 to L2
341
+ // - Claim User A's leaf from first batch -> verify success
342
+ // - Claim User B's leaf from second batch -> verify success
343
+ //
344
+ // Key: Even though User B's leaf was in a "new batch" (prepared after User A's toRemote),
345
+ // the outbox tree is cumulative. Nonce=2's root contains BOTH leaves. After nonce=2 delivery
346
+ // overwrites the inbox root, claims for both User A and User B must use proofs against the
347
+ // 2-leaf tree root.
348
+ //
349
+ // BUT: if we want to claim User A from nonce=1's root, we must do it BEFORE nonce=2
350
+ // overwrites it. This test demonstrates both orderings:
351
+ // (a) Deliver nonce=1, claim User A against root1 (1-leaf proof)
352
+ // (b) Deliver nonce=2, claim User B against root2 (2-leaf proof)
353
+ //
354
+ // ════════════════════════════════════════════════════════════════════════
355
+
356
+ function test_adversarial_prepareAfterToRemote_newBatch() external {
357
+ address userA = makeAddr("userA");
358
+ address userB = makeAddr("userB");
359
+ uint256 amountToSend = 0.05 ether;
360
+
361
+ // ── L1: User A prepares ──
362
+ vm.selectFork(l1Fork);
363
+ address token = _terminalToken();
364
+ _mapTerminalToken();
365
+
366
+ LeafData memory leafA = _mapPayAndPrepare(userA, amountToSend);
367
+ assertEq(leafA.index, 0, "User A should be at index 0");
368
+
369
+ // toRemote nonce=1 (1-leaf tree root).
370
+ (bytes32 root1, uint64 nonce1) = _sendToRemote();
371
+
372
+ // ── L1: User B prepares after toRemote (new "batch", same tree) ──
373
+ LeafData memory leafB = _mapPayAndPrepare(userB, amountToSend);
374
+ assertEq(leafB.index, 1, "User B should be at index 1");
375
+
376
+ // toRemote nonce=2 (2-leaf tree root).
377
+ (bytes32 root2, uint64 nonce2) = _sendToRemote();
378
+
379
+ // ── Deliver nonce=1 to L2 and claim User A immediately ──
380
+ vm.selectFork(l2Fork);
381
+ token = _terminalToken();
382
+
383
+ _deliverToL2(root1, nonce1, leafA.terminalTokenAmount);
384
+ assertEq(suckerL1.inboxOf(token).root, root1, "Inbox should hold root1");
385
+
386
+ // Claim User A against root1 (single-leaf tree, zero proof).
387
+ suckerL1.claim(
388
+ JBClaim({
389
+ token: token,
390
+ leaf: JBLeaf({
391
+ index: leafA.index,
392
+ beneficiary: leafA.beneficiary,
393
+ projectTokenCount: leafA.projectTokenCount,
394
+ terminalTokenAmount: leafA.terminalTokenAmount
395
+ }),
396
+ proof: _zeroProof()
397
+ })
398
+ );
399
+ assertEq(
400
+ jbTokens().totalBalanceOf(userA, 1),
401
+ leafA.projectTokenCount,
402
+ "User A should receive tokens from nonce=1 claim"
403
+ );
404
+
405
+ // ── Deliver nonce=2 to L2 (overwrites inbox root) ──
406
+ _deliverToL2(root2, nonce2, leafB.terminalTokenAmount);
407
+ assertEq(suckerL1.inboxOf(token).root, root2, "Inbox should now hold root2");
408
+
409
+ // Claim User B against root2 (2-leaf tree, sibling = leafA.hashed).
410
+ bytes32[32] memory proofB = _zeroProof();
411
+ proofB[0] = leafA.hashed;
412
+
413
+ suckerL1.claim(
414
+ JBClaim({
415
+ token: token,
416
+ leaf: JBLeaf({
417
+ index: leafB.index,
418
+ beneficiary: leafB.beneficiary,
419
+ projectTokenCount: leafB.projectTokenCount,
420
+ terminalTokenAmount: leafB.terminalTokenAmount
421
+ }),
422
+ proof: proofB
423
+ })
424
+ );
425
+ assertEq(
426
+ jbTokens().totalBalanceOf(userB, 1),
427
+ leafB.projectTokenCount,
428
+ "User B should receive tokens from nonce=2 claim"
429
+ );
430
+
431
+ // ── Verify double-claim for User A reverts (already claimed above) ──
432
+ bytes32[32] memory proofA2 = _zeroProof();
433
+ proofA2[0] = leafB.hashed;
434
+
435
+ vm.expectRevert(abi.encodeWithSelector(JBSucker.JBSucker_LeafAlreadyExecuted.selector, token, leafA.index));
436
+ suckerL1.claim(
437
+ JBClaim({
438
+ token: token,
439
+ leaf: JBLeaf({
440
+ index: leafA.index,
441
+ beneficiary: leafA.beneficiary,
442
+ projectTokenCount: leafA.projectTokenCount,
443
+ terminalTokenAmount: leafA.terminalTokenAmount
444
+ }),
445
+ proof: proofA2
446
+ })
447
+ );
448
+ }
449
+ }
@@ -66,6 +66,7 @@ contract ForkArbitrumDeployerTest is TestBaseWorkflow, IERC721Receiver {
66
66
 
67
67
  // ── L1 (Ethereum mainnet)
68
68
  l1Fork = vm.createSelectFork("ethereum");
69
+ vm.rollFork(block.number - 5);
69
70
  super.setUp();
70
71
  vm.stopPrank();
71
72
 
@@ -97,6 +98,7 @@ contract ForkArbitrumDeployerTest is TestBaseWorkflow, IERC721Receiver {
97
98
 
98
99
  // ── L2 (Arbitrum mainnet)
99
100
  l2Fork = vm.createSelectFork("arbitrum");
101
+ vm.rollFork(block.number - 5);
100
102
  super.setUp();
101
103
  vm.stopPrank();
102
104
 
@@ -253,6 +255,7 @@ contract ForkArbitrumNativeTransferTest is TestBaseWorkflow {
253
255
 
254
256
  // ── L1 (Ethereum mainnet)
255
257
  l1Fork = vm.createSelectFork("ethereum");
258
+ vm.rollFork(block.number - 5);
256
259
  super.setUp();
257
260
  vm.stopPrank();
258
261
 
@@ -87,6 +87,7 @@ contract ForkCeloTest is TestBaseWorkflow {
87
87
  // ── L1 (Ethereum)
88
88
  // ────────────────────────────────────────────────────────────
89
89
  l1Fork = vm.createSelectFork("ethereum");
90
+ vm.rollFork(block.number - 5);
90
91
 
91
92
  // Deploy full JB infrastructure on L1.
92
93
  super.setUp();
@@ -131,6 +132,7 @@ contract ForkCeloTest is TestBaseWorkflow {
131
132
  // ── L2 (Celo)
132
133
  // ────────────────────────────────────────────────────────────
133
134
  l2Fork = vm.createSelectFork("celo");
135
+ vm.rollFork(block.number - 5);
134
136
 
135
137
  // Deploy full JB infrastructure on Celo.
136
138
  super.setUp();
@@ -452,7 +452,7 @@ abstract contract CCIPSuckerClaimForkTestBase is TestBaseWorkflow {
452
452
  sourceDecimals: 18,
453
453
  sourceSurplus: 0,
454
454
  sourceBalance: 0,
455
- snapshotNonce: 1
455
+ sourceTimestamp: 1
456
456
  });
457
457
 
458
458
  vm.prank(CCIPHelper.routerOfChain(_l2ChainId()));
@@ -885,7 +885,7 @@ abstract contract CCIPSuckerClaimForkTestBase is TestBaseWorkflow {
885
885
  sourceDecimals: 18,
886
886
  sourceSurplus: 0,
887
887
  sourceBalance: 0,
888
- snapshotNonce: 2
888
+ sourceTimestamp: 2
889
889
  });
890
890
 
891
891
  vm.prank(CCIPHelper.routerOfChain(_l2ChainId()));