@bananapus/suckers-v6 0.0.13 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/ADMINISTRATION.md +14 -3
  2. package/ARCHITECTURE.md +67 -17
  3. package/AUDIT_INSTRUCTIONS.md +97 -1
  4. package/CHANGE_LOG.md +14 -2
  5. package/README.md +17 -26
  6. package/RISKS.md +23 -6
  7. package/SKILLS.md +46 -9
  8. package/STYLE_GUIDE.md +2 -2
  9. package/USER_JOURNEYS.md +245 -156
  10. package/foundry.toml +1 -1
  11. package/package.json +3 -3
  12. package/script/Deploy.s.sol +31 -2
  13. package/script/helpers/SuckerDeploymentLib.sol +6 -6
  14. package/src/JBArbitrumSucker.sol +15 -12
  15. package/src/JBBaseSucker.sol +1 -1
  16. package/src/JBCCIPSucker.sol +1 -1
  17. package/src/JBCeloSucker.sol +1 -1
  18. package/src/JBOptimismSucker.sol +1 -1
  19. package/src/JBSucker.sol +24 -7
  20. package/src/JBSuckerRegistry.sol +1 -1
  21. package/src/deployers/JBArbitrumSuckerDeployer.sol +1 -1
  22. package/src/deployers/JBBaseSuckerDeployer.sol +1 -1
  23. package/src/deployers/JBCCIPSuckerDeployer.sol +1 -1
  24. package/src/deployers/JBCeloSuckerDeployer.sol +1 -1
  25. package/src/deployers/JBOptimismSuckerDeployer.sol +1 -1
  26. package/src/deployers/JBSuckerDeployer.sol +1 -1
  27. package/src/libraries/CCIPHelper.sol +1 -1
  28. package/src/utils/MerkleLib.sol +1 -1
  29. package/test/Fork.t.sol +1 -1
  30. package/test/ForkArbitrum.t.sol +1 -1
  31. package/test/ForkCelo.t.sol +1 -1
  32. package/test/ForkClaim.t.sol +1 -1
  33. package/test/ForkMainnet.t.sol +1 -1
  34. package/test/ForkOPStack.t.sol +1 -1
  35. package/test/SuckerDeepAttacks.t.sol +5 -4
  36. package/test/audit/ArbitrumL2ToRemoteFeeDoS.t.sol +120 -0
  37. package/test/audit/CodexNemesisPoC.t.sol +169 -0
  38. package/test/fork/OptimismSuckerFork.t.sol +457 -0
  39. package/test/unit/ccip_refund.t.sol +1 -1
package/USER_JOURNEYS.md CHANGED
@@ -1,247 +1,336 @@
1
- # User Journeys
1
+ # nana-suckers-v6 -- User Journeys
2
2
 
3
- Step-by-step flows for every major user interaction with the sucker bridging system.
3
+ All user paths through the Juicebox V6 sucker bridging system. For each journey: entry point, key parameters, state changes, events, and edge cases.
4
+
5
+ ---
4
6
 
5
7
  ## 1. Deploy Suckers
6
8
 
7
- A project owner deploys a pair of suckers to bridge tokens between Ethereum and Optimism.
9
+ **Entry point**: `JBSuckerRegistry.deploySuckersFor(uint256 projectId, bytes32 salt, JBSuckerDeployerConfig[] configurations)`
8
10
 
9
- **Actors:** Project owner, JBSuckerRegistry, JBOptimismSuckerDeployer
11
+ **Who can call**: The project owner, or any address with the project owner's `DEPLOY_SUCKERS` permission.
10
12
 
11
- **Steps:**
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
12
19
 
13
- 1. Registry owner has previously called `JBSuckerRegistry.allowSuckerDeployer(optimismDeployerAddress)`.
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.
14
29
 
15
- 2. Project owner calls `JBSuckerRegistry.deploySuckersFor(projectId, salt, configurations)` on Ethereum.
16
- - `salt` must be the same value on both chains for CREATE2 address matching.
17
- - `configurations` contains one entry: `JBSuckerDeployerConfig{deployer: optimismDeployer, mappings: [...]}`.
30
+ **Events**: `SuckerDeployedFor(projectId, sucker, configuration, caller)` -- One per deployed sucker
18
31
 
