@bananapus/suckers-v6 0.0.20 → 0.0.21

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.
@@ -1,398 +1,128 @@
1
1
  # Audit Instructions
2
2
 
3
- You are auditing the Juicebox V6 suckers -- a cross-chain bridging system that lets project token holders move their tokens between chains by cashing out on one chain and minting on another. Dual incremental merkle trees (outbox on source, inbox on destination) track token movements, with chain-specific bridges (OP Stack, Arbitrum, CCIP) transporting the merkle roots and backing assets. Your goal is to find bugs that enable double-claiming, lose bridged funds, or bypass the deprecation lifecycle.
3
+ This repo bridges Juicebox project tokens and associated terminal assets across chains. Audit it as a conservation and replay-prevention system.
4
4
 
5
- Read [RISKS.md](./RISKS.md) for known risks and trust assumptions. Then come back here.
5
+ ## Objective
6
+
7
+ Find issues that:
8
+ - allow double claim, replay, or claim on the wrong destination
9
+ - lose or strand bridged backing assets
10
+ - let deprecated or emergency paths violate intended safety rules
11
+ - mis-handle root ordering, especially across asynchronous bridge transports
12
+ - grant mapping or safety privileges more broadly than intended
6
13
 
7
14
  ## Scope
8
15
 
9
- **In scope -- all Solidity in `src/`:**
10
- ```
11
- src/JBSucker.sol # Abstract base (~1,196 lines)
12
- src/JBOptimismSucker.sol # OP Stack bridge implementation (~141 lines)
13
- src/JBBaseSucker.sol # Base (OP Stack variant) (~48 lines)
14
- src/JBCeloSucker.sol # Celo (OP Stack + WETH wrapping) (~196 lines)
15
- src/JBArbitrumSucker.sol # Arbitrum bridge implementation (~322 lines)
16
- src/JBCCIPSucker.sol # Chainlink CCIP implementation (~306 lines)
17
- src/JBSuckerRegistry.sol # Deployer registry and tracking (~282 lines)
18
- src/deployers/ # JBSuckerDeployer, JB{Optimism,Base,Celo,Arbitrum,CCIP}SuckerDeployer
19
- src/utils/MerkleLib.sol # Incremental merkle tree (eth2-style) (~1,030 lines)
20
- src/structs/ # JBMessageRoot, JBLeaf, JBClaim, JBOutboxTree, etc.
21
- src/enums/ # JBSuckerState, JBLayer
22
- src/libraries/ # ARBChains, ARBAddresses, CCIPHelper
23
- ```
16
+ In scope:
17
+ - all Solidity under `src/`
18
+ - deployer contracts under `src/deployers/`
19
+ - `src/utils/MerkleLib.sol`
20
+ - libraries, enums, interfaces, and structs under `src/`
21
+ - deployment scripts in `script/`
22
+
23
+ ## Start Here
24
+
25
+ Read in this order:
26
+ - the shared flow in `JBSucker`
27
+ - claim validation and execution tracking
28
+ - token mapping and emergency-hatch logic
29
+ - one native bridge implementation
30
+ - `JBCCIPSucker`
31
+ - deployers and registry assumptions
32
+
33
+ That order gets you from the shared conservation model to the transport-specific deviations.
34
+
35
+ ## System Model
36
+
37
+ The bridge flow is:
38
+ - burn or prepare project-token value on source chain
39
+ - record a leaf into an outbox tree
40
+ - send a merkle root and backing assets over a chain-specific transport
41
+ - receive the root on the remote chain
42
+ - claim by proving inclusion against the current inbox root
43
+
44
+ This repo supports multiple transport implementations:
45
+ - OP Stack variants
46
+ - Arbitrum
47
+ - CCIP
48
+ - related deployers and registries
49
+
50
+ One non-obvious property to audit explicitly:
51
+ - roots and assets do not always arrive in a perfectly ordered, synchronous way
52
+ - the system is intentionally designed to survive some transport mismatch without deadlocking
53
+ - those recovery choices are exactly where conservation bugs tend to hide
54
+
55
+ ## Critical Invariants
56
+
57
+ 1. Cross-chain conservation
58
+ For any prepared transfer, destination claimable value must not exceed what the source side actually prepared and backed.
59
+
60
+ 2. Single execution
61
+ Each bridged leaf must be claimable at most once on the destination and at most once via emergency exit.
62
+
63
+ 3. Peer authenticity
64
+ Only the intended remote peer and messenger path may update inbox roots.
65
+
66
+ 4. Deprecation safety
67
+ Deprecation and emergency-hatch controls must not let callers bypass intended restrictions or steal in-flight funds.
68
+
69
+ 5. Token mapping integrity
70
+ Remote token mappings must be immutable or mutable only exactly where the design allows.
71
+
72
+ 6. Nonce progression is monotonic in the way each transport expects
73
+ Later roots must not silently invalidate earlier user claims unless the protocol explicitly intends that recovery path.
74
+
75
+ ## Threat Model
76
+
77
+ Prioritize:
78
+ - out-of-order nonce arrival
79
+ - cross-sucker replay
80
+ - trusted-forwarder or messenger spoofing
81
+ - emergency-exit races
82
+ - fee fallback and bridge-payment edge cases
83
+ - deterministic deployer assumptions for peer pairing
84
+
85
+ The strongest attacker models here are:
86
+ - a caller trying to claim from the wrong root with a structurally valid proof
87
+ - a privileged actor abusing token mapping or emergency controls after users already prepared transfers
88
+ - a transport delivering messages out of order and exposing assumptions hidden in the happy path
89
+
90
+ ## Hotspots
91
+
92
+ - `prepare`, `toRemote`, `fromRemote`, and `claim`
93
+ - bitmap execution tracking
94
+ - root and nonce handling
95
+ - token mapping and registry trust
96
+ - chain-specific messenger authentication
97
+ - deployer address derivation and clone setup
98
+
99
+ ## Sequences Worth Replaying
100
+
101
+ 1. Prepare multiple leaves -> send multiple roots -> receive them out of order -> attempt claims for each.
102
+ 2. Prepare -> deprecate or enable emergency hatch -> claim and exit attempts racing each other.
103
+ 3. Map token -> prepare transfer -> attempt remap or peer mismatch after value is already in flight.
104
+ 4. Same logical transfer across different sucker implementations to check for replay or identity confusion.
24
105
 
