@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.
- package/ADMINISTRATION.md +14 -3
- package/ARCHITECTURE.md +67 -17
- package/AUDIT_INSTRUCTIONS.md +97 -1
- package/CHANGE_LOG.md +14 -2
- package/README.md +17 -26
- package/RISKS.md +23 -6
- package/SKILLS.md +46 -9
- package/STYLE_GUIDE.md +2 -2
- package/USER_JOURNEYS.md +245 -156
- package/foundry.toml +1 -1
- package/package.json +3 -3
- package/script/Deploy.s.sol +31 -2
- package/script/helpers/SuckerDeploymentLib.sol +6 -6
- package/src/JBArbitrumSucker.sol +15 -12
- package/src/JBBaseSucker.sol +1 -1
- package/src/JBCCIPSucker.sol +1 -1
- package/src/JBCeloSucker.sol +1 -1
- package/src/JBOptimismSucker.sol +1 -1
- package/src/JBSucker.sol +24 -7
- package/src/JBSuckerRegistry.sol +1 -1
- package/src/deployers/JBArbitrumSuckerDeployer.sol +1 -1
- package/src/deployers/JBBaseSuckerDeployer.sol +1 -1
- package/src/deployers/JBCCIPSuckerDeployer.sol +1 -1
- package/src/deployers/JBCeloSuckerDeployer.sol +1 -1
- package/src/deployers/JBOptimismSuckerDeployer.sol +1 -1
- package/src/deployers/JBSuckerDeployer.sol +1 -1
- package/src/libraries/CCIPHelper.sol +1 -1
- package/src/utils/MerkleLib.sol +1 -1
- package/test/Fork.t.sol +1 -1
- package/test/ForkArbitrum.t.sol +1 -1
- package/test/ForkCelo.t.sol +1 -1
- package/test/ForkClaim.t.sol +1 -1
- package/test/ForkMainnet.t.sol +1 -1
- package/test/ForkOPStack.t.sol +1 -1
- package/test/SuckerDeepAttacks.t.sol +5 -4
- package/test/audit/ArbitrumL2ToRemoteFeeDoS.t.sol +120 -0
- package/test/audit/CodexNemesisPoC.t.sol +169 -0
- package/test/fork/OptimismSuckerFork.t.sol +457 -0
- 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
|
-
|
|
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
|
-
|
|
9
|
+
**Entry point**: `JBSuckerRegistry.deploySuckersFor(uint256 projectId, bytes32 salt, JBSuckerDeployerConfig[] configurations)`
|
|
8
10
|
|
|
9
|
-
**
|
|
11
|
+
**Who can call**: The project owner, or any address with the project owner's `DEPLOY_SUCKERS` permission.
|
|
10
12
|
|
|
11
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
---
|
|
22
38
|
|
|
23
|
-
|
|
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
|
-
|
|
41
|
+
**Entry point**: `JBSucker.mapToken(JBTokenMapping map)` (payable)
|
|
31
42
|
|
|
32
|
-
**
|
|
43
|
+
**Who can call**: The project owner, or any address with the project owner's `MAP_SUCKER_TOKEN` permission.
|
|
33
44
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
59
|
+
**Events**: None directly from `mapToken`. If a root flush occurs during disable, emits `RootToRemote(root, token, index, nonce, caller)`.
|
|
39
60
|
|
|
40
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
72
|
+
---
|
|
45
73
|
|
|
46
|
-
3.
|
|
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
|
-
|
|
76
|
+
**Entry point**: `JBSucker.prepare(uint256 projectTokenCount, bytes32 beneficiary, uint256 minTokensReclaimed, address token)`
|
|
52
77
|
|
|
53
|
-
|
|
78
|
+
**Who can call**: Anyone. The caller must have approved the sucker to transfer `projectTokenCount` of their project ERC-20 tokens.
|
|
54
79
|
|
|
55
|
-
**
|
|
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
|
-
**
|
|
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
|
-
|
|
95
|
+
**Events**: `InsertToOutboxTree(beneficiary, token, hashed, index, root, projectTokenCount, terminalTokenAmount, caller)`
|
|
60
96
|
|
|
61
|
-
|
|
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
|
-
|
|
105
|
+
---
|
|
64
106
|
|
|
65
|
-
|
|
107
|
+
## 4. Bridge (toRemote)
|
|
66
108
|
|
|
67
|
-
|
|
109
|
+
**Entry point**: `JBSucker.toRemote(address token)` (payable)
|
|
68
110
|
|
|
69
|
-
|
|
111
|
+
**Who can call**: Anyone. Typically called by a relayer. Requires `msg.value >= REGISTRY.toRemoteFee()` plus any bridge-specific transport payment.
|
|
70
112
|
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
143
|
+
---
|
|
93
144
|
|
|
94
|
-
|
|
145
|
+
## 5. Receive Root (fromRemote)
|
|
95
146
|
|
|
96
|
-
|
|
147
|
+
**Entry point**: `JBSucker.fromRemote(JBMessageRoot root)` (payable)
|
|
97
148
|
|
|
98
|
-
|
|
149
|
+
**Who can call**: Only the authenticated bridge messenger representing the remote peer. Validated via `_isRemotePeer(_msgSender())`.
|
|
99
150
|
|
|
100
|
-
**
|
|
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
|
-
**
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
198
|
+
**Events**: `Claimed(beneficiary, token, projectTokenCount, terminalTokenAmount, index, caller)`
|
|
131
199
|
|
|
132
|
-
**
|
|
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
|
-
|
|
208
|
+
---
|
|
135
209
|
|
|
136
|
-
|
|
210
|
+
## 7. Deprecate Sucker
|
|
137
211
|
|
|
138
|
-
|
|
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
|
-
|
|
214
|
+
**Who can call**: The project owner, or any address with the project owner's `SET_SUCKER_DEPRECATION` permission.
|
|
145
215
|
|
|
146
|
-
|
|
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
|
-
|
|
219
|
+
**State changes**:
|
|
220
|
+
1. `deprecatedAfter = timestamp` -- Sets or clears the deprecation timestamp
|
|
149
221
|
|
|
150
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
236
|
+
---
|
|
168
237
|
|
|
169
|
-
|
|
238
|
+
## 8. Enable Emergency Hatch
|
|
170
239
|
|
|
171
|
-
**
|
|
240
|
+
**Entry point**: `JBSucker.enableEmergencyHatchFor(address[] tokens)`
|
|
172
241
|
|
|
173
|
-
|
|
242
|
+
**Who can call**: The project owner, or any address with the project owner's `SUCKER_SAFETY` permission.
|
|
174
243
|
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
+
---
|
|
190
260
|
|
|
191
|
-
|
|
261
|
+
## 9. Exit Through Emergency Hatch
|
|
192
262
|
|
|
193
|
-
|
|
263
|
+
**Entry point**: `JBSucker.exitThroughEmergencyHatch(JBClaim claimData)`
|
|
194
264
|
|
|
195
|
-
|
|
265
|
+
**Who can call**: Anyone. The beneficiary receives the tokens regardless of who calls.
|
|
196
266
|
|
|
197
|
-
**
|
|
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
|
-
**
|
|
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
|
-
|
|
285
|
+
**Events**: `EmergencyExit(beneficiary, token, terminalTokenAmount, projectTokenCount, caller)`
|
|
202
286
|
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
+
## 10. Register Sucker Deployer
|
|
211
297
|
|
|
212
|
-
|
|
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
|
-
|
|
300
|
+
**Who can call**: Only the registry `owner` (Ownable). Initially JuiceboxDAO / project #1.
|
|
219
301
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
**
|
|
306
|
+
**State changes**:
|
|
307
|
+
1. `suckerDeployerIsAllowed[deployer] = true` -- Adds the deployer to the allowlist
|
|
225
308
|
|
|
226
|
-
**
|
|
309
|
+
**Events**: `SuckerDeployerAllowed(deployer, caller)` -- One per deployer
|
|
227
310
|
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
+
---
|
|
233
318
|
|
|
234
|
-
|
|
319
|
+
## 11. Set toRemote Fee
|
|
235
320
|
|
|
236
|
-
|
|
321
|
+
**Entry point**: `JBSuckerRegistry.setToRemoteFee(uint256 fee)`
|
|
237
322
|
|
|
238
|
-
|
|
239
|
-
- Only the registry `owner` (Ownable) can call this.
|
|
323
|
+
**Who can call**: Only the registry `owner` (Ownable).
|
|
240
324
|
|
|
241
|
-
|
|
325
|
+
**Parameters**:
|
|
326
|
+
- `fee` -- The new ETH fee in wei, paid into the fee project on each `toRemote()` call
|
|
242
327
|
|
|
243
|
-
|
|
328
|
+
**State changes**:
|
|
329
|
+
1. `toRemoteFee = fee` -- Updates the global fee
|
|
244
330
|
|
|
245
|
-
**
|
|
331
|
+
**Events**: `ToRemoteFeeChanged(oldFee, fee, caller)`
|
|
246
332
|
|
|
247
|
-
**
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/suckers-v6",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
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",
|
package/script/Deploy.s.sol
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
pragma solidity 0.8.
|
|
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-
|
|
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,
|