19
- 3. Registry enforces `DEPLOY_SUCKERS` permission from the project owner.
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
20
36
 
21
- 4. Registry computes `salt = keccak256(abi.encode(_msgSender(), salt))` (sender-specific determinism).
37
+ ---
22
38
 
23
- 5. For each configuration:
24
- - Validates the deployer is in the allowlist.
25
- - Calls `deployer.createForSender(projectId, salt)` which deploys a clone of `JBOptimismSucker` via CREATE2.
26
- - The clone's `initialize(projectId)` is called, setting `_localProjectId` and `deployer`.
27
- - Registry stores the sucker address in `_suckersOf[projectId]`.
28
- - Calls `sucker.mapTokens(configuration.mappings)` to set initial token mappings.
39
+ ## 2. Map Token
29
40
 
30
- 6. Project owner repeats step 2 on Optimism with the **same salt and same sender address** to deploy the matching peer sucker.
41
+ **Entry point**: `JBSucker.mapToken(JBTokenMapping map)` (payable)
31
42
 
32
- **Result:** Two suckers exist at matching CREATE2 addresses. Each recognizes the other as its `peer()`. Token mappings are configured for bridging.
43
+ **Who can call**: The project owner, or any address with the project owner's `MAP_SUCKER_TOKEN` permission.
33
44
 
34
- ## 2. Map Token
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
35
50
 
36
- A project owner maps a local terminal token to its remote counterpart.
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.
37
58
 
38
- **Actors:** Project owner, JBSucker
59
+ **Events**: None directly from `mapToken`. If a root flush occurs during disable, emits `RootToRemote(root, token, index, nonce, caller)`.
39
60
 
40
- **Steps:**
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
41
69
 
42
- 1. Project owner calls `JBSucker.mapToken(JBTokenMapping{localToken: USDC, minGas: 200_000, remoteToken: bytes32(remoteUSDC)})`.
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.
43
71
 
44
- 2. The sucker enforces `MAP_SUCKER_TOKEN` permission from the project owner.
72
+ ---
45
73
 
46
- 3. `_validateTokenMapping()` checks:
47
- - For non-native tokens: `minGas >= MESSENGER_ERC20_MIN_GAS_LIMIT` (200,000).
48
- - For native tokens (base class): remote must be `NATIVE_TOKEN` or `bytes32(0)`.
49
- - CCIP/Celo suckers override to allow native-to-ERC20 mapping.
74
+ ## 3. Prepare (Bridge Out)
50
75
 
51
- 4. Immutability check: if `_outboxOf[USDC].tree.count != 0` (outbox has entries) AND the current mapping exists AND the new remote differs, reverts with `TokenAlreadyMapped`.
76
+ **Entry point**: `JBSucker.prepare(uint256 projectTokenCount, bytes32 beneficiary, uint256 minTokensReclaimed, address token)`
52
77
 
53
- 5. Stores the mapping: `_remoteTokenFor[USDC] = JBRemoteToken{enabled: true, emergencyHatch: false, minGas: 200_000, addr: bytes32(remoteUSDC)}`.
78
+ **Who can call**: Anyone. The caller must have approved the sucker to transfer `projectTokenCount` of their project ERC-20 tokens.
54
79
 
55
- **Result:** USDC is now bridgeable. Users can call `prepare()` with USDC as the terminal token.
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)
56
85
 