25
- **Out of scope:** Test files (`test/`), OpenZeppelin/Arbitrum/CCIP dependencies (assume correct), forge-std.
106
+ ## Finding Bar
26
107
 
27
- ## Architecture
108
+ The strongest findings here usually show one of these:
109
+ - a user can claim against value that was never actually prepared
110
+ - a valid prepare becomes permanently unclaimable without the recovery path the protocol expects
111
+ - transport-specific authentication is weaker than the shared model assumes
112
+ - a privileged mapping or safety control can rewrite the meaning of already in-flight value
28
113
 
29
- ### JBSucker (src/JBSucker.sol) -- Abstract Base
114
+ ## Build And Verification
30
115
 
31
- The core bridging logic. Each sucker instance is associated with one project and deployed as a clone via `Initializable`. Suckers are deployed in pairs (one per chain) with matching CREATE2 addresses so `peer()` returns `_toBytes32(address(this))` by default.
116
+ Standard workflow:
117
+ - `npm install`
118
+ - `forge build`
119
+ - `forge test`
32
120
 
33
- **Immutables:** `DIRECTORY`, `TOKENS`.
121
+ The current tests already target:
122
+ - deep attack and regression scenarios
123
+ - trusted-forwarder spoofing
124
+ - fee fallback behavior
125
+ - deterministic deployment
126
+ - chain-specific fork flows
34
127
 
