@bananapus/suckers-v6 0.0.28 → 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.
- package/package.json +3 -3
- package/src/JBSucker.sol +9 -10
- package/src/JBSwapCCIPSucker.sol +13 -7
- package/src/libraries/JBSuckerLib.sol +3 -3
- package/src/libraries/JBSwapPoolLib.sol +6 -0
- package/src/structs/JBMessageRoot.sol +3 -4
- package/test/AdversarialSuckerFork.t.sol +449 -0
- package/test/ForkArbitrum.t.sol +3 -0
- package/test/ForkCelo.t.sol +2 -0
- package/test/ForkClaimMainnet.t.sol +2 -2
- package/test/ForkOPStack.t.sol +4 -2
- package/test/ForkSwap.t.sol +2 -2
- package/test/ForkSwapMainnet.t.sol +1 -1
- package/test/InteropCompat.t.sol +3 -3
- package/test/MultiSuckerFork.t.sol +529 -0
- package/test/SuckerAttacks.t.sol +4 -4
- package/test/SuckerCrossChainAdversarial.t.sol +745 -0
- package/test/SuckerDeepAttacks.t.sol +10 -10
- package/test/TestAuditGaps.sol +11 -11
- package/test/audit/2026-04-21-codex-nemesis-RegistryStaleDeprecatedMaxSurplus.t.sol +127 -0
- package/test/audit/2026-04-22-codex-nemesis-PeerTopologyAuthBreak.t.sol +112 -0
- package/test/audit/2026-04-22-codex-nemesis-RegistryPeerAuthBreak.t.sol +198 -0
- package/test/audit/2026-04-22-codex-nemesis-ZeroOutputRetryClaim.t.sol +162 -0
- package/test/audit/2026-04-24-codex-nemesis-FreshRound.t.sol +383 -0
- package/test/audit/2026-04-24-codex-nemesis-RegistryPeerMismatch.t.sol +112 -0
- package/test/audit/2026-04-24-codex-nemesis-RegistryStaleMaxAggregation.t.sol +88 -0
- package/test/audit/2026-04-25-codex-nemesis-TransientClaimContext.t.sol +258 -0
- package/test/audit/DeprecatedSuckerDestination.t.sol +1 -1
- package/test/audit/TrustedForwarderSpoof.t.sol +2 -2
- package/test/audit/TrustedForwarderSpoofCCIP.t.sol +1 -1
- package/test/audit/ZeroOutputSwapPending.t.sol +2 -2
- package/test/audit/codex-CCIPLegacyFormatCompatibility.t.sol +1 -1
- package/test/audit/codex-CCIPWrappedNativeMisunwrap.t.sol +1 -1
- package/test/audit/codex-PeerSnapshotDesync.t.sol +2 -2
- package/test/audit/codex-SwapZeroAmountBatchGap.t.sol +2 -2
- package/test/unit/ccip_native_interop.t.sol +7 -7
- 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.
|
|
3
|
+
"version": "0.0.30",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@arbitrum/nitro-contracts": "^1.2.1",
|
|
22
|
-
"@bananapus/core-v6": "^0.0.
|
|
23
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
22
|
+
"@bananapus/core-v6": "^0.0.36",
|
|
23
|
+
"@bananapus/permission-ids-v6": "^0.0.19",
|
|
24
24
|
"@chainlink/contracts-ccip": "^1.6.0",
|
|
25
25
|
"@chainlink/local": "github:smartcontractkit/chainlink-local#v0.2.7",
|
|
26
26
|
"@openzeppelin/contracts": "^5.6.1",
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
404
|
-
//
|
|
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.
|
|
408
|
-
|
|
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
|
-
|
|
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).
|
package/src/JBSwapCCIPSucker.sol
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
21
|
-
///
|
|
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
|
-
|
|
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
|
+
}
|
package/test/ForkArbitrum.t.sol
CHANGED
|
@@ -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
|
|
package/test/ForkCelo.t.sol
CHANGED
|
@@ -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();
|