57
- **Disabling a mapping:** Call `mapToken(JBTokenMapping{localToken: USDC, ..., remoteToken: bytes32(0), ...})`. If the outbox has unsent entries, `_sendRoot()` is called first to flush them. The mapping is disabled (`enabled = false`) but `addr` retains the original remote address (for re-enabling later).
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`.
58
94
 
59
- ## 3. Prepare (Bridge Out)
95
+ **Events**: `InsertToOutboxTree(beneficiary, token, hashed, index, root, projectTokenCount, terminalTokenAmount, caller)`
60
96
 
61
- A user prepares project tokens to be bridged to the remote chain.
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
62
104
 
63
- **Actors:** Token holder, JBSucker, JBMultiTerminal, JBController
105
+ ---
64
106
 
65
- **Steps:**
107
+ ## 4. Bridge (toRemote)
66
108
 
67
- 1. User approves the sucker to spend their project tokens.
109
+ **Entry point**: `JBSucker.toRemote(address token)` (payable)
68
110
 
69
- 2. User calls `JBSucker.prepare(projectTokenCount: 1000, beneficiary: bytes32(userAddressOnRemote), minTokensReclaimed: 950, token: NATIVE_TOKEN)`.
111
+ **Who can call**: Anyone. Typically called by a relayer. Requires `msg.value >= REGISTRY.toRemoteFee()` plus any bridge-specific transport payment.
70
112
 
71
- 3. Validation:
72
- - `beneficiary != bytes32(0)` (would revert on remote mint).
73
- - Project has a deployed ERC-20 token.
74
- - `_remoteTokenFor[NATIVE_TOKEN].enabled == true`.
75
- - Sucker state is `ENABLED` or `DEPRECATION_PENDING` (not `SENDING_DISABLED` or `DEPRECATED`).
113
+ **Parameters**:
114
+ - `token` -- The terminal token whose outbox tree root and backing assets should be sent to the remote chain
76
115
 
77
- 4. Transfers 1000 project tokens from user to the sucker.
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
78
131
 
79
- 5. `_pullBackingAssets()`:
80
- - Gets the project's primary terminal for `NATIVE_TOKEN`.
81
- - Calls `terminal.cashOutTokensOf(address(this), projectId, 1000, NATIVE_TOKEN, 950, payable(address(this)), "")`.
82
- - The sucker cashes out with 0% cashOutTaxRate (configured by JBOmnichainDeployer as data hook).
83
- - Records `balanceBefore`, asserts `reclaimedAmount == balanceAfter - balanceBefore`.
84
- - Returns `reclaimedAmount` (e.g., 0.5 ETH).
132
+ **Events**: `RootToRemote(root, token, index, nonce, caller)`
85
133
 
86
- 6. `_insertIntoTree()`:
87
- - Guards: `terminalTokenAmount` and `projectTokenCount` fit in `uint128`.
88
- - Builds leaf hash: `keccak256(abi.encode(1000, 500000000000000000, beneficiary))`.
89
- - Inserts into `_outboxOf[NATIVE_TOKEN].tree` via `MerkleLib.insert()`.
90
- - Updates `_outboxOf[NATIVE_TOKEN].balance += 0.5 ETH`.
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
91
142
 
92
- 7. Emits `InsertToOutboxTree` with the leaf details and updated root.
143
+ ---
93
144
 
94
- **Result:** The user's project tokens are burned (via cash out), the backing ETH is held by the sucker, and a leaf is recorded in the outbox merkle tree. The user waits for someone to call `toRemote()`.
145
+ ## 5. Receive Root (fromRemote)
95
146
 
96
- ## 4. Bridge (toRemote)
147
+ **Entry point**: `JBSucker.fromRemote(JBMessageRoot root)` (payable)
97
148
 
98
- Anyone triggers the bridge to send the outbox root and backing assets to the remote chain.
149
+ **Who can call**: Only the authenticated bridge messenger representing the remote peer. Validated via `_isRemotePeer(_msgSender())`.
99
150
 
100
- **Actors:** Relayer (anyone), JBSucker, Bridge infrastructure
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)
101
157
 
102
- **Steps:**
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
103
162
 
104
- 1. Relayer calls `JBSucker.toRemote{value: transportPayment}(NATIVE_TOKEN)`.
105
- - `transportPayment` is 0 for OP bridges, non-zero for Arbitrum L1->L2 and CCIP.
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)
106
166
 
107
- 2. Validation:
108
- - Emergency hatch not enabled for this token.
109
- - "Nothing to send" guard: reverts if `outbox.balance == 0 && outbox.tree.count == outbox.numberOfClaimsSent`.
110
- - If `REGISTRY.toRemoteFee() != 0`: deducts the fee from `msg.value` and pays it into the fee project (`FEE_PROJECT_ID`, typically project ID 1) via `terminal.pay()`. The caller (relayer) receives project tokens. Best-effort: if the fee project has no native token terminal or `terminal.pay()` reverts, proceeds without collecting the fee. Remainder is passed as `transportPayment`. The fee is global across all suckers, set by the registry owner via `JBSuckerRegistry.setToRemoteFee()`, capped at `MAX_TO_REMOTE_FEE` (0.001 ether).
111
- - Sucker not deprecated/sending-disabled.
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)
112
174
 
113
- 3. `_sendRoot()`:
114
- - Reads `outbox.tree.count` and `outbox.balance` (e.g., 2.5 ETH across multiple prepares).
115
- - Clears `outbox.balance = 0`.
116
- - Increments `outbox.nonce`.
117
- - Computes `outbox.tree.root()`.
118
- - Sets `outbox.numberOfClaimsSent = tree.count` (used for emergency exit bounds).
119
- - Builds `JBMessageRoot{version: 1, token: remoteTokenAddr, amount: 2.5 ETH, remoteRoot: {nonce, root}}`.
175
+ ---
176
+
177
+ ## 6. Claim (Bridge In)
178
+
179
+ **Entry point**: `JBSucker.claim(JBClaim claimData)` or `JBSucker.claim(JBClaim[] claims)` for batch
120
180
 
121
- 4. `_sendRootOverAMB()` (chain-specific):
122
- - **OP Stack**: Bridges ETH via `OPMESSENGER.sendMessage{value: 2.5 ETH}()` to `peer()`. Message encodes `JBSucker.fromRemote(messageRoot)`.
123
- - **Arbitrum L1->L2**: Bridges ERC-20 via gateway router (retryable ticket 1), sends merkle root message via inbox (retryable ticket 2). Two independent tickets.
124
- - **CCIP**: Wraps native ETH to WETH, calls `CCIP_ROUTER.ccipSend()` with token amounts and message data.
181
+ **Who can call**: Anyone. The beneficiary receives the tokens regardless of who calls.
125
182
 
126
- **Result:** The merkle root and backing assets are in transit to the remote chain. The bridge delivers them according to its own timeline (minutes to hours depending on the bridge).
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`)
127
188
 