35
- **Key state:**
36
- - `_outboxOf[token]` -- `JBOutboxTree`: merkle tree, balance, nonce, numberOfClaimsSent per token
37
- - `_inboxOf[token]` -- `JBInboxTreeRoot`: root hash and nonce per token
38
- - `_remoteTokenFor[token]` -- `JBRemoteToken`: remote address, enabled flag, emergency hatch, min gas, min bridge amount
39
- - `_executedFor[token]` -- `BitMap`: tracks which leaf indices have been claimed (prevents double-spend)
40
- - `deprecatedAfter` -- timestamp for deprecation lifecycle
41
-
42
- **Key functions:**
43
- - `initialize(projectId)` -- One-time initialization of the clone with the project ID.
44
- - `prepare(projectTokenCount, beneficiary, minTokensReclaimed, token)` -- Cash out project tokens, insert a leaf into the outbox merkle tree.
45
- - `toRemote(token)` -- Send the outbox root and backing assets to the remote chain via the bridge.
46
- - `fromRemote(JBMessageRoot)` -- Receive a merkle root from the remote peer. Only callable by the authenticated bridge messenger.
47
- - `claim(JBClaim)` / `claim(JBClaim[])` -- Verify a merkle proof against the inbox root and mint project tokens for the beneficiary.
48
- - `mapToken(JBTokenMapping)` / `mapTokens(JBTokenMapping[])` -- Map local tokens to remote tokens. Requires `MAP_SUCKER_TOKEN` permission.
49
- - `exitThroughEmergencyHatch(JBClaim)` -- Reclaim tokens locally when the bridge is broken. Validates against the outbox tree (not inbox).
50
- - `enableEmergencyHatchFor(address[])` -- Project owner enables emergency exit for specific tokens. Requires `SUCKER_SAFETY` permission.
51
- - `setDeprecation(uint40 timestamp)` -- Set or clear the deprecation timestamp. Requires `SET_SUCKER_DEPRECATION` permission.
52
-
53
- **Key internal functions:**
54
- - `_insertIntoTree(projectTokenCount, token, terminalTokenAmount, beneficiary)` -- Builds leaf hash, inserts into outbox merkle tree, updates balance.
55
- - `_validate(projectTokenCount, terminalToken, terminalTokenAmount, beneficiary, index, leaves)` -- Verifies merkle proof against inbox root, marks leaf as executed in bitmap.
56
- - `_validateForEmergencyExit(...)` -- Validates against outbox root, checks `numberOfClaimsSent` bounds, uses separate bitmap slot.
57
- - `_validateBranchRoot(expectedRoot, ...)` -- Computes `MerkleLib.branchRoot()` and compares to expected root.
58
- - `_sendRoot(transportPayment, token, remoteToken)` -- Builds `JBMessageRoot`, clears outbox balance, increments nonce, delegates to `_sendRootOverAMB()`.
59
- - `_pullBackingAssets(projectToken, count, token, minTokensReclaimed)` -- Cashes out project tokens via the primary terminal.
60
- - `_handleClaim(terminalToken, terminalTokenAmount, projectTokenAmount, beneficiary)` -- Adds terminal tokens to balance and mints project tokens for beneficiary.
61
- - `_mapToken(map, transportPaymentValue)` -- Token mapping with immutability enforcement (cannot remap once outbox has entries).
62
- - `_addToBalance(token, amount)` -- Adds terminal tokens to the project's balance via the primary terminal.
63
- - `_isRemotePeer(sender)` -- Abstract. Verifies the caller is the authenticated bridge messenger representing the remote peer.
64
- - `_sendRootOverAMB(...)` -- Abstract. Chain-specific bridge logic.
65
-
66
- ### JBOptimismSucker (src/JBOptimismSucker.sol)
67
-
68
- OP Stack implementation. Uses `IOPMessenger` for messages and `IOPStandardBridge` for token bridging.
69
-
70
- - `_isRemotePeer(sender)`: Checks `sender == OPMESSENGER && OPMESSENGER.xDomainMessageSender() == peer()`.
71
- - `_sendRootOverAMB(...)`: Bridges ERC-20 via `OPBRIDGE.bridgeERC20To()`, sends message via `OPMESSENGER.sendMessage()`. Native ETH sent as `msg.value` on the messenger call. Transport payment must be 0 (OP bridge is free).
72
-
73
- ### JBBaseSucker (src/JBBaseSucker.sol)
74
-
75
- Extends `JBOptimismSucker` with Base chain ID mappings. Same bridge logic.
76
-
77
- ### JBCeloSucker (src/JBCeloSucker.sol)
78
-
79
- Extends `JBOptimismSucker` for Celo (OP Stack chain with CELO as native gas token, not ETH).
80
-
81
- - Wraps native ETH to WETH before bridging as ERC-20.
82
- - Removes the `NATIVE_TOKEN -> NATIVE_TOKEN` restriction so native ETH can map to a remote ERC-20.
83
- - `_addToBalance()` override: unwraps WETH -> native ETH before adding to project balance.
84
- - Messenger message sent with `nativeValue = 0` (no ETH attached on Celo).
85
-
86
- ### JBArbitrumSucker (src/JBArbitrumSucker.sol)
87
-
88
- Arbitrum implementation. Uses `IInbox` for retryable tickets and `IArbGatewayRouter` for token bridging.
89
-
90
- - `_isRemotePeer(sender)`:
91
- - **L1 side**: Checks `sender == ARBINBOX.bridge() && IOutbox(bridge.activeOutbox()).l2ToL1Sender() == peer()`.
92
- - **L2 side**: Checks `sender == AddressAliasHelper.applyL1ToL2Alias(peer())`.
93
- - `_sendRootOverAMB(...)`:
94
- - **L1 -> L2**: Creates two independent retryable tickets (one for ERC-20 bridge, one for merkle root message). Non-atomic: tickets are redeemed independently on L2 with no guaranteed ordering. `_addToBalance` checks actual token balance to prevent unbacked minting when message arrives before tokens.
95
- - **L2 -> L1**: Uses `ArbSys.sendTxToL1()` for message, `IArbL2GatewayRouter.outboundTransfer()` for tokens.
96
- - Transport payment required from L1 (covers retryable ticket gas).
97
-
98
- ### JBCCIPSucker (src/JBCCIPSucker.sol)
99
-
100
- Chainlink CCIP implementation. Uses `ICCIPRouter` for cross-chain messaging with token transfer.
101
-
102
- - `_isRemotePeer(sender)`: Checks `sender == address(this)` (CCIP calls `ccipReceive` which then calls `this.fromRemote()`).
103
- - `ccipReceive(Client.Any2EVMMessage)`: Entry point from CCIP router. Validates `msg.sender == CCIP_ROUTER`, `origin == peer()`, `sourceChainSelector == REMOTE_CHAIN_SELECTOR`. Unwraps WETH to ETH when `root.token == NATIVE_TOKEN`. Calls `this.fromRemote(root)`.
104
- - `_sendRootOverAMB(...)`: Wraps native ETH to WETH (CCIP only transports ERC-20s). Builds `Client.EVM2AnyMessage`, gets fee quote, calls `CCIP_ROUTER.ccipSend{value: fees}()`. Refunds excess transport payment (best-effort, does not revert on failure).
105
- - Amount validation intentionally skipped (CCIP guarantees delivery). Reverting on mismatch would lock tokens.
106
-
107
- ### JBSuckerRegistry (src/JBSuckerRegistry.sol)
108
-
109
- Manages sucker deployment and tracking.
110
-
111
- **Key functions:**
112
- - `deploySuckersFor(projectId, salt, configurations[])` -- Deploys suckers via allowed deployers. Requires `DEPLOY_SUCKERS` permission. Salt includes sender address for deterministic cross-chain deployment.
113
- - `allowSuckerDeployer(deployer)` / `removeSuckerDeployer(deployer)` -- Owner-only allowlist management.
114
- - `removeDeprecatedSucker(projectId, sucker)` -- Anyone can remove a fully deprecated sucker from the registry.
115
- - `isSuckerOf(projectId, addr)` -- Check if a sucker belongs to a project.
116
- - `suckersOf(projectId)` / `suckerPairsOf(projectId)` -- List project suckers with remote peer info.
117
-
118
- ### MerkleLib (src/utils/MerkleLib.sol)
119
-
120
- Incremental merkle tree modeled on the eth2 deposit contract. Depth-32, max `2^32 - 1` leaves.
121
-
122
- **Key functions:**
123
- - `insert(Tree, node)` -- Insert a leaf. Returns updated tree. Reverts if full.
124
- - `root(Tree storage)` -- Compute the current root from storage (assembly-optimized).
125
- - `branchRoot(item, branch, index)` -- Compute root from a leaf, proof branch, and index (assembly-optimized). Used for claim verification.
126
-
127
- ## Key Flows
128
-
129
- ### Prepare (Bridge Out -- Source Chain)
130
-
131
- ```
132
- User calls prepare(projectTokenCount, beneficiary, minTokensReclaimed, token)
133
- |
134
- +--> Validate: beneficiary != 0, project has ERC-20, token is mapped and enabled, sucker not deprecated
135
- +--> Transfer project tokens from user to sucker
136
- +--> _pullBackingAssets():
137
- | Cash out project tokens via terminal.cashOutTokensOf()
138
- | Sucker gets 0% cashOutTaxRate (special permission from JBOmnichainDeployer)
139
- | Assert: balance delta matches returned reclaimedAmount
140
- |
141
- +--> _insertIntoTree():
142
- | Guard: amounts fit in uint128 (SVM compatibility)
143
- | Build leaf hash: keccak256(abi.encode(projectTokenCount, terminalTokenAmount, beneficiary))
144
- | Insert into outbox merkle tree
145
- | Update outbox balance
146
- |
147
- +--> Emit InsertToOutboxTree(beneficiary, token, hash, index, root, counts)
148
- ```
149
-
150
- ### Bridge (Transport -- Source Chain)
151
-
152
- ```
153
- Anyone calls toRemote(token)
154
- |
155
- +--> Validate: emergency hatch not enabled, nothing-to-send guard, deduct toRemoteFee (if set, read from JBSuckerRegistry via REGISTRY.toRemoteFee(), set globally by registry owner via setToRemoteFee(), capped at MAX_TO_REMOTE_FEE = 0.001 ether), sucker not deprecated
156
- +--> _sendRoot():
157
- | Read outbox tree count and balance
158
- | Clear outbox balance (set to 0)
159
- | Increment nonce
160
- | Compute outbox tree root
161
- | Update numberOfClaimsSent = tree.count
162
- | Build JBMessageRoot(version, remoteToken.addr, amount, JBInboxTreeRoot(nonce, root))
163
- |
164
- +--> _sendRootOverAMB() [chain-specific]:
165
- OP: bridgeERC20To() + OPMESSENGER.sendMessage()
166
- ARB: IArbGatewayRouter.outboundTransfer() + ARBINBOX.createRetryableTicket()
167
- CCIP: CCIP_ROUTER.ccipSend() with token amounts
168
- ```
169
-
170
- ### Claim (Bridge In -- Destination Chain)
171
-
172
- ```
173
- Bridge delivers message -> fromRemote(JBMessageRoot) is called
174
- |
175
- +--> Validate: caller is authenticated remote peer, message version matches
176
- +--> If root.remoteRoot.nonce > inbox.nonce && state != DEPRECATED:
177
- | Update inbox root and nonce
178
- |
179
-
180
- Later, anyone calls claim(JBClaim) for a beneficiary:
181
- |
182
- +--> _validate():
183
- | Check _executedFor[token].get(index) is false (not already claimed)
184
- | Set _executedFor[token].set(index)
185
- | Build leaf hash from claim data
186
- | Compute root via MerkleLib.branchRoot(hash, proof, index)
187
- | Compare to _inboxOf[token].root -- revert if mismatch
188
- |
189
- +--> _handleClaim():
190
- | _addToBalance(terminalToken, terminalTokenAmount)
191
- | Mint project tokens for beneficiary via controller.mintTokensOf()
192
- | (useReservedPercent = false -- sucker mints bypass reserved percent)
193
- ```
194
-
195
- ### Deprecation Lifecycle
196
-
197
- ```
198
- ENABLED (normal operation)
199
- | setDeprecation(timestamp) where timestamp > block.timestamp + _maxMessagingDelay()
200
- v
201
- DEPRECATION_PENDING (warning, all operations still work)
202
- | block.timestamp reaches (deprecatedAfter - _maxMessagingDelay)
203
- v
204
- SENDING_DISABLED (prepare and toRemote blocked, claims still work)
205
- | block.timestamp reaches deprecatedAfter
206
- v
207
- DEPRECATED (fromRemote rejects new roots, claims still work, emergency exit works)
208
- ```
209
-
210
- ## Merkle Tree Proof System
211
-
212
- ### Outbox Tree Construction
213
-
214
- Each `prepare()` call inserts a leaf into the outbox tree:
215
-
216
- ```
217
- leaf = keccak256(abi.encode(projectTokenCount, terminalTokenAmount, beneficiary))
218
- ```
219
-
220
- The tree is an incremental merkle tree (eth2 deposit contract pattern) with depth 32 and max `2^32 - 1` leaves. Leaves are inserted in order; the tree is append-only.
221
-
222
- ### Inbox Root Verification
223
-
224
- When `fromRemote()` receives a `JBMessageRoot`, it stores the merkle root in `_inboxOf[token].root`. The nonce must be strictly greater than the current inbox nonce (non-sequential ordering is accepted for CCIP compatibility).
225
-
226
- ### Claim Verification
227
-
228
- To claim, a user provides:
229
- - `JBLeaf`: `(index, beneficiary, projectTokenCount, terminalTokenAmount)`
230
- - `bytes32[32] proof`: the 32-element merkle proof branch
231
-
232
- `_validate()` rebuilds the leaf hash, computes the root via `MerkleLib.branchRoot()`, and compares it to the stored inbox root. The leaf index is marked in a bitmap to prevent double-claiming.
233
-
234
- ### Emergency Exit Verification
235
-
236
- `_validateForEmergencyExit()` validates against the **outbox** tree (not inbox). It additionally checks:
237
- - Emergency hatch is enabled for the token, OR the sucker is deprecated/sending-disabled.
238
- - `index >= numberOfClaimsSent` (the leaf was NOT part of a root already sent to the remote peer). This prevents double-spending where a leaf could be claimed on both chains.
239
- - Uses a separate bitmap slot: `address(bytes20(keccak256(abi.encode(terminalToken))))` to avoid collision with the regular claim bitmap.
240
-
241
- ## Token Mapping
242
-
243
- - **Immutable once outbox has entries**: If `_outboxOf[token].tree.count != 0`, the token can only be disabled (mapped to `bytes32(0)`), not remapped to a different remote token. This prevents double-spending.
244
- - **Re-enabling supported**: A disabled token (remote = 0) can be re-enabled back to its original remote token.
245
- - **Disabling triggers root flush**: When mapping to `bytes32(0)` with unsent outbox entries (`numberOfClaimsSent != tree.count`), `_sendRoot()` is called to flush remaining entries before disabling.
246
- - **Validation**: Base class requires native token to map only to native token or zero. `JBCCIPSucker` and `JBCeloSucker` override this to allow native-to-ERC20 mapping. All implementations enforce `minGas >= MESSENGER_ERC20_MIN_GAS_LIMIT` for ERC-20 tokens.
247
-
248
- ## Priority Audit Areas
249
-
250
- | Priority | Target | Why |
251
- |----------|--------|-----|
252
- | 1 | **Merkle proof verification** (`_validate`, `_validateForEmergencyExit`, `MerkleLib.branchRoot`) | Double-claim or invalid-claim is the highest-impact bug. Verify: leaf hash construction matches insertion, bitmap prevents replay, proof verification is correct, emergency exit uses separate bitmap and checks numberOfClaimsSent bounds. |
253
- | 2 | **fromRemote authentication** (`_isRemotePeer` in each implementation) | If an attacker can call `fromRemote()` with a crafted root, they can mint arbitrary tokens. Verify each bridge's authentication: OP (xDomainMessageSender), Arbitrum (L1 outbox / L2 alias), CCIP (router + origin + chain selector). |
254
- | 3 | **Token conservation across bridge** | Verify: outbox balance cleared in `_sendRoot()` matches what the bridge transfers, inbox `_handleClaim()` only distributes `terminalTokenAmount` that was actually received. For CCIP: amount validation is intentionally skipped. For Arbitrum L1->L2: two independent retryable tickets create a non-atomic window. |
255
- | 4 | **Emergency exit safety** | `numberOfClaimsSent` determines which leaves are safe to emergency-exit. Verify: it's updated only in `_sendRoot()`, accurately tracks what was sent, and the `>= index` comparison is correct (count vs 0-based index). |
256
- | 5 | **Deprecation lifecycle** | State transitions are timestamp-based. Verify: `_maxMessagingDelay()` provides enough time for in-flight messages, `SENDING_DISABLED` blocks `prepare()` and `toRemote()` but allows `claim()` and `fromRemote()`, `DEPRECATED` blocks `fromRemote()` new roots. |
257
- | 6 | **Token mapping immutability** | Verify: once `_outboxOf[token].tree.count != 0`, remapping to a different remote token reverts. Disabling triggers root flush. Re-enabling back to the same address works. |
258
- | 7 | **Arbitrum non-atomic bridging** | L1->L2 creates two independent retryable tickets. Verify: `_addToBalance()` checks `amountToAddToBalanceOf()` which depends on actual token balance, preventing unbacked minting when message arrives before tokens. |
259
- | 8 | **CCIP-specific: ccipReceive** | Must never revert after CCIP delivers tokens. Verify: WETH unwrap safety, `this.fromRemote()` self-call pattern, transport payment refund (best-effort, stuck ETH accepted). |
260
- | 9 | **Reentrancy surfaces** | `_pullBackingAssets()` calls `terminal.cashOutTokensOf()` which triggers hooks. `_handleClaim()` calls `terminal.addToBalanceOf()` and `controller.mintTokensOf()`. No ReentrancyGuard. Verify: state is updated before external calls, bitmap prevents re-entry exploits. |
261
-
262
- ## Invariants to Verify
263
-
264
- 1. **No double-claim**: Each leaf index can only be claimed once per token (bitmap enforcement).
265
- 2. **No cross-chain double-spend**: Emergency exit only allows leaves with `index >= numberOfClaimsSent` (not already sent to remote).
266
- 3. **Token conservation**: `outbox.balance` (before `_sendRoot()`) == amount bridged to remote peer == amount available for claims on remote.
267
- 4. **Nonce monotonicity**: Inbox nonce only increases. A stale nonce is rejected.
268
- 5. **Mapping immutability**: After first outbox insertion, token cannot be remapped (only disabled).
269
- 6. **Deprecation irreversibility**: Once `SENDING_DISABLED` or `DEPRECATED`, the sucker cannot return to `ENABLED`.
270
- 7. **Balance accounting**: `amountToAddToBalanceOf(token) = contract.balance(token) - outboxOf[token].balance` is always non-negative.
271
- 8. **Peer symmetry**: `peer()` returns the same address on both chains (CREATE2 determinism).
272
-
273
- ## How to Run Tests
274
-
275
- ```bash
276
- cd nana-suckers-v6
277
- npm install
278
- forge build
279
- forge test
280
-
281
- # Run with high verbosity for debugging
282
- forge test -vvvv --match-test testExploitName
283
-
284
- # Write a PoC
285
- forge test --match-path test/audit/ExploitPoC.t.sol -vvv
286
-
287
- # Specific test suites
288
- forge test --match-contract SuckerAttacks # Attack scenarios
289
- forge test --match-contract SuckerDeepAttacks # Deep attack vectors
290
- forge test --match-contract SuckerRegressions # Regression tests
291
- forge test --match-contract InteropCompat # Cross-chain compatibility
292
- forge test --match-path test/unit/ # Unit tests
293
- forge test --match-path test/regression/ # Regression tests
294
-
295
- # Fork tests (require RPC URLs)
296
- forge test --match-contract Fork --fork-url $ETH_RPC_URL
297
- forge test --match-contract ForkCelo --fork-url $CELO_RPC_URL
298
- forge test --match-contract ForkMainnet --fork-url $ETH_RPC_URL
299
-
300
- # Gas analysis
301
- forge test --gas-report
302
- ```
303
-
304
- ## Error Reference
305
-
306
- 35 custom errors across source files:
307
-
308
- | Error | Contract | Trigger |
309
- |-------|----------|---------|
310
- | `JBSucker_AmountExceedsUint128` | JBSucker | `projectTokenCount` or `terminalTokenAmount` exceeds `uint128` during `_insertIntoTree` |
311
- | `JBSucker_BelowMinGas` | JBSucker | Token mapping `minGas` is below `MESSENGER_ERC20_MIN_GAS_LIMIT` |
312
- | `JBSucker_Deprecated` | JBSucker | `prepare`, `toRemote`, or `_sendRoot` called when sucker is `SENDING_DISABLED` or `DEPRECATED` |
313
- | `JBSucker_DeprecationTimestampTooSoon` | JBSucker | `setDeprecation` timestamp is earlier than `block.timestamp + _maxMessagingDelay()` |
314
- | `JBSucker_ExpectedMsgValue` | JBArbitrumSucker, JBCCIPSucker | Transport payment is 0 when bridge requires msg.value (Arbitrum L1, CCIP) |
315
- | `JBSucker_InsufficientBalance` | JBSucker | `_addToBalance` amount exceeds `amountToAddToBalanceOf` (actual token balance minus outbox balance) |
316
- | `JBSucker_InsufficientMsgValue` | JBSucker | `msg.value` is less than the `toRemoteFee` in `toRemote` |
317
- | `JBSucker_InvalidMessageVersion` | JBSucker | `fromRemote` receives a root with mismatched `MESSAGE_VERSION` |
318
- | `JBSucker_InvalidNativeRemoteAddress` | JBSucker | Native token mapped to non-native, non-zero remote address (base class restriction) |
319
- | `JBSucker_InvalidProof` | JBSucker | Merkle proof does not reconstruct the stored inbox root during `_validate` |
320
- | `JBSucker_LeafAlreadyExecuted` | JBSucker | Leaf index already claimed in bitmap during `_validate` or `_validateForEmergencyExit` |
321
- | `JBSucker_NoTerminalForToken` | JBSucker | No primary terminal registered for the token when pulling backing assets or adding to balance |
322
- | `JBSucker_NotPeer` | JBSucker | `fromRemote` caller fails `_isRemotePeer` authentication |
323
- | `JBSucker_NothingToSend` | JBSucker | `toRemote` called when emergency hatch is enabled or outbox has no unsent entries |
324
- | `JBSucker_TokenAlreadyMapped` | JBSucker | Attempt to remap a token after outbox tree has entries (immutability enforcement) |
325
- | `JBSucker_TokenHasInvalidEmergencyHatchState` | JBSucker | Emergency hatch state conflict during `toRemote`, `_mapToken`, or `_validateForEmergencyExit` |
326
- | `JBSucker_TokenNotMapped` | JBSucker | `prepare` or `_sendRoot` called for an unmapped token |
327
- | `JBSucker_UnexpectedMsgValue` | JBArbitrumSucker, JBOptimismSucker, JBCeloSucker | Non-zero transport payment on bridges that don't accept it (Arbitrum L2, OP Stack, Celo) |
328
- | `JBSucker_ZeroBeneficiary` | JBSucker | `prepare` called with zero-address beneficiary |
329
- | `JBSucker_ZeroERC20Token` | JBSucker | `prepare` called when project has no ERC-20 token |
330
- | `JBArbitrumSucker_NotEnoughGas` | JBArbitrumSucker | Transport payment insufficient for retryable ticket gas cost |
331
- | `JBCCIPSucker_InvalidRouter` | JBCCIPSucker | Constructor reverts when `CCIP_ROUTER == address(0)` |
332
- | `JBSuckerRegistry_FeeExceedsMax` | JBSuckerRegistry | `setToRemoteFee` exceeds `MAX_TO_REMOTE_FEE` |
333
- | `JBSuckerRegistry_InvalidDeployer` | JBSuckerRegistry | Deployer not in allowlist during `deploySuckersFor` |
334
- | `JBSuckerRegistry_SuckerDoesNotBelongToProject` | JBSuckerRegistry | `removeDeprecatedSucker` called for sucker not belonging to the project |
335
- | `JBSuckerRegistry_SuckerIsNotDeprecated` | JBSuckerRegistry | `removeDeprecatedSucker` called for a non-deprecated sucker |
336
- | `JBCCIPSuckerDeployer_InvalidCCIPRouter` | JBCCIPSuckerDeployer | Zero-address CCIP router in constructor |
337
- | `JBSuckerDeployer_AlreadyConfigured` | JBSuckerDeployer | `configureSucker` called on an already-configured sucker |
338
- | `JBSuckerDeployer_DeployerIsNotConfigured` | JBSuckerDeployer | `createFor` called before deployer is configured |
339
- | `JBSuckerDeployer_InvalidLayerSpecificConfiguration` | JBSuckerDeployer | Layer-specific configuration validation fails |
340
- | `JBSuckerDeployer_LayerSpecificNotConfigured` | JBSuckerDeployer | Layer-specific configuration not set when required |
341
- | `JBSuckerDeployer_Unauthorized` | JBSuckerDeployer | Caller is not the expected configurator |
342
- | `JBSuckerDeployer_ZeroConfiguratorAddress` | JBSuckerDeployer | Zero-address configurator in constructor |
343
- | `CCIPHelper_UnsupportedChain` | CCIPHelper | Chain ID has no CCIP chain selector mapping |
344
- | `MerkleLib_InsertTreeIsFull` | MerkleLib | Merkle tree reached max capacity (`2^32 - 1` leaves) |
345
-
346
- ## Previous Audit Findings
347
-
348
- No prior formal audit with finding IDs has been conducted on this codebase. All risk analysis is internal. See [RISKS.md](./RISKS.md) for known risks and trust assumptions.
349
-
350
- ## Anti-Patterns to Hunt
351
-
352
- | Pattern | Where to Look | Why It's Dangerous |
353
- |---------|--------------|-------------------|
354
- | Non-atomic bridging (Arbitrum L1→L2) | `JBArbitrumSucker._sendRootOverAMB()` | Two independent retryable tickets: tokens and merkle root arrive separately. If message arrives before tokens, `_handleClaim` could mint unbacked tokens. Mitigated by `amountToAddToBalanceOf()` check -- verify this is sufficient. |
355
- | Self-call pattern (CCIP) | `JBCCIPSucker.ccipReceive()` calls `this.fromRemote()` | The self-call bypasses the `_isRemotePeer` check since `msg.sender == address(this)`. All authentication happens in `ccipReceive`. Verify no path can call `fromRemote()` directly. |
356
- | Emergency hatch with no timelock | `enableEmergencyHatchFor()` | Project owner can enable emergency exit instantly. No delay, no multisig requirement. If the owner's key is compromised, they can emergency-exit tokens that are legitimately in transit. |
357
- | uint128 truncation | `_insertIntoTree()` | Amounts are cast to uint128 for SVM compatibility. If a project token amount exceeds uint128, it silently truncates. Verify the cast reverts on overflow. |
358
- | Bitmap slot collision | `_executedFor[token]` vs emergency exit bitmap | Emergency exit uses `address(bytes20(keccak256(abi.encode(terminalToken))))` as a separate bitmap key. Verify this cannot collide with any legitimate token address. |
359
- | Root flush on disable | `_mapToken()` with `bytes32(0)` | Disabling a token calls `_sendRoot()` to flush unsent entries. If the bridge is down, this flush reverts and the token cannot be disabled. |
360
- | CCIP amount validation skip | `JBCCIPSucker._sendRootOverAMB()` | Amount validation is intentionally skipped (reverting would lock tokens). If CCIP delivers fewer tokens than expected, claims are underfunded. |
361
- | Nonce gap acceptance | `fromRemote()` | Inbox nonce only requires `> current`, not `== current + 1`. For CCIP where messages can arrive out of order, intermediate roots are lost. |
362
-
363
- ## Coverage Gaps
364
-
365
- The test suite covers core flows but these areas have limited or no coverage:
366
-
367
- - **CCIP amount mismatch handling**: CCIP intentionally skips amount validation (reverting would lock tokens). No tests verify behavior when the delivered token amount differs from the amount encoded in the merkle root.
368
- - **Arbitrum retryable ticket redemption ordering**: L1->L2 creates two independent retryable tickets (one for tokens, one for merkle root). No tests simulate the message arriving before tokens, verifying that `_addToBalance` correctly checks `amountToAddToBalanceOf` to prevent unbacked minting.
369
- - **Multi-hop Celo WETH wrapping**: Celo wraps native ETH to WETH before bridging and unwraps on the other side. No tests cover edge cases like partial WETH unwrap failures or WETH contract balance manipulation.
370
- - **Emergency exit under active bridging**: No tests for the scenario where a user calls `prepare()`, tokens are in the outbox, `toRemote()` is called (sending the root), and then emergency hatch is enabled -- verifying that `numberOfClaimsSent` correctly prevents double-spend across both chains.
371
- - **Concurrent deprecation and bridging**: No tests for the timing window between `SENDING_DISABLED` and `DEPRECATED` states where `fromRemote()` can still accept roots but `prepare()`/`toRemote()` are blocked.
372
- - **Token mapping flush under reentrancy**: `_mapToken` calls `_sendRoot()` when disabling a token with unsent outbox entries. No tests verify this flush is safe under reentrancy from the bridge callback.
373
-
374
- ## Compiler and Version Info
375
-
376
- - **Solidity**: 0.8.28
377
- - **EVM target**: Cancun
378
- - **Optimizer**: 200 runs (via-IR is NOT enabled)
379
- - **Dependencies**: OpenZeppelin 5.x, Arbitrum SDK, Chainlink CCIP, nana-core-v6
380
- - **Build**: `forge build` (Foundry)
381
-
382
- ## How to Report Findings
383
-
384
- For each finding:
385
-
386
- 1. **Title** -- one line, starts with severity (CRITICAL/HIGH/MEDIUM/LOW)
387
- 2. **Affected contract(s)** -- exact file path and line numbers
388
- 3. **Description** -- what is wrong, in plain language
389
- 4. **Trigger sequence** -- step-by-step, minimal steps to reproduce (include which chain each step happens on)
390
- 5. **Impact** -- what an attacker gains, what a user loses (with numbers if possible)
391
- 6. **Proof** -- code trace showing the exact execution path, or a Foundry test
392
- 7. **Fix** -- minimal code change that resolves the issue
393
-
394
- **Severity guide:**
395
- - **CRITICAL**: Double-claim, unbacked minting, bridge fund loss. Exploitable with no preconditions.
396
- - **HIGH**: Conditional fund loss, authentication bypass, or broken cross-chain invariant.
397
- - **MEDIUM**: Value leakage, griefing, stuck tokens (recoverable via emergency hatch).
398
- - **LOW**: Informational, edge-case-only with no material impact.
128
+ High-value findings here show a break in conservation, replay resistance, or trusted-peer boundaries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,72 @@
1
+ # Changelog
2
+
3
+ ## Scope
4
+
5
+ This file describes the verified change from `nana-suckers-v5` to the current `nana-suckers-v6` repo.
6
+
7
+ ## Current v6 surface
8
+
9
+ - `JBSucker`
10
+ - `JBSuckerRegistry`
11
+ - `JBOptimismSucker`
12
+ - `JBArbitrumSucker`
13
+ - `JBBaseSucker`
14
+ - `JBCCIPSucker`
15
+ - `JBCeloSucker`
16
+ - the deployers, structs, and interfaces under `src/`
17
+
18
+ ## Summary
19
+
20
+ - Cross-chain identifiers are now modeled for a wider address space. The v6 repo uses `bytes32` where the v5 repo used EVM `address` assumptions.
21
+ - Message handling is versioned instead of implicitly trusting an older fixed format.
22
+ - The anti-spam and fee model changed materially. v5's per-token minimum-bridge assumptions were replaced by a registry-level `toRemoteFee` flow in v6.
23
+ - The old manual add-to-balance mode is gone from the current repo.
24
+ - Celo support is now part of the repo's first-class contract set.
25
+ - The repo moved from the v5 `0.8.23` baseline to `0.8.28`.
26
+
27
+ ## Verified deltas
28
+
29
+ - `IJBSucker.peer()` now returns `bytes32`.
30
+ - `IJBSucker.prepare(...)` now takes a `bytes32 beneficiary`.
31
+ - `Claimed` and `InsertToOutboxTree` changed their `beneficiary` field from `address` to `bytes32`.
32
+ - The `Claimed` event no longer carries the old `autoAddedToBalance` boolean.
33
+ - The public `ADD_TO_BALANCE_MODE()` surface and the manual mode path are gone from the interface.
34
+ - `StaleRootRejected(...)` is a new event on the interface.
35
+
36
+ ## Breaking ABI changes
37
+
38
+ - `JBRemoteToken.addr` changed from `address` to `bytes32`.
39
+ - `JBTokenMapping.remoteToken` changed from `address` to `bytes32`.
40
+ - `JBMessageRoot` gained `version` and changed `token` from `address` to `bytes32`.
41
+ - `IJBSucker.peer()` changed return type.
42
+ - `IJBSucker.prepare(...)` changed parameter type for `beneficiary`.
43
+ - The manual add-to-balance mode surface was removed.
44
+
45
+ ## Indexer impact
46
+
47
+ - `Claimed` and `InsertToOutboxTree` require schema changes because `beneficiary` is no longer an EVM address.
48
+ - Remote token and peer identifiers should be stored as raw 32-byte values.
49
+ - `StaleRootRejected` is new and can be used to monitor out-of-order or duplicate delivery attempts.
50
+
51
+ ## Migration notes
52
+
53
+ - Treat every cross-chain identifier schema as migrated, including indexers and bridge metadata.
54
+ - Rebuild integrations around the current fee and registry model. Old `minBridgeAmount` assumptions are stale.
55
+ - Use the current v6 structs and events for ABI regeneration. This repo has too many widened fields for manual patching to be safe.
56
+
57
+ ## ABI appendix
58
+
59
+ - Changed functions
60
+ - `peer() -> bytes32`
61
+ - `prepare(..., bytes32 beneficiary, ...)`
62
+ - Changed events
63
+ - `Claimed`
64
+ - `InsertToOutboxTree`
65
+ - Added events
66
+ - `StaleRootRejected`
67
+ - Removed surface
68
+ - manual add-to-balance mode / `ADD_TO_BALANCE_MODE()`
69
+ - Changed structs
70
+ - `JBRemoteToken`
71
+ - `JBTokenMapping`
72
+ - `JBMessageRoot`