@bananapus/suckers-v6 0.0.19 → 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.
package/USER_JOURNEYS.md CHANGED
@@ -1,336 +1,72 @@
1
- # nana-suckers-v6 -- User Journeys
1
+ # User Journeys
2
2
 
3
- All user paths through the Juicebox V6 sucker bridging system. For each journey: entry point, key parameters, state changes, events, and edge cases.
3
+ ## Who This Repo Serves
4
4
 
5
- ---
5
+ - projects that want canonical cross-chain movement of project-token positions
6
+ - operators deploying and registering sucker pairs on supported bridge families
7
+ - users bridging a project position from one chain to another
8
+ - teams responsible for bridge fees, token mappings, deprecation, and emergency controls
6
9
 
7
- ## 1. Deploy Suckers
10
+ ## Journey 1: Launch A Cross-Chain Sucker Pair For A Project
8
11
 
9
- **Entry point**: `JBSuckerRegistry.deploySuckersFor(uint256 projectId, bytes32 salt, JBSuckerDeployerConfig[] configurations)`
12
+ **Starting state:** the project exists on multiple chains or plans to, and the team has chosen the bridge family it trusts.
10
13
 
11
- **Who can call**: The project owner, or any address with the project owner's `DEPLOY_SUCKERS` permission.
14
+ **Success:** paired suckers are deployed, registered, and ready to transport claims between the chains they serve.
12
15
 
13
- **Parameters**:
14
- - `projectId` -- The ID of the project to deploy suckers for
15
- - `salt` -- A salt for CREATE2 deterministic deployment. Must be the same value on both chains for the suckers to be peers
16
- - `configurations` -- Array of `JBSuckerDeployerConfig` structs, each containing:
17
- - `deployer` -- An `IJBSuckerDeployer` address that must be on the registry's allowlist
18
- - `mappings` -- Array of `JBTokenMapping` structs for initial token mappings
16
+ **Flow**
17
+ 1. Choose the chain-specific sucker implementation and deployer, such as Arbitrum, OP Stack, Celo, or CCIP.
18
+ 2. Configure token mappings, bridge counterparties, and per-project registry state in `JBSuckerRegistry`.
19
+ 3. Deploy the pair so each side knows its remote peer and expected transport assumptions.
20
+ 4. Frontends and operators can now reason about the bridge as a known project surface instead of ad hoc per-transfer logic.
19
21
 
20
- **State changes**:
21
- 1. Registry enforces `DEPLOY_SUCKERS` permission from the project owner.
22
- 2. Computes `salt = keccak256(abi.encode(_msgSender(), salt))` (sender-specific determinism). Note: the deployer also hashes the salt again with its own `_msgSender()` via `keccak256(abi.encodePacked(_msgSender(), salt))`, so the final CREATE2 salt is double-hashed (registry + deployer).
23
- 3. For each configuration:
24
- - Validates the deployer is in the allowlist; reverts with `JBSuckerRegistry_InvalidDeployer` if not.
25
- - Calls `deployer.createForSender(projectId, salt)` which deploys a clone via CREATE2 and internally calls `initialize(projectId)` on the clone (setting `_localProjectId` and `deployer`). There is no separate `initialize()` call -- it happens inside `createForSender`.
26
- - Stores the sucker address in `_suckersOf[projectId]`.
27
- - Calls `sucker.mapTokens(configuration.mappings)` to set initial token mappings.
28
- 4. Project owner repeats on the remote chain with the **same salt and same sender address** to deploy the matching peer sucker.
22
+ ## Journey 2: Bridge A Position From One Chain To Another
29
23
 
30
- **Events**: `SuckerDeployedFor(projectId, sucker, configuration, caller)` -- One per deployed sucker
24
+ **Starting state:** a user holds project-token exposure on the source chain and wants the corresponding position on the destination chain.
31
25
 
32
- **Edge cases**:
33
- - Reverts with `JBSuckerRegistry_InvalidDeployer` if any deployer is not on the allowlist
34
- - The sender must be the same address on both chains for CREATE2 addresses to match (peer recognition)
35
- - Project owner repeats the call on the remote chain with the **same salt and same sender** to deploy the matching peer
26
+ **Success:** the source position becomes a claim, the claim is relayed, and the destination position is minted after proof verification.
36
27
 
37
- ---
28
+ **Flow**
29
+ 1. The user calls `prepare` on the source-chain sucker to burn or lock the relevant local position into a claimable leaf.
30
+ 2. The source sucker appends that leaf into its Merkle outbox tree.
31
+ 3. Someone relays the new root to the remote chain using `toRemote`.
32
+ 4. The claimant proves inclusion against the remote inbox tree and receives the recreated project-token position there.
38
33
 
39
- ## 2. Map Token
34
+ **Failure cases that matter:** wrong token mappings, transport-layer fee shortages, root ordering mistakes, and assuming the bridge is generic ERC-20 transport when it is really project-position transport.
40
35
 
41
- **Entry point**: `JBSucker.mapToken(JBTokenMapping map)` (payable)
36
+ ## Journey 3: Map Treasury Assets And Project Tokens Correctly Across Chains
42
37
 
43
- **Who can call**: The project owner, or any address with the project owner's `MAP_SUCKER_TOKEN` permission.
38
+ **Starting state:** the project supports multiple assets or wrappers across chains and wants users to bridge without silent economic mismatch.
44
39
 
45
- **Parameters**:
46
- - `map` -- A `JBTokenMapping` struct containing:
47
- - `localToken` -- The local terminal token address (e.g., `NATIVE_TOKEN` or an ERC-20)
48
- - `minGas` -- Minimum gas for bridging; must be >= `MESSENGER_ERC20_MIN_GAS_LIMIT` (200,000) for non-native tokens
49
- - `remoteToken` -- The remote token address as `bytes32`. Set to `bytes32(0)` to disable bridging
40
+ **Success:** the remote claim recreates the intended exposure instead of a superficially similar but economically different asset.
50
41
 
51
- **State changes**:
52
- 1. Validates the emergency hatch is not enabled for the token; reverts with `JBSucker_TokenHasInvalidEmergencyHatchState` if so.
53
- 2. `_validateTokenMapping()` checks native-token and min-gas rules.
54
- 3. Enforces `MAP_SUCKER_TOKEN` permission from the project owner.
55
- 4. Immutability check: if `_remoteTokenFor[token].addr != bytes32(0)` AND the new `remoteToken` differs from the current mapping AND `remoteToken != bytes32(0)` AND `_outboxOf[token].tree.count != 0`, reverts with `JBSucker_TokenAlreadyMapped`. All four conditions must be true for the revert -- notably, the mapping is only considered immutable when both the current remote address is set and the outbox has entries.
56
- 5. If disabling a mapping (`remoteToken == bytes32(0)`) and the outbox has unsent entries, `_sendRoot()` is called first to flush them.
57
- 6. Stores the mapping: `_remoteTokenFor[token] = JBRemoteToken{enabled: true/false, emergencyHatch: false, minGas, addr: remoteToken}`. When disabling, `addr` retains the original remote address for re-enabling.
42
+ **Flow**
43
+ 1. Configure remote token metadata and mapping with the sucker pair.
44
+ 2. Make sure the destination chain can mint or settle the project-token representation the bridge expects.
45
+ 3. Audit chain-specific native-asset handling, especially on Celo or other non-identical environments.
58
46
 
59
- **Events**: None directly from `mapToken`. If a root flush occurs during disable, emits `RootToRemote(root, token, index, nonce, caller)`.
47
+ ## Journey 4: Operate The Bridge Safely Over Time
60
48
 
61
- **Edge cases**:
62
- - Reverts with `JBSucker_TokenAlreadyMapped` if the outbox has entries and remapping to a different remote token
63
- - Reverts with `JBSucker_TokenHasInvalidEmergencyHatchState` if the emergency hatch is already enabled for the token
64
- - Reverts with `JBSucker_InvalidNativeRemoteAddress` if mapping native token to a non-native, non-zero remote
65
- - Reverts with `JBSucker_BelowMinGas` if `minGas < MESSENGER_ERC20_MIN_GAS_LIMIT` for non-native tokens
66
- - Re-enabling a previously disabled mapping (to the same remote token) is supported
67
- - CCIP and Celo suckers override `_validateTokenMapping` to allow native-to-ERC20 mapping
68
- - `msg.value` is used as `transportPayment` when flushing the outbox during disable
49
+ **Starting state:** the bridge is live and now needs operational stewardship rather than just deployment.
69
50
 
70
- **Bulk variant**: `JBSucker.mapTokens(JBTokenMapping[] maps)` -- Maps multiple tokens. Splits `msg.value` evenly across mappings that require a root flush. Refunds remainder from integer division.
51
+ **Success:** fee policy, deprecation, trusted counterparties, and emergency paths remain coherent as conditions change.
71
52
 
72
- ---
53
+ **Flow**
54
+ 1. Use `JBSuckerRegistry` to manage deployer allowlists and shared operational config.
55
+ 2. Watch fee fallback paths and transport assumptions because delivery failure is part of the intended threat model.
56
+ 3. Use deprecation or emergency surfaces when a bridge family or remote destination should no longer be used.
73
57
 
74
- ## 3. Prepare (Bridge Out)
58
+ ## Journey 5: Recover Value Through The Emergency Hatch When Normal Delivery Breaks
75
59
 
76
- **Entry point**: `JBSucker.prepare(uint256 projectTokenCount, bytes32 beneficiary, uint256 minTokensReclaimed, address token)`
60
+ **Starting state:** a claim cannot complete through the normal inbox or remote-delivery path.
77
61
 
78
- **Who can call**: Anyone. The caller must have approved the sucker to transfer `projectTokenCount` of their project ERC-20 tokens.
62
+ **Success:** users can recover through the explicit emergency mechanism without double-spending the same claim.
79
63
 
80
- **Parameters**:
81
- - `projectTokenCount` -- Number of project tokens to cash out and bridge
82
- - `beneficiary` -- Recipient on the remote chain (`bytes32` for cross-VM compatibility; EVM addresses are left-padded to 32 bytes, Solana uses full 32-byte public keys)
83
- - `minTokensReclaimed` -- Minimum terminal tokens to receive from the cash out (slippage protection)
84
- - `token` -- The terminal token to cash out into (e.g., `NATIVE_TOKEN` or an ERC-20)
64
+ **Flow**
65
+ 1. Enable or enter the emergency mode the sucker pair exposes for the affected path.
66
+ 2. Use `exitThroughEmergencyHatch(...)` with the relevant claim data.
67
+ 3. Treat emergency execution slots as distinct state that still must not allow the same economic position to be claimed twice.
85
68
 
86
- **State changes**:
87
- 1. Validates `beneficiary != bytes32(0)`; reverts with `JBSucker_ZeroBeneficiary` if zero.
88
- 2. Validates the project has a deployed ERC-20 token; reverts with `JBSucker_ZeroERC20Token` if not.
89
- 3. Validates `_remoteTokenFor[token].enabled == true`; reverts with `JBSucker_TokenNotMapped` if disabled.
90
- 4. Validates sucker state is `ENABLED` or `DEPRECATION_PENDING`; reverts with `JBSucker_Deprecated` otherwise.
91
- 5. Transfers `projectTokenCount` project tokens from caller to the sucker via `safeTransferFrom`.
92
- 6. `_pullBackingAssets()`: calls `terminal.cashOutTokensOf()` with `beneficiary: payable(address(this))` (the sucker itself receives the reclaimed tokens) at 0% cashOutTaxRate (set by JBOmnichainDeployer as data hook). Records the reclaimed amount and asserts the balance delta matches.
93
- 7. `_insertIntoTree()`: builds a leaf hash, inserts into `_outboxOf[token].tree`, and increments `_outboxOf[token].balance`.
69
+ ## Hand-Offs
94
70
 
95
- **Events**: `InsertToOutboxTree(beneficiary, token, hashed, index, root, projectTokenCount, terminalTokenAmount, caller)`
96
-
97
- **Edge cases**:
98
- - Reverts with `JBSucker_ZeroBeneficiary` if `beneficiary == bytes32(0)`
99
- - Reverts with `JBSucker_ZeroERC20Token` if the project has no deployed ERC-20 token
100
- - Reverts with `JBSucker_TokenNotMapped` if the token mapping is not enabled
101
- - Reverts with `JBSucker_Deprecated` if sucker state is `SENDING_DISABLED` or `DEPRECATED`
102
- - Reverts with `JBSucker_AmountExceedsUint128` if `terminalTokenAmount` or `projectTokenCount` exceeds `uint128` (SVM compatibility)
103
- - The project tokens are burned via the cash out, and the backing assets are held by the sucker until `toRemote()` is called
104
-
105
- ---
106
-
107
- ## 4. Bridge (toRemote)
108
-
109
- **Entry point**: `JBSucker.toRemote(address token)` (payable)
110
-
111
- **Who can call**: Anyone. Typically called by a relayer. Requires `msg.value >= REGISTRY.toRemoteFee()` plus any bridge-specific transport payment.
112
-
113
- **Parameters**:
114
- - `token` -- The terminal token whose outbox tree root and backing assets should be sent to the remote chain
115
-
116
- **State changes**:
117
- 1. Validates emergency hatch is not enabled for the token; reverts with `JBSucker_TokenHasInvalidEmergencyHatchState`.
118
- 2. Validates the outbox has something to send; reverts with `JBSucker_NothingToSend` if `outbox.balance == 0 && outbox.tree.count == outbox.numberOfClaimsSent`.
119
- 3. Validates `msg.value >= REGISTRY.toRemoteFee()`; reverts with `JBSucker_InsufficientMsgValue` if insufficient. This check is a hard revert -- it happens before any best-effort logic.
120
- 4. Fee deduction: deducts `toRemoteFee` from `msg.value` to compute `transportPayment = msg.value - toRemoteFee`. Then attempts to pay the fee into the fee project (ID 1) via `terminal.pay()`. The fee payment itself is best-effort: if the fee project has no native-token terminal or `pay()` reverts (via try-catch), the fee is returned to `transportPayment` and the call proceeds. Only the bridge transport cost uses the remaining `transportPayment`.
121
- 5. `_sendRoot()`:
122
- - Reads `outbox.tree.count` and `outbox.balance`.
123
- - Clears `outbox.balance = 0`.
124
- - Increments `outbox.nonce`.
125
- - Computes `outbox.tree.root()`.
126
- - Sets `outbox.numberOfClaimsSent = tree.count`.
127
- 6. `_sendRootOverAMB()` (chain-specific): bridges assets and merkle root message to the remote peer.
128
- - **OP Stack**: `OPMESSENGER.sendMessage{value: amount}()` bridges ETH and encodes `JBSucker.fromRemote(messageRoot)`
129
- - **Arbitrum**: Two retryable tickets -- one for ERC-20 via gateway router, one for the merkle root message via inbox
130
- - **CCIP**: Wraps native ETH to WETH, calls `CCIP_ROUTER.ccipSend()` with token amounts and message data
131
-
132
- **Events**: `RootToRemote(root, token, index, nonce, caller)`
133
-
134
- **Edge cases**:
135
- - Reverts with `JBSucker_TokenHasInvalidEmergencyHatchState` if the emergency hatch is enabled for this token
136
- - Reverts with `JBSucker_NothingToSend` if `outbox.balance == 0 && outbox.tree.count == outbox.numberOfClaimsSent`
137
- - Reverts with `JBSucker_InsufficientMsgValue` if `msg.value < REGISTRY.toRemoteFee()`
138
- - Reverts with `JBSucker_Deprecated` if sucker state is `SENDING_DISABLED` or `DEPRECATED`
139
- - If the outbox tree is empty (`count == 0`), `_sendRoot` returns early without sending
140
- - The `toRemoteFee` is global across all suckers, set by the registry owner via `setToRemoteFee()`, capped at `MAX_TO_REMOTE_FEE` (0.001 ether)
141
- - Transport payment (0 for OP bridges, non-zero for Arbitrum L1->L2 and CCIP) is the remainder after fee deduction
142
-
143
- ---
144
-
145
- ## 5. Receive Root (fromRemote)
146
-
147
- **Entry point**: `JBSucker.fromRemote(JBMessageRoot root)` (payable)
148
-
149
- **Who can call**: Only the authenticated bridge messenger representing the remote peer. Validated via `_isRemotePeer(_msgSender())`.
150
-
151
- **Parameters**:
152
- - `root` -- A `JBMessageRoot` struct containing:
153
- - `version` -- Message format version (must equal `MESSAGE_VERSION`, currently 1)
154
- - `token` -- The remote token address as `bytes32` (converted to local address for inbox lookup)
155
- - `amount` -- The amount of terminal tokens being delivered
156
- - `remoteRoot` -- A `JBInboxTreeRoot` with `nonce` and `root` (the merkle root)
157
-
158
- **State changes**:
159
- 1. If `root.remoteRoot.nonce > inbox.nonce` AND state is not `DEPRECATED`:
160
- 1. `_inboxOf[localToken].nonce = root.remoteRoot.nonce` -- Updates the inbox nonce
161
- 2. `_inboxOf[localToken].root = root.remoteRoot.root` -- Updates the inbox merkle root
162
-
163
- **Events**:
164
- - On success: `NewInboxTreeRoot(token, nonce, root, caller)`
165
- - On rejection: `StaleRootRejected(token, receivedNonce, currentNonce)` -- emitted if the nonce is not newer OR if the sucker is `DEPRECATED` (even with a valid newer nonce, deprecated suckers reject new roots to prevent double-spend with emergency hatch withdrawals)
166
-
167
- **Edge cases**:
168
- - Reverts with `JBSucker_NotPeer` if the sender is not the authenticated remote peer
169
- - Reverts with `JBSucker_InvalidMessageVersion` if `root.version != MESSAGE_VERSION`
170
- - Does NOT revert for stale nonces -- emits `StaleRootRejected` instead (because reverting could lose native tokens delivered with the message)
171
- - Accepts roots for unmapped tokens (rejecting would permanently lose bridged tokens; future mapping enables claims)
172
- - Nonce gaps are expected -- some bridges (e.g., CCIP) do not guarantee in-order delivery
173
- - Deprecated suckers reject new roots to prevent double-spend (project owner may have enabled emergency hatch for local withdrawals)
174
-
175
- ---
176
-
177
- ## 6. Claim (Bridge In)
178
-
179
- **Entry point**: `JBSucker.claim(JBClaim claimData)` or `JBSucker.claim(JBClaim[] claims)` for batch
180
-
181
- **Who can call**: Anyone. The beneficiary receives the tokens regardless of who calls.
182
-
183
- **Parameters**:
184
- - `claimData` -- A `JBClaim` struct containing:
185
- - `token` -- The local terminal token address
186
- - `leaf` -- A `JBLeaf` struct with `index`, `beneficiary` (bytes32), `projectTokenCount`, `terminalTokenAmount`
187
- - `proof` -- A `bytes32[32]` merkle proof (32 = `_TREE_DEPTH`)
188
-
189
- **State changes**:
190
- 1. `_validate()`:
191
- 1. `_executedFor[token].get(index)` -- Checks leaf not already claimed
192
- 2. `_executedFor[token].set(index)` -- Marks leaf as claimed
193
- 3. Computes `MerkleLib.branchRoot(leafHash, proof, index)` and compares to `_inboxOf[token].root`
194
- 2. `_handleClaim()`:
195
- 1. If `terminalTokenAmount > 0`: calls `terminal.addToBalanceOf{value: amount}(projectId, token, amount, false, "", "")` -- Adds backing assets to the project's terminal balance
196
- 2. `controller.mintTokensOf(projectId, projectTokenCount, beneficiary, "", false)` -- Mints project tokens for the beneficiary with `useReservedPercent = false` (suckers bypass reserved percent)
197
-
198
- **Events**: `Claimed(beneficiary, token, projectTokenCount, terminalTokenAmount, index, caller)`
199
-
200
- **Edge cases**:
201
- - Reverts with `JBSucker_LeafAlreadyExecuted` if the leaf at `index` was already claimed
202
- - Reverts with `JBSucker_InvalidProof` if the merkle proof does not match the inbox root
203
- - Reverts if the project's controller is misconfigured or the project doesn't exist on the destination chain (permanently blocks claims -- a deployment concern)
204
- - Off-chain: a relayer must compute the merkle proof from `InsertToOutboxTree` events on the source chain
205
- - If nonces arrive out of order, users need regenerated proofs against the current root (the merkle tree is append-only, so all leaves remain provable)
206
- - Batch `claim(JBClaim[])` simply loops over each claim
207
-
208
- ---
209
-
210
- ## 7. Deprecate Sucker
211
-
212
- **Entry point**: `JBSucker.setDeprecation(uint40 timestamp)`
213
-
214
- **Who can call**: The project owner, or any address with the project owner's `SET_SUCKER_DEPRECATION` permission.
215
-
216
- **Parameters**:
217
- - `timestamp` -- The time after which the sucker is deprecated. Must be `0` (cancel deprecation) or `>= block.timestamp + _maxMessagingDelay()` (14 days minimum). Type is `uint40`.
218
-
219
- **State changes**:
220
- 1. `deprecatedAfter = timestamp` -- Sets or clears the deprecation timestamp
221
-
222
- **Events**: `DeprecationTimeUpdated(timestamp, caller)`
223
-
224
- **State progression over time**:
225
- - **Now -> timestamp - 14 days**: `DEPRECATION_PENDING`. All operations work normally. Users are warned.
226
- - **timestamp - 14 days -> timestamp**: `SENDING_DISABLED`. `prepare()` and `toRemote()` revert. `fromRemote()` still accepts roots. `claim()` still works. `exitThroughEmergencyHatch()` works (even without enabling the emergency hatch per-token).
227
- - **After timestamp**: `DEPRECATED`. `fromRemote()` rejects new roots. `claim()` still works for existing inbox roots. `exitThroughEmergencyHatch()` works (even without enabling the emergency hatch per-token).
228
-
229
- **Edge cases**:
230
- - Reverts with `JBSucker_Deprecated` if state is already `SENDING_DISABLED` or `DEPRECATED`
231
- - Reverts with `JBSucker_DeprecationTimestampTooSoon` if `timestamp != 0` and `timestamp < block.timestamp + _maxMessagingDelay()`
232
- - Cancellation: call `setDeprecation(0)` before reaching `SENDING_DISABLED` state
233
-
234
- **Registry cleanup**: Anyone calls `JBSuckerRegistry.removeDeprecatedSucker(uint256 projectId, address sucker)` after the sucker reaches `DEPRECATED` state. This removes the sucker from `_suckersOf[projectId]` and emits `SuckerDeprecated(projectId, sucker, caller)`. Reverts with `JBSuckerRegistry_SuckerDoesNotBelongToProject` if the sucker is not registered, or `JBSuckerRegistry_SuckerIsNotDeprecated` if not yet deprecated.
235
-
236
- ---
237
-
238
- ## 8. Enable Emergency Hatch
239
-
240
- **Entry point**: `JBSucker.enableEmergencyHatchFor(address[] tokens)`
241
-
242
- **Who can call**: The project owner, or any address with the project owner's `SUCKER_SAFETY` permission.
243
-
244
- **Parameters**:
245
- - `tokens` -- Array of terminal token addresses to enable the emergency hatch for
246
-
247
- **State changes**:
248
- 1. For each token:
249
- 1. `_remoteTokenFor[token].enabled = false` -- Disables bridging for the token
250
- 2. `_remoteTokenFor[token].emergencyHatch = true` -- Enables emergency exit
251
-
252
- **Events**: `EmergencyHatchOpened(tokens, caller)`
253
-
254
- **Edge cases**:
255
- - **Irreversible**: Once the emergency hatch is enabled for a token, it cannot be disabled
256
- - No explicit revert for empty array (no-op)
257
- - Does not require the sucker to be in any particular state
258
-
259
- ---
260
-
261
- ## 9. Exit Through Emergency Hatch
262
-
263
- **Entry point**: `JBSucker.exitThroughEmergencyHatch(JBClaim claimData)`
264
-
265
- **Who can call**: Anyone. The beneficiary receives the tokens regardless of who calls.
266
-
267
- **Parameters**:
268
- - `claimData` -- A `JBClaim` struct containing:
269
- - `token` -- The local terminal token address
270
- - `leaf` -- A `JBLeaf` struct with `index`, `beneficiary` (bytes32), `projectTokenCount`, `terminalTokenAmount`
271
- - `proof` -- A `bytes32[32]` merkle proof against the **outbox** tree root (not inbox)
272
-
273
- **State changes**:
274
- 1. `_validateForEmergencyExit()`:
275
- 1. Checks emergency hatch is enabled for the token (or sucker is `DEPRECATED`/`SENDING_DISABLED`)
276
- 2. Checks `index >= numberOfClaimsSent` or `numberOfClaimsSent == 0` (the leaf was NOT sent to the remote peer)
277
- 3. Uses a separate bitmap slot (`address(bytes20(keccak256(abi.encode(token))))`) to prevent collision with regular claims
278
- 4. Marks the leaf as executed in the emergency exit bitmap
279
- 5. Validates the merkle proof against `_outboxOf[token].tree.root()`
280
- 2. `_outboxOf[token].balance -= terminalTokenAmount` -- Decreases the outbox balance
281
- 3. `_handleClaim()`:
282
- 1. If `terminalTokenAmount > 0`: `terminal.addToBalanceOf(projectId, token, amount, ...)` -- Adds tokens back to project balance
283
- 2. `controller.mintTokensOf(projectId, projectTokenCount, beneficiary, "", false)` -- Mints project tokens for the beneficiary
284
-
285
- **Events**: `EmergencyExit(beneficiary, token, terminalTokenAmount, projectTokenCount, caller)`
286
-
287
- **Edge cases**:
288
- - Reverts with `JBSucker_TokenHasInvalidEmergencyHatchState` if emergency hatch is not enabled AND sucker is not `DEPRECATED`/`SENDING_DISABLED`
289
- - Reverts with `JBSucker_LeafAlreadyExecuted` if `index < numberOfClaimsSent` (leaf was already sent to remote peer) or if already emergency-exited
290
- - Reverts with `JBSucker_InvalidProof` if the merkle proof does not match the outbox root
291
- - **Important limitation**: If `_sendRoot()` was called (incrementing `numberOfClaimsSent`) but the bridge message was never delivered, leaves with `index < numberOfClaimsSent` are blocked from emergency exit even though they cannot be claimed remotely. This is a conservative failure mode (funds locked, not double-spent). The deprecation flow provides an alternative exit path.
292
- - Only leaves NOT already sent to the remote peer can be emergency-exited
293
-
294
- ---
295
-
296
- ## 10. Register Sucker Deployer
297
-
298
- **Entry point**: `JBSuckerRegistry.allowSuckerDeployer(address deployer)` or `JBSuckerRegistry.allowSuckerDeployers(address[] deployers)` for batch
299
-
300
- **Who can call**: Only the registry `owner` (Ownable). Initially JuiceboxDAO / project #1.
301
-
302
- **Parameters**:
303
- - `deployer` -- The address of the sucker deployer contract to add to the allowlist
304
- - `deployers` (batch variant) -- Array of deployer addresses
305
-
306
- **State changes**:
307
- 1. `suckerDeployerIsAllowed[deployer] = true` -- Adds the deployer to the allowlist
308
-
309
- **Events**: `SuckerDeployerAllowed(deployer, caller)` -- One per deployer
310
-
311
- **Edge cases**:
312
- - Reverts with `OwnableUnauthorizedAccount` if caller is not the owner
313
- - Idempotent: calling again on an already-allowed deployer is a no-op (re-sets to `true`)
314
-
315
- **Removing a deployer**: `JBSuckerRegistry.removeSuckerDeployer(address deployer)` -- Sets `suckerDeployerIsAllowed[deployer] = false`. Emits `SuckerDeployerRemoved(deployer, caller)`. Existing suckers deployed by this deployer continue operating.
316
-
317
- ---
318
-
319
- ## 11. Set toRemote Fee
320
-
321
- **Entry point**: `JBSuckerRegistry.setToRemoteFee(uint256 fee)`
322
-
323
- **Who can call**: Only the registry `owner` (Ownable).
324
-
325
- **Parameters**:
326
- - `fee` -- The new ETH fee in wei, paid into the fee project on each `toRemote()` call
327
-
328
- **State changes**:
329
- 1. `toRemoteFee = fee` -- Updates the global fee
330
-
331
- **Events**: `ToRemoteFeeChanged(oldFee, fee, caller)`
332
-
333
- **Edge cases**:
334
- - Reverts with `JBSuckerRegistry_FeeExceedsMax` if `fee > MAX_TO_REMOTE_FEE` (0.001 ether)
335
- - The fee is initialized to `MAX_TO_REMOTE_FEE` in the constructor
336
- - Setting to 0 effectively disables the fee
71
+ - Use [nana-omnichain-deployers-v6](../nana-omnichain-deployers-v6/USER_JOURNEYS.md) when a project wants suckers packaged into its launch flow instead of deployed separately.
72
+ - Use [nana-core-v6](../nana-core-v6/USER_JOURNEYS.md) or [revnet-core-v6](../revnet-core-v6/USER_JOURNEYS.md) for the treasury and runtime project behavior that suckers transport across chains.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/suckers-v6",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,9 +19,8 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@arbitrum/nitro-contracts": "^1.2.1",
22
- "@bananapus/address-registry-v6": "^0.0.16",
23
- "@bananapus/core-v6": "^0.0.28",
24
- "@bananapus/permission-ids-v6": "^0.0.14",
22
+ "@bananapus/core-v6": "^0.0.31",
23
+ "@bananapus/permission-ids-v6": "^0.0.15",
25
24
  "@chainlink/contracts-ccip": "^1.5.0",
26
25
  "@chainlink/local": "github:smartcontractkit/chainlink-local",
27
26
  "@openzeppelin/contracts": "^5.6.1",
@@ -0,0 +1,25 @@
1
+ # Suckers Operations
2
+
3
+ ## Configuration Surface
4
+
5
+ - [`src/JBSuckerRegistry.sol`](../src/JBSuckerRegistry.sol) is the first stop for deployer allowlists, shared fees, project inventory, and deprecation helpers.
6
+ - Transport-specific deployers in [`src/deployers/`](../src/deployers/) are where chain-specific constants and bridge addresses live.
7
+ - [`script/`](../script/) is where deployment-time environment wiring belongs.
8
+
9
+ ## Change Checklist
10
+
11
+ - If you edit base sucker accounting, verify claim flow across at least one chain-specific implementation.
12
+ - If you edit token mapping logic, re-check the registry and deployer assumptions that feed it.
13
+ - If you edit deprecation or emergency paths, verify the intended operator workflow still works end to end.
14
+ - If you touch bridge-specific code, confirm whether the real bug is transport-side or shared accounting-side.
15
+
16
+ ## Common Failure Modes
17
+
18
+ - Cross-chain issue is blamed on transport when the root or token mapping was wrong before message delivery.
19
+ - Registry configuration drifts from what a deployer or external operator expects.
20
+ - Emergency hatches or deprecation paths are stale because nobody exercises them until stress conditions arrive.
21
+
22
+ ## Useful Proof Points
23
+
24
+ - [`test/audit/`](../test/audit/) for security-sensitive assumptions.
25
+ - [`script/helpers/`](../script/helpers/) when the problem is deployment wiring rather than runtime logic.
@@ -0,0 +1,28 @@
1
+ # Suckers Runtime
2
+
3
+ ## Core Roles
4
+
5
+ - [`src/JBSucker.sol`](../src/JBSucker.sol) owns the shared prepare, relay, claim, token-mapping, and lifecycle logic.
6
+ - [`src/JBSuckerRegistry.sol`](../src/JBSuckerRegistry.sol) owns project-to-sucker inventory, deployer allowlists, and shared remote-fee settings.
7
+ - Chain-specific sucker contracts under [`src/`](../src/) own the transport-specific message delivery and verification path.
8
+ - Matching deployers under [`src/deployers/`](../src/deployers/) own clone and transport configuration.
9
+
10
+ ## Runtime Path
11
+
12
+ 1. Local state is prepared into a claimable Merkle leaf.
13
+ 2. A root is relayed to the peer chain through the bridge-specific transport.
14
+ 3. The remote side records the root in its inbox state.
15
+ 4. Claimants prove inclusion and recreate their position on the destination chain.
16
+
17
+ ## High-Risk Areas
18
+
19
+ - Token mapping: mapping mistakes break economic equivalence, not just UX.
20
+ - Root ordering and replay protection: message sequencing is part of correctness.
21
+ - Emergency and deprecation paths: these are operational safety surfaces that must remain reliable.
22
+ - Shared accounting vs transport logic: many incidents stem from confusing these layers.
23
+
24
+ ## Tests To Trust First
25
+
26
+ - [`test/fork/`](../test/fork/) for real transport assumptions.
27
+ - [`test/regression/`](../test/regression/) for pinned cross-chain edge cases.
28
+ - [`test/`](../test/) broadly when the bug could involve base logic, registry behavior, or a specific bridge implementation.
@@ -5,8 +5,7 @@ import {IInbox} from "@arbitrum/nitro-contracts/src/bridge/IInbox.sol";
5
5
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
6
6
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
7
7
  import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
8
- // forge-lint: disable-next-line(unaliased-plain-import)
9
- import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
8
+ import {CoreDeployment, CoreDeploymentLib} from "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
10
9
  import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
11
10
  import {Sphinx} from "@sphinx-labs/contracts/contracts/foundry/SphinxPlugin.sol";
12
11
  import {Script} from "forge-std/Script.sol";
@@ -132,12 +131,13 @@ contract DeployScript is Script, Sphinx {
132
131
  _arbitrumSucker();
133
132
  _ccipSucker();
134
133
 
135
- if (!registryAlreadyDeployed) {
136
- // Before transferring ownership to JBDAO we approve the deployers.
137
- if (PRE_APPROVED_DEPLOYERS.length != 0) {
138
- REGISTRY.allowSuckerDeployers(PRE_APPROVED_DEPLOYERS);
139
- }
134
+ // Synchronize any deployers discovered or resumed during this run into the registry as long as the
135
+ // current safe still controls it. This keeps partial-deployment recovery idempotent.
136
+ if (PRE_APPROVED_DEPLOYERS.length != 0 && Ownable(address(REGISTRY)).owner() == safeAddress()) {
137
+ REGISTRY.allowSuckerDeployers(PRE_APPROVED_DEPLOYERS);
138
+ }
140
139
 
140
+ if (!registryAlreadyDeployed) {
141
141
  // Check what safe this is, if this is the same one as the fee-project owner, then we do not need to
142
142
  // transfer. If its not then we transfer to the fee-project safe.
143
143
  // NOTE: If this is ran after the configuration of the fee-project, this would transfer it to the
@@ -87,9 +87,11 @@ contract JBCeloSucker is JBOptimismSucker {
87
87
  uint256 _projectId = projectId();
88
88
 
89
89
  // Unwrap WETH → native ETH.
90
+ // slither-disable-next-line calls-loop
90
91
  WRAPPED_NATIVE.withdraw(amount);
91
92
 
92
93
  // Get the project's primary terminal for native token.
94
+ // slither-disable-next-line calls-loop
93
95
  IJBTerminal terminal = DIRECTORY.primaryTerminalOf({projectId: _projectId, token: JBConstants.NATIVE_TOKEN});
94
96
 
95
97
  if (address(terminal) == address(0)) {
@@ -97,7 +99,7 @@ contract JBCeloSucker is JBOptimismSucker {
97
99
  }
98
100
 
99
101
  // Add native ETH to the project's balance.
100
- // slither-disable-next-line arbitrary-send-eth
102
+ // slither-disable-next-line arbitrary-send-eth,calls-loop
101
103
  terminal.addToBalanceOf{value: amount}({
102
104
  projectId: _projectId,
103
105
  token: JBConstants.NATIVE_TOKEN,
@@ -140,7 +142,7 @@ contract JBCeloSucker is JBOptimismSucker {
140
142
  if (amount != 0) {
141
143
  if (token == JBConstants.NATIVE_TOKEN) {
142
144
  // Wrap native ETH → WETH so it can be bridged as ERC-20.
143
- // slither-disable-next-line arbitrary-send-eth
145
+ // slither-disable-next-line arbitrary-send-eth,calls-loop
144
146
  WRAPPED_NATIVE.deposit{value: amount}();
145
147
 
146
148
  // Approve the bridge to spend the WETH.
package/src/JBSucker.sol CHANGED
@@ -712,7 +712,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
712
712
  // transportPayment.
713
713
  IJBTerminal terminal = DIRECTORY.primaryTerminalOf({projectId: FEE_PROJECT_ID, token: JBConstants.NATIVE_TOKEN});
714
714
  if (address(terminal) != address(0)) {
715
- // slither-disable-next-line unused-return
715
+ // slither-disable-next-line unused-return,reentrancy-events
716
716
  try terminal.pay{value: _toRemoteFee}({
717
717
  projectId: FEE_PROJECT_ID,
718
718
  token: JBConstants.NATIVE_TOKEN,
@@ -726,12 +726,12 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
726
726
  ) {}
727
727
  catch {
728
728
  // Fee payment failed — fee ETH stays in this contract, transportPayment unchanged.
729
- // The retained ETH is recoverable: it increases `amountToAddToBalanceOf(NATIVE_TOKEN)`,
730
- // so the next `claim(NATIVE_TOKEN)` call will sweep it into the project's terminal balance.
729
+ // There is no dedicated sweep path for this retained ETH. This is an accepted tradeoff
730
+ // to avoid DoS on zero-cost bridges that revert on non-zero transport payment.
731
731
  }
732
732
  }
733
733
  // If no terminal exists, fee ETH stays in this contract. transportPayment is already correct.
734
- // Same recovery path applies: retained fee ETH flows to the project via `claim`.
734
+ // This retained ETH is an accepted stuck-funds edge case; later `claim` calls do not sweep it.
735
735
 
736
736
  // Send the merkle root to the remote chain.
737
737
  _sendRoot({transportPayment: transportPayment, token: token, remoteToken: remoteToken});
@@ -187,6 +187,8 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
187
187
 
188
188
  /// @notice Deploy one or more suckers for the specified project.
189
189
  /// @dev The caller must be the project's owner or have `JBPermissionIds.DEPLOY_SUCKERS` from the project's owner.
190
+ /// Each newly created sucker is immediately configured by calling `mapTokens`, so successful execution also
191
+ /// depends on this registry being authorized to perform `MAP_SUCKER_TOKEN` for the project.
190
192
  /// @param projectId The ID of the project to deploy suckers for.
191
193
  /// @param salt The salt used to deploy the contract. For the suckers to be peers, this must be the same value on
192
194
  /// each chain where suckers are deployed.
@@ -209,7 +211,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
209
211
  suckers = new address[](configurations.length);
210
212
 
211
213
  // Calculate the salt using the sender's address and the provided `salt`.
212
- // This means that for suckers to be peers, the sender has to be the same on each chain.
214
+ // This is an intentional part of the same-address peer invariant: if projects deploy suckers from
215
+ // different sender addresses on different chains, the resulting sucker addresses will differ and the
216
+ // default peer symmetry assumption will not hold.
213
217
  salt = keccak256(abi.encode(_msgSender(), salt));
214
218
 
215
219
  // Iterate through the configurations and deploy the suckers.
@@ -91,6 +91,8 @@ interface IJBSuckerRegistry {
91
91
  function allowSuckerDeployers(address[] calldata deployers) external;
92
92
 
93
93
  /// @notice Deploy one or more suckers for the specified project.
94
+ /// @dev This call also applies each configuration's token mappings on the deployed suckers. Projects using this
95
+ /// path need the registry to be able to satisfy the corresponding `MAP_SUCKER_TOKEN` checks.
94
96
  /// @param projectId The ID of the project to deploy suckers for.
95
97
  /// @param salt The salt used for deterministic deployment.
96
98
  /// @param configurations The deployer configs to use.
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity ^0.8.0;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  /// @notice Global constants used across Juicebox contracts.
5
5
  library ARBAddresses {
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity ^0.8.0;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  /// @notice Global constants used across Juicebox contracts.
5
5
  library ARBChains {