128
- ## 5. Claim (Bridge In)
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)
129
197
 
130
- A beneficiary claims their bridged tokens on the destination chain.
198
+ **Events**: `Claimed(beneficiary, token, projectTokenCount, terminalTokenAmount, index, caller)`
131
199
 
132
- **Actors:** Beneficiary (or anyone on their behalf), JBSucker (destination), JBController
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
133
207
 
134
- **Steps:**
208
+ ---
135
209
 
136
- 1. Bridge delivers the message. The bridge messenger calls `JBSucker.fromRemote(JBMessageRoot)`.
210
+ ## 7. Deprecate Sucker
137
211
 
138
- 2. `fromRemote()` validates:
139
- - `_isRemotePeer(_msgSender())` -- verifies the bridge messenger represents the authenticated peer.
140
- - `root.version == MESSAGE_VERSION` (1).
141
- - `root.remoteRoot.nonce > inbox.nonce` -- only accepts newer roots.
142
- - Sucker state is not `DEPRECATED`.
212
+ **Entry point**: `JBSucker.setDeprecation(uint40 timestamp)`
143
213
 
144
- 3. Updates `_inboxOf[localToken].root = root.remoteRoot.root` and `_inboxOf[localToken].nonce = root.remoteRoot.nonce`.
214
+ **Who can call**: The project owner, or any address with the project owner's `SET_SUCKER_DEPRECATION` permission.
145
215
 
146
- 4. Off-chain: a relayer computes the merkle proof for the beneficiary's leaf against the inbox root. This requires knowing all leaves in the tree (from `InsertToOutboxTree` events on the source chain).
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`.
147
218
 
148
- 5. Beneficiary (or anyone) calls `JBSucker.claim(JBClaim{token: ETH, leaf: {index: 3, beneficiary: userAddr, projectTokenCount: 1000, terminalTokenAmount: 0.5 ETH}, proof: [...]})`.
219
+ **State changes**:
220
+ 1. `deprecatedAfter = timestamp` -- Sets or clears the deprecation timestamp
149
221
 
150
- 6. `_validate()`:
151
- - Checks `_executedFor[ETH].get(3)` is false (not already claimed).
152
- - Sets `_executedFor[ETH].set(3)` (marks as claimed).
153
- - Builds leaf hash: `keccak256(abi.encode(1000, 500000000000000000, beneficiary))`.
154
- - Computes root via `MerkleLib.branchRoot(hash, proof, 3)`.
155
- - Compares to `_inboxOf[ETH].root` -- reverts with `InvalidProof` if mismatch.
222
+ **Events**: `DeprecationTimeUpdated(timestamp, caller)`
156
223
 
157
- 7. `_handleClaim()`:
158
- - If `terminalTokenAmount > 0`:
159
- - Calls `_addToBalance(ETH, 0.5 ETH)` which forwards to `terminal.addToBalanceOf{value: 0.5 ETH}(projectId, ...)`.
160
- - Mints 1000 project tokens for the beneficiary via `controller.mintTokensOf(projectId, 1000, beneficiary, "", false)`.
161
- - `useReservedPercent = false` -- sucker mints bypass the reserved percent.
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).
162
228
 
163
- **Result:** The beneficiary receives 1000 project tokens on the destination chain. The 0.5 ETH backing is added to the project's terminal balance.
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
164
233
 
165
- ## 6. Deprecate Sucker
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.
166
235
 
167
- A project owner deprecates a sucker pair, progressively disabling operations.
236
+ ---
168
237
 
169
- **Actors:** Project owner, JBSucker
238
+ ## 8. Enable Emergency Hatch
170
239
 
171
- **Steps:**
240
+ **Entry point**: `JBSucker.enableEmergencyHatchFor(address[] tokens)`
172
241
 
173
- 1. Project owner calls `JBSucker.setDeprecation(timestamp)` on both chains, ideally with matching timestamps.
242
+ **Who can call**: The project owner, or any address with the project owner's `SUCKER_SAFETY` permission.
174
243
 
175
- 2. Validation:
176
- - Sucker state is `ENABLED` or `DEPRECATION_PENDING` (cannot change if already `SENDING_DISABLED` or `DEPRECATED`).
177
- - `SET_SUCKER_DEPRECATION` permission from project owner.
178
- - `timestamp == 0` (cancel) OR `timestamp >= block.timestamp + _maxMessagingDelay()` (14 days minimum).
244
+ **Parameters**:
245
+ - `tokens` -- Array of terminal token addresses to enable the emergency hatch for
179
246
 
180
- 3. Sets `deprecatedAfter = timestamp`.
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
181
251
 
182
- 4. State progression over time:
183
- - **Now -> timestamp - 14 days**: `DEPRECATION_PENDING`. All operations work normally. Users are warned.
184
- - **timestamp - 14 days -> timestamp**: `SENDING_DISABLED`. `prepare()` and `toRemote()` revert. `fromRemote()` still accepts roots. `claim()` still works.
185
- - **After timestamp**: `DEPRECATED`. `fromRemote()` rejects new roots. `claim()` still works for existing inbox roots. `exitThroughEmergencyHatch()` works.
252
+ **Events**: `EmergencyHatchOpened(tokens, caller)`
186
253
 
187
- 5. **Cancellation**: Project owner calls `setDeprecation(0)` before reaching `SENDING_DISABLED` state.
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
188
258
 
189
- 6. **Registry cleanup**: Anyone calls `JBSuckerRegistry.removeDeprecatedSucker(projectId, suckerAddress)` after the sucker reaches `DEPRECATED` state.
259
+ ---
190
260
 
191
- **Result:** The sucker is gracefully wound down. Users have 14+ days to claim pending bridges. No new bridges can be initiated.
261
+ ## 9. Exit Through Emergency Hatch
192
262
 
193
- ## 7. Enable Emergency Hatch
263
+ **Entry point**: `JBSucker.exitThroughEmergencyHatch(JBClaim claimData)`
194
264
 
195
- A project owner enables the emergency exit for tokens stuck in a broken bridge.
265
+ **Who can call**: Anyone. The beneficiary receives the tokens regardless of who calls.
196
266
 
197
- **Actors:** Project owner, JBSucker, affected token holders
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)
198
272
 
199
- **Steps:**
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
200
284
 
201
- 1. A bridge becomes non-functional (e.g., OP Stack bridge bug, token incompatibility). Tokens are stuck in the sucker's outbox.
285
+ **Events**: `EmergencyExit(beneficiary, token, terminalTokenAmount, projectTokenCount, caller)`
202
286
 
203
- 2. Project owner calls `JBSucker.enableEmergencyHatchFor([NATIVE_TOKEN, USDC])`.
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
204
293
 
205
- 3. Validation:
206
- - `SUCKER_SAFETY` permission from project owner.
207
- - For each token: sets `_remoteTokenFor[token].enabled = false` and `_remoteTokenFor[token].emergencyHatch = true`.
208
- - **Irreversible**: Once the emergency hatch is enabled for a token, it cannot be disabled.
294
+ ---
209
295
 
210
- 4. Affected users call `JBSucker.exitThroughEmergencyHatch(JBClaim{...})` with their prepare data.
296
+ ## 10. Register Sucker Deployer
211
297
 
212
- 5. `_validateForEmergencyExit()`:
213
- - Confirms emergency hatch is enabled for the token (or sucker is deprecated/sending-disabled).
214
- - Checks `index >= numberOfClaimsSent` or `numberOfClaimsSent == 0` (the leaf was NOT sent to the remote peer).
215
- - Uses a separate bitmap slot (derived from `keccak256(abi.encode(terminalToken))`) to prevent collision with regular claims.
216
- - Validates the merkle proof against the **outbox** tree root (not inbox).
298
+ **Entry point**: `JBSuckerRegistry.allowSuckerDeployer(address deployer)` or `JBSuckerRegistry.allowSuckerDeployers(address[] deployers)` for batch
217
299
 
218
- 6. Decreases `_outboxOf[token].balance -= terminalTokenAmount`.
300
+ **Who can call**: Only the registry `owner` (Ownable). Initially JuiceboxDAO / project #1.
219
301
 
220
- 7. `_handleClaim()`:
221
- - Adds terminal tokens back to the project's balance.
222
- - Mints project tokens for the beneficiary.
302
+ **Parameters**:
303
+ - `deployer` -- The address of the sucker deployer contract to add to the allowlist
304
+ - `deployers` (batch variant) -- Array of deployer addresses
223
305
 
224
- **Result:** Users recover their tokens locally without the bridge. Only leaves that were NOT already sent to the remote peer can be emergency-exited (leaves that were sent must be claimed on the remote chain).
306
+ **State changes**:
307
+ 1. `suckerDeployerIsAllowed[deployer] = true` -- Adds the deployer to the allowlist
225
308
 
226
- **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.
309
+ **Events**: `SuckerDeployerAllowed(deployer, caller)` -- One per deployer
227
310
 
228
- ## 8. Register Sucker Deployer
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`)
229
314
 
230
- The registry owner adds a new sucker deployer implementation.
315
+ **Removing a deployer**: `JBSuckerRegistry.removeSuckerDeployer(address deployer)` -- Sets `suckerDeployerIsAllowed[deployer] = false`. Emits `SuckerDeployerRemoved(deployer, caller)`. Existing suckers deployed by this deployer continue operating.
231
316
 
232
- **Actors:** Registry owner (initially JuiceboxDAO / project #1), JBSuckerRegistry
317
+ ---
233
318
 
234
- **Steps:**
319
+ ## 11. Set toRemote Fee
235
320
 
236
- 1. A new sucker deployer contract is deployed (e.g., `JBCCIPSuckerDeployer` for a new chain).
321
+ **Entry point**: `JBSuckerRegistry.setToRemoteFee(uint256 fee)`
237
322
 
238
- 2. Registry owner calls `JBSuckerRegistry.allowSuckerDeployer(deployerAddress)`.
239
- - Only the registry `owner` (Ownable) can call this.
323
+ **Who can call**: Only the registry `owner` (Ownable).
240
324
 
241
- 3. Sets `suckerDeployerIsAllowed[deployerAddress] = true`.
325
+ **Parameters**:
326
+ - `fee` -- The new ETH fee in wei, paid into the fee project on each `toRemote()` call
242
327
 
243
- 4. Projects can now use this deployer in `deploySuckersFor()` configurations.
328
+ **State changes**:
329
+ 1. `toRemoteFee = fee` -- Updates the global fee
244
330
 
245
- **Removing a deployer:** Registry owner calls `removeSuckerDeployer(deployerAddress)`. Sets `suckerDeployerIsAllowed[deployerAddress] = false`. Existing suckers deployed by this deployer are unaffected -- they continue operating.
331
+ **Events**: `ToRemoteFeeChanged(oldFee, fee, caller)`
246
332
 
247
- **Result:** A new bridge implementation is available for projects to deploy suckers with.
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
package/foundry.toml CHANGED
@@ -1,5 +1,5 @@
1
1
  [profile.default]
2
- solc = '0.8.26'
2
+ solc = '0.8.28'
3
3
  evm_version = 'cancun'
4
4
  optimizer_runs = 200
5
5
  libs = ["node_modules", "lib"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/suckers-v6",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
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",
23
- "@bananapus/permission-ids-v6": "^0.0.10",
22
+ "@bananapus/core-v6": "^0.0.25",
23
+ "@bananapus/permission-ids-v6": "^0.0.11",
24
24
  "@chainlink/contracts-ccip": "^1.5.0",
25
25
  "@chainlink/local": "github:smartcontractkit/chainlink-local",
26
26
  "@openzeppelin/contracts": "^5.6.1",
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IInbox} from "@arbitrum/nitro-contracts/src/bridge/IInbox.sol";
5
5
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
@@ -52,7 +52,7 @@ contract DeployScript is Script, Sphinx {
52
52
 
53
53
  function configureSphinx() public override {
54
54
  // TODO: Update to contain JB Emergency Developers
55
- sphinxConfig.projectName = "nana-suckers-v5";
55
+ sphinxConfig.projectName = "nana-suckers-v6";
56
56
  sphinxConfig.mainnets = ["ethereum", "optimism", "base", "arbitrum"];
57
57
  sphinxConfig.testnets = ["ethereum_sepolia", "optimism_sepolia", "base_sepolia", "arbitrum_sepolia"];
58
58
  }
@@ -71,6 +71,14 @@ contract DeployScript is Script, Sphinx {
71
71
  deploy();
72
72
  }
73
73
 
74
+ /// @dev Ownership transfer ordering: This function deploys multiple contracts and performs configuration in a
75
+ /// specific sequence. If the deployment is interrupted (e.g., by an out-of-gas error or a revert in one of the
76
+ /// deployer steps), intermediate states are possible where some deployers are created but not yet approved in the
77
+ /// registry, or the registry's ownership has not yet been transferred. When using Sphinx for deployment, the
78
+ /// entire `deploy()` function executes atomically within a single Gnosis Safe transaction, so partial deployment
79
+ /// states are not possible on-chain. However, if this script is used outside of Sphinx (e.g., via `forge script`
80
+ /// with `--broadcast`), each internal call would be a separate transaction, and an interruption could leave the
81
+ /// system in a partially configured state requiring manual intervention.
74
82
  function deploy() public sphinx {
75
83
  // Deploy the registry first — singletons need its address as an immutable.
76
84
  // If the registry is already deployed we don't have to deploy it
@@ -550,6 +558,27 @@ contract DeployScript is Script, Sphinx {
550
558
  internal
551
559
  returns (JBCCIPSuckerDeployer deployer)
552
560
  {
561
+ // Check if this CCIP deployer is already deployed on this chain,
562
+ // if that is the case we return the existing address and skip redeployment.
563
+ if (_isDeployed({
564
+ salt: salt,
565
+ creationCode: type(JBCCIPSuckerDeployer).creationCode,
566
+ arguments: abi.encode(directory, permissions, tokens, configurator, trustedForwarder)
567
+ })) {
568
+ return JBCCIPSuckerDeployer(
569
+ vm.computeCreate2Address({
570
+ salt: salt,
571
+ initCodeHash: keccak256(
572
+ abi.encodePacked(
573
+ type(JBCCIPSuckerDeployer).creationCode,
574
+ abi.encode(directory, permissions, tokens, configurator, trustedForwarder)
575
+ )
576
+ ),
577
+ deployer: address(0x4e59b44847b379578588920cA78FbF26c0B4956C)
578
+ })
579
+ );
580
+ }
581
+
553
582
  deployer = new JBCCIPSuckerDeployer{salt: salt}({
554
583
  directory: directory,
555
584
  permissions: permissions,