@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/ADMINISTRATION.md
CHANGED
|
@@ -56,7 +56,7 @@ Admin privileges and their scope in nana-suckers-v6.
|
|
|
56
56
|
| `allowSuckerDeployers(deployers)` | Registry Owner | N/A (`onlyOwner`) | Global | Batch version: adds multiple deployer contracts to the allowlist. |
|
|
57
57
|
| `removeSuckerDeployer(deployer)` | Registry Owner | N/A (`onlyOwner`) | Global | Removes a deployer contract from the allowlist, preventing future sucker deployments through it. |
|
|
58
58
|
| `setToRemoteFee(fee)` | Registry Owner | N/A (`onlyOwner`) | Global | Sets the `toRemoteFee` applied to all suckers. The fee must be <= `MAX_TO_REMOTE_FEE` (0.001 ether). Emits `ToRemoteFeeChanged`. |
|
|
59
|
-
| `deploySuckersFor(projectId, salt, configs)` | Project Owner | `DEPLOY_SUCKERS` | Per-project | Deploys one or more suckers for a project using allowlisted deployers. Hashes salt with `msg.sender` for deterministic cross-chain addresses. Also calls `mapTokens` on each newly created sucker. |
|
|
59
|
+
| `deploySuckersFor(projectId, salt, configs)` | Project Owner | `DEPLOY_SUCKERS` | Per-project | Deploys one or more suckers for a project using allowlisted deployers. Hashes salt with `_msgSender()` (which equals `msg.sender` for direct calls, or the relayed sender for ERC-2771 meta-transactions) for deterministic cross-chain addresses. Also calls `mapTokens` on each newly created sucker. |
|
|
60
60
|
| `removeDeprecatedSucker(projectId, sucker)` | Anyone | None | Per-project | Removes a fully `DEPRECATED` sucker from the registry. Permissionless but only succeeds if the sucker is in the `DEPRECATED` state. |
|
|
61
61
|
|
|
62
62
|
### JBSucker
|
|
@@ -110,7 +110,7 @@ The emergency hatch is a per-token escape mechanism for when a bridge becomes no
|
|
|
110
110
|
- Prevents `mapToken()` from remapping or re-enabling the token (reverts with `JBSucker_TokenHasInvalidEmergencyHatchState`).
|
|
111
111
|
- Enables `exitThroughEmergencyHatch()` for users whose outbox entries were never sent to the remote peer (entries with `index >= numberOfClaimsSent`).
|
|
112
112
|
|
|
113
|
-
**Who can exit:** Any user with a valid outbox merkle proof for a leaf that was never sent over the bridge. The exit
|
|
113
|
+
**Who can exit:** Any user with a valid outbox merkle proof for a leaf that was never sent over the bridge. The exit mints project tokens to the beneficiary and returns the terminal tokens to the project's terminal balance. The beneficiary can then cash out the minted project tokens through the normal Juicebox mechanism if desired.
|
|
114
114
|
|
|
115
115
|
**Safety constraint:** Only leaves that were never communicated to the remote peer (i.e., `index >= outbox.numberOfClaimsSent`) can be emergency-exited. Leaves already sent over the bridge (index < `numberOfClaimsSent`) cannot be emergency-exited because they may have already been claimed on the remote chain.
|
|
116
116
|
|
|
@@ -136,7 +136,7 @@ The following values are set at deploy time and cannot be changed:
|
|
|
136
136
|
| `REMOTE_CHAIN_SELECTOR` | `JBCCIPSucker` | Constructor (from deployer callback) | The CCIP chain selector for the remote chain. |
|
|
137
137
|
| `WRAPPED_NATIVE` | `JBCeloSucker` | Constructor (from deployer callback) | The wrapped native token (WETH) on the local chain. |
|
|
138
138
|
| `LAYER_SPECIFIC_CONFIGURATOR` | All deployers | Constructor | The address authorized to call one-time setup functions. |
|
|
139
|
-
| `peer()` | `JBSucker` | Deterministic (
|
|
139
|
+
| `peer()` | `JBSucker` | Deterministic (returns `bytes32` via `_toBytes32(address(this))`) | The remote peer sucker address as `bytes32`. Defaults to the local address, relying on CREATE2 giving the same address on both chains. |
|
|
140
140
|
| `deployer` | `JBSucker` | Set once in `initialize()` by `msg.sender` | The address that deployed this sucker clone (the deployer contract). |
|
|
141
141
|
| `projectId()` | `JBSucker` | Set once in `initialize()` | The local project ID this sucker serves. |
|
|
142
142
|
| Bridge/messenger/router addresses | All deployers | `setChainSpecificConstants()` (one-time) | Bridge infrastructure addresses, set once by the configurator. |
|
|
@@ -167,3 +167,14 @@ What admins **cannot** do:
|
|
|
167
167
|
- **Cannot change the fee project.** `FEE_PROJECT_ID` is set at construction and is immutable. If the fee project's terminal changes or is removed, fee payments silently stop (best-effort design), but `toRemote()` still works.
|
|
168
168
|
|
|
169
169
|
- **Can adjust the fee amount, within bounds.** The registry owner can call `setToRemoteFee()` on `JBSuckerRegistry` to adjust the fee globally for all suckers, but it is capped at `MAX_TO_REMOTE_FEE` (0.001 ether).
|
|
170
|
+
|
|
171
|
+
## Bridge Infrastructure Risks
|
|
172
|
+
|
|
173
|
+
Sucker security ultimately depends on the underlying bridge infrastructure (OP Cross-Domain Messenger, Arbitrum Inbox/Outbox, CCIP Router). Each sucker's `_isRemotePeer()` check trusts these bridges to faithfully report the sender of cross-chain messages. If a bridge itself is compromised, an attacker could forge messages that pass the peer check, potentially minting unbacked project tokens on the destination chain.
|
|
174
|
+
|
|
175
|
+
**Mitigations available to project owners:**
|
|
176
|
+
|
|
177
|
+
- **Emergency hatch.** If a bridge is suspected to be compromised for a specific token, the project owner (or `SUCKER_SAFETY` delegate) can call `enableEmergencyHatchFor()` to immediately disable bridging for that token and allow users to exit with their outbox entries locally.
|
|
178
|
+
- **Deprecation.** If the bridge is broadly compromised, the project owner (or `SET_SUCKER_DEPRECATION` delegate) can deprecate the entire sucker via `setDeprecation()`, shutting down all new outbound and eventually inbound activity.
|
|
179
|
+
|
|
180
|
+
**Fee delivery is best-effort.** The `toRemoteFee` is collected by calling `terminal.pay()` to the fee project during `toRemote()`. If this call reverts (e.g., the fee project has no native token terminal or its terminal is misconfigured), the fee is silently skipped and `toRemote()` proceeds normally. This ensures bridging availability is never blocked by fee infrastructure issues, but it also means fee revenue can be lost without any on-chain signal.
|
package/ARCHITECTURE.md
CHANGED
|
@@ -2,28 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
Cross-chain token bridging for Juicebox V6. Allows project tokens and funds to move between EVM chains via Optimism, Arbitrum, and Chainlink CCIP bridges. Uses dual merkle trees (outbox/inbox) for claim verification.
|
|
5
|
+
Cross-chain token bridging for Juicebox V6. Allows project tokens and funds to move between EVM chains via Optimism, Base, Celo (OP Stack), Arbitrum, and Chainlink CCIP bridges. Uses dual merkle trees (outbox/inbox) for claim verification.
|
|
6
6
|
|
|
7
7
|
## Contract Map
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
src/
|
|
11
11
|
├── JBSucker.sol — Abstract base: merkle tree management, prepare/claim logic, FEE_PROJECT_ID, reads toRemoteFee from REGISTRY (immutable IJBSuckerRegistry)
|
|
12
|
-
├──
|
|
13
|
-
├──
|
|
12
|
+
├── JBOptimismSucker.sol — OP Stack bridge implementation (Optimism, Base, Celo)
|
|
13
|
+
├── JBBaseSucker.sol — Base chain sucker (extends JBOptimismSucker, overrides peerChainId for Base ↔ Ethereum)
|
|
14
|
+
├── JBCeloSucker.sol — Celo sucker (extends JBOptimismSucker, wraps ETH → WETH for bridging, custom gas token handling)
|
|
14
15
|
├── JBArbitrumSucker.sol — Arbitrum bridge implementation
|
|
15
16
|
├── JBCCIPSucker.sol — Chainlink CCIP bridge implementation
|
|
16
17
|
├── JBSuckerRegistry.sol — Registry of suckers per project, deployment permissions, centralized toRemoteFee (owner-controlled, applies to all suckers)
|
|
17
18
|
├── deployers/
|
|
19
|
+
│ ├── JBSuckerDeployer.sol — Abstract base deployer (LibClone, singleton pattern)
|
|
18
20
|
│ ├── JBOptimismSuckerDeployer.sol
|
|
21
|
+
│ ├── JBBaseSuckerDeployer.sol
|
|
22
|
+
│ ├── JBCeloSuckerDeployer.sol
|
|
19
23
|
│ ├── JBArbitrumSuckerDeployer.sol
|
|
20
24
|
│ └── JBCCIPSuckerDeployer.sol
|
|
21
25
|
├── enums/
|
|
22
|
-
│
|
|
26
|
+
│ ├── JBSuckerState.sol — ENABLED → DEPRECATION_PENDING → SENDING_DISABLED → DEPRECATED
|
|
27
|
+
│ └── JBLayer.sol — L1 / L2 indicator (used by JBArbitrumSucker)
|
|
28
|
+
├── interfaces/ — IJBSucker, IJBSuckerRegistry, IJBSuckerDeployer, bridge-specific interfaces (OP, Arb, CCIP)
|
|
23
29
|
├── libraries/
|
|
24
|
-
│ ├──
|
|
25
|
-
│
|
|
26
|
-
└──
|
|
30
|
+
│ ├── ARBAddresses.sol — Arbitrum precompile/contract addresses
|
|
31
|
+
│ ├── ARBChains.sol — Arbitrum chain ID constants
|
|
32
|
+
│ └── CCIPHelper.sol — CCIP chain selector lookups
|
|
33
|
+
├── structs/ — JBClaim, JBLeaf, JBTokenMapping, JBRemoteToken, JBOutboxTree, JBMessageRoot, etc.
|
|
34
|
+
└── utils/
|
|
35
|
+
└── MerkleLib.sol — Append-only merkle tree operations
|
|
27
36
|
```
|
|
28
37
|
|
|
29
38
|
## Key Data Flows
|
|
@@ -33,19 +42,23 @@ src/
|
|
|
33
42
|
User → JBSucker.prepare()
|
|
34
43
|
→ Cash out project tokens (0% tax via sucker privilege)
|
|
35
44
|
→ Insert {beneficiary, token, amount} into outbox merkle tree
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
|
|
46
|
+
User → JBSucker.toRemote{value}(token)
|
|
47
|
+
→ Pay toRemoteFee (ETH) to fee project via terminal.pay() — caller gets fee project tokens
|
|
48
|
+
→ Remaining msg.value forwarded as transport payment to the bridge
|
|
49
|
+
→ Send merkle root + bridged tokens to remote sucker via OP/Arb/CCIP messenger
|
|
39
50
|
```
|
|
40
51
|
|
|
52
|
+
The `toRemoteFee` is a global ETH fee (max 0.001 ETH) set by the registry owner via `setToRemoteFee()`. The caller must send at least `toRemoteFee` as `msg.value` (reverts otherwise). The fee is paid into `FEE_PROJECT_ID` (typically project 1) through the project's primary native-token terminal. If the `pay()` call reverts or no terminal exists, the fee is silently added to the transport payment instead (best-effort).
|
|
53
|
+
|
|
41
54
|
### Inbound (Claim)
|
|
42
55
|
```
|
|
43
56
|
Remote root arrives → stored in inbox tree
|
|
44
57
|
User → JBSucker.claim(proof)
|
|
58
|
+
→ Check leaf not already claimed (bitmap), mark as executed
|
|
45
59
|
→ Verify merkle proof against inbox root
|
|
46
|
-
→
|
|
47
|
-
→ Mint project tokens to beneficiary (
|
|
48
|
-
→ Mark leaf as executed
|
|
60
|
+
→ Add bridged terminal tokens to project balance (via terminal.addToBalanceOf)
|
|
61
|
+
→ Mint project tokens to beneficiary (via controller.mintTokensOf)
|
|
49
62
|
```
|
|
50
63
|
|
|
51
64
|
### Deprecation Lifecycle
|
|
@@ -54,6 +67,31 @@ ENABLED → DEPRECATION_PENDING → SENDING_DISABLED → DEPRECATED
|
|
|
54
67
|
(owner) (pending period) (no new outbox) (fully disabled)
|
|
55
68
|
```
|
|
56
69
|
|
|
70
|
+
## Token Mapping
|
|
71
|
+
|
|
72
|
+
Token mappings link a local terminal token to a remote token address, enabling bridging for that pair. Mappings are managed via `mapToken()` / `mapTokens()` (requires `MAP_SUCKER_TOKEN` permission).
|
|
73
|
+
|
|
74
|
+
**Immutability constraint:** Once a token has outbox tree entries (`_outboxOf[token].tree.count != 0`), it cannot be remapped to a *different* remote token — it can only be disabled by mapping to `address(0)`. This prevents double-spending: if remapping were allowed after outbox activity, the same local funds could be claimed against two different remote tokens on the remote chain.
|
|
75
|
+
|
|
76
|
+
A disabled mapping can be re-enabled to the *same* remote token. A misconfigured mapping requires deploying a new sucker.
|
|
77
|
+
|
|
78
|
+
When a mapping is disabled (set to `address(0)`) and unsent leaves remain in the outbox, the sucker automatically flushes the remaining root to the remote chain to settle outstanding claims.
|
|
79
|
+
|
|
80
|
+
## Emergency Hatch
|
|
81
|
+
|
|
82
|
+
The emergency hatch allows users to reclaim their tokens on the chain they deposited on when the bridge is no longer functional.
|
|
83
|
+
|
|
84
|
+
**Who can enable it:** The project owner (or an address with the `SUCKER_SAFETY` permission) calls `enableEmergencyHatchFor(address[] tokens)`.
|
|
85
|
+
|
|
86
|
+
**What it does:** Sets `emergencyHatch = true` and `enabled = false` for each token's remote mapping. This is irreversible — once the emergency hatch is enabled for a token, it cannot be disabled or remapped.
|
|
87
|
+
|
|
88
|
+
**How users exit:** Users call `exitThroughEmergencyHatch(JBClaim claimData)` with a merkle proof against the *outbox* tree. The sucker validates:
|
|
89
|
+
1. The emergency hatch is enabled for that token (or the sucker is in `DEPRECATED` / `SENDING_DISABLED` state).
|
|
90
|
+
2. The leaf has not already been sent to the remote chain (`index >= numberOfClaimsSent`). Leaves whose roots were already bridged cannot be emergency-exited, since they could also be claimed remotely.
|
|
91
|
+
3. The leaf has not already been claimed (bitmap check).
|
|
92
|
+
|
|
93
|
+
The sucker then adds the terminal tokens back to the project's balance (via `terminal.addToBalanceOf`) and mints project tokens to the beneficiary on the local chain.
|
|
94
|
+
|
|
57
95
|
## Extension Points
|
|
58
96
|
|
|
59
97
|
| Point | Interface | Purpose |
|
|
@@ -62,10 +100,22 @@ ENABLED → DEPRECATION_PENDING → SENDING_DISABLED → DEPRECATED
|
|
|
62
100
|
| Sucker deployer | `IJBSuckerDeployer` | Factory for new suckers |
|
|
63
101
|
| Registry | `IJBSuckerRegistry` | Discovery and access control |
|
|
64
102
|
|
|
103
|
+
## Design Decisions
|
|
104
|
+
|
|
105
|
+
**Dual merkle trees instead of direct bridging.** Each sucker maintains separate outbox and inbox merkle trees per token. Outbound transfers are batched into the outbox tree via `prepare()`, and a single `toRemote()` call bridges the root and accumulated funds together. This amortizes bridge costs across many users — only one cross-chain message per batch rather than one per transfer. The inbox tree on the receiving side lets users self-serve claims with merkle proofs at their own pace.
|
|
106
|
+
|
|
107
|
+
**Bitmap for claim tracking.** Each leaf index is tracked in an OpenZeppelin `BitMaps.BitMap` (`_executedFor`), which packs 256 booleans per storage slot. This is far cheaper than a `mapping(uint256 => bool)` for dense sequential indices, and the merkle tree's sequential leaf indices are a natural fit.
|
|
108
|
+
|
|
109
|
+
**Immutable token mappings once outbox has entries.** After a token mapping has been used (outbox tree count > 0), remapping to a different remote token is blocked. Without this, an attacker could prepare tokens mapped to token A, then remap to token B before `toRemote()` is called, allowing the same local funds to be claimed against both tokens on the remote chain. Disabling (mapping to `address(0)`) is still allowed since it flushes remaining outbox entries and stops new ones.
|
|
110
|
+
|
|
111
|
+
**`uint128` amount cap for SVM compatibility.** Both `terminalTokenAmount` and `projectTokenCount` are capped at `type(uint128).max` in `_insertIntoTree()`. This ensures the leaf data is compatible with Solana's SVM, where token amounts are `u64`/`u128`. The `bytes32` beneficiary field similarly accommodates 32-byte Solana public keys alongside 20-byte EVM addresses.
|
|
112
|
+
|
|
113
|
+
**Best-effort fee payment.** The `toRemoteFee` payment to the fee project is wrapped in a try-catch. If the fee project's terminal doesn't exist or the `pay()` call reverts, the bridge proceeds without the fee. This prevents a misconfigured or paused fee project from blocking all cross-chain transfers.
|
|
114
|
+
|
|
65
115
|
## Dependencies
|
|
66
116
|
- `@bananapus/core-v6` — Terminal, controller, token interfaces
|
|
67
117
|
- `@bananapus/permission-ids-v6` — DEPLOY_SUCKERS, MAP_SUCKER_TOKEN, etc.
|
|
68
|
-
- `@arbitrum/nitro-contracts` — Arbitrum bridge interfaces
|
|
69
|
-
- `@chainlink/contracts-ccip` — CCIP router interfaces
|
|
70
|
-
- `@openzeppelin/contracts` —
|
|
71
|
-
- `solady` —
|
|
118
|
+
- `@arbitrum/nitro-contracts` — Arbitrum bridge interfaces (IInbox, IOutbox, IBridge, ArbSys, AddressAliasHelper)
|
|
119
|
+
- `@chainlink/contracts-ccip` — CCIP router and message interfaces
|
|
120
|
+
- `@openzeppelin/contracts` — BitMaps, SafeERC20, ERC165, Initializable, ERC2771Context, Ownable, EnumerableMap
|
|
121
|
+
- `solady` — LibClone (deterministic clone deployment for sucker deployers)
|
package/AUDIT_INSTRUCTIONS.md
CHANGED
|
@@ -14,7 +14,7 @@ src/JBBaseSucker.sol # Base (OP Stack variant) (~48 lines)
|
|
|
14
14
|
src/JBCeloSucker.sol # Celo (OP Stack + WETH wrapping) (~196 lines)
|
|
15
15
|
src/JBArbitrumSucker.sol # Arbitrum bridge implementation (~322 lines)
|
|
16
16
|
src/JBCCIPSucker.sol # Chainlink CCIP implementation (~306 lines)
|
|
17
|
-
src/JBSuckerRegistry.sol # Deployer registry and tracking (~
|
|
17
|
+
src/JBSuckerRegistry.sol # Deployer registry and tracking (~282 lines)
|
|
18
18
|
src/deployers/ # JBSuckerDeployer, JB{Optimism,Base,Celo,Arbitrum,CCIP}SuckerDeployer
|
|
19
19
|
src/utils/MerkleLib.sol # Incremental merkle tree (eth2-style) (~1,030 lines)
|
|
20
20
|
src/structs/ # JBMessageRoot, JBLeaf, JBClaim, JBOutboxTree, etc.
|
|
@@ -300,3 +300,99 @@ forge test --match-contract ForkMainnet --fork-url $ETH_RPC_URL
|
|
|
300
300
|
# Gas analysis
|
|
301
301
|
forge test --gas-report
|
|
302
302
|
```
|
|
303
|
+
|
|
304
|
+
## Error Reference
|
|
305
|
+
|
|
306
|
+
35 custom errors across source files:
|
|
307
|
+
|
|
308
|
+
| Error | Contract | Trigger |
|
|
309
|
+
|-------|----------|---------|
|
|
310
|
+
| `JBSucker_AmountExceedsUint128` | JBSucker | `projectTokenCount` or `terminalTokenAmount` exceeds `uint128` during `_insertIntoTree` |
|
|
311
|
+
| `JBSucker_BelowMinGas` | JBSucker | Token mapping `minGas` is below `MESSENGER_ERC20_MIN_GAS_LIMIT` |
|
|
312
|
+
| `JBSucker_Deprecated` | JBSucker | `prepare`, `toRemote`, or `_sendRoot` called when sucker is `SENDING_DISABLED` or `DEPRECATED` |
|
|
313
|
+
| `JBSucker_DeprecationTimestampTooSoon` | JBSucker | `setDeprecation` timestamp is earlier than `block.timestamp + _maxMessagingDelay()` |
|
|
314
|
+
| `JBSucker_ExpectedMsgValue` | JBArbitrumSucker, JBCCIPSucker | Transport payment is 0 when bridge requires msg.value (Arbitrum L1, CCIP) |
|
|
315
|
+
| `JBSucker_InsufficientBalance` | JBSucker | `_addToBalance` amount exceeds `amountToAddToBalanceOf` (actual token balance minus outbox balance) |
|
|
316
|
+
| `JBSucker_InsufficientMsgValue` | JBSucker | `msg.value` is less than the `toRemoteFee` in `toRemote` |
|
|
317
|
+
| `JBSucker_InvalidMessageVersion` | JBSucker | `fromRemote` receives a root with mismatched `MESSAGE_VERSION` |
|
|
318
|
+
| `JBSucker_InvalidNativeRemoteAddress` | JBSucker | Native token mapped to non-native, non-zero remote address (base class restriction) |
|
|
319
|
+
| `JBSucker_InvalidProof` | JBSucker | Merkle proof does not reconstruct the stored inbox root during `_validate` |
|
|
320
|
+
| `JBSucker_LeafAlreadyExecuted` | JBSucker | Leaf index already claimed in bitmap during `_validate` or `_validateForEmergencyExit` |
|
|
321
|
+
| `JBSucker_NoTerminalForToken` | JBSucker | No primary terminal registered for the token when pulling backing assets or adding to balance |
|
|
322
|
+
| `JBSucker_NotPeer` | JBSucker | `fromRemote` caller fails `_isRemotePeer` authentication |
|
|
323
|
+
| `JBSucker_NothingToSend` | JBSucker | `toRemote` called when emergency hatch is enabled or outbox has no unsent entries |
|
|
324
|
+
| `JBSucker_TokenAlreadyMapped` | JBSucker | Attempt to remap a token after outbox tree has entries (immutability enforcement) |
|
|
325
|
+
| `JBSucker_TokenHasInvalidEmergencyHatchState` | JBSucker | Emergency hatch state conflict during `toRemote`, `_mapToken`, or `_validateForEmergencyExit` |
|
|
326
|
+
| `JBSucker_TokenNotMapped` | JBSucker | `prepare` or `_sendRoot` called for an unmapped token |
|
|
327
|
+
| `JBSucker_UnexpectedMsgValue` | JBArbitrumSucker, JBOptimismSucker, JBCeloSucker | Non-zero transport payment on bridges that don't accept it (Arbitrum L2, OP Stack, Celo) |
|
|
328
|
+
| `JBSucker_ZeroBeneficiary` | JBSucker | `prepare` called with zero-address beneficiary |
|
|
329
|
+
| `JBSucker_ZeroERC20Token` | JBSucker | `prepare` called when project has no ERC-20 token |
|
|
330
|
+
| `JBArbitrumSucker_NotEnoughGas` | JBArbitrumSucker | Transport payment insufficient for retryable ticket gas cost |
|
|
331
|
+
| `JBCCIPSucker_InvalidRouter` | JBCCIPSucker | Constructor reverts when `CCIP_ROUTER == address(0)` |
|
|
332
|
+
| `JBSuckerRegistry_FeeExceedsMax` | JBSuckerRegistry | `setToRemoteFee` exceeds `MAX_TO_REMOTE_FEE` |
|
|
333
|
+
| `JBSuckerRegistry_InvalidDeployer` | JBSuckerRegistry | Deployer not in allowlist during `deploySuckersFor` |
|
|
334
|
+
| `JBSuckerRegistry_SuckerDoesNotBelongToProject` | JBSuckerRegistry | `removeDeprecatedSucker` called for sucker not belonging to the project |
|
|
335
|
+
| `JBSuckerRegistry_SuckerIsNotDeprecated` | JBSuckerRegistry | `removeDeprecatedSucker` called for a non-deprecated sucker |
|
|
336
|
+
| `JBCCIPSuckerDeployer_InvalidCCIPRouter` | JBCCIPSuckerDeployer | Zero-address CCIP router in constructor |
|
|
337
|
+
| `JBSuckerDeployer_AlreadyConfigured` | JBSuckerDeployer | `configureSucker` called on an already-configured sucker |
|
|
338
|
+
| `JBSuckerDeployer_DeployerIsNotConfigured` | JBSuckerDeployer | `createFor` called before deployer is configured |
|
|
339
|
+
| `JBSuckerDeployer_InvalidLayerSpecificConfiguration` | JBSuckerDeployer | Layer-specific configuration validation fails |
|
|
340
|
+
| `JBSuckerDeployer_LayerSpecificNotConfigured` | JBSuckerDeployer | Layer-specific configuration not set when required |
|
|
341
|
+
| `JBSuckerDeployer_Unauthorized` | JBSuckerDeployer | Caller is not the expected configurator |
|
|
342
|
+
| `JBSuckerDeployer_ZeroConfiguratorAddress` | JBSuckerDeployer | Zero-address configurator in constructor |
|
|
343
|
+
| `CCIPHelper_UnsupportedChain` | CCIPHelper | Chain ID has no CCIP chain selector mapping |
|
|
344
|
+
| `MerkleLib_InsertTreeIsFull` | MerkleLib | Merkle tree reached max capacity (`2^32 - 1` leaves) |
|
|
345
|
+
|
|
346
|
+
## Previous Audit Findings
|
|
347
|
+
|
|
348
|
+
No prior formal audit with finding IDs has been conducted on this codebase. All risk analysis is internal. See [RISKS.md](./RISKS.md) for known risks and trust assumptions.
|
|
349
|
+
|
|
350
|
+
## Anti-Patterns to Hunt
|
|
351
|
+
|
|
352
|
+
| Pattern | Where to Look | Why It's Dangerous |
|
|
353
|
+
|---------|--------------|-------------------|
|
|
354
|
+
| Non-atomic bridging (Arbitrum L1→L2) | `JBArbitrumSucker._sendRootOverAMB()` | Two independent retryable tickets: tokens and merkle root arrive separately. If message arrives before tokens, `_handleClaim` could mint unbacked tokens. Mitigated by `amountToAddToBalanceOf()` check -- verify this is sufficient. |
|
|
355
|
+
| Self-call pattern (CCIP) | `JBCCIPSucker.ccipReceive()` calls `this.fromRemote()` | The self-call bypasses the `_isRemotePeer` check since `msg.sender == address(this)`. All authentication happens in `ccipReceive`. Verify no path can call `fromRemote()` directly. |
|
|
356
|
+
| Emergency hatch with no timelock | `enableEmergencyHatchFor()` | Project owner can enable emergency exit instantly. No delay, no multisig requirement. If the owner's key is compromised, they can emergency-exit tokens that are legitimately in transit. |
|
|
357
|
+
| uint128 truncation | `_insertIntoTree()` | Amounts are cast to uint128 for SVM compatibility. If a project token amount exceeds uint128, it silently truncates. Verify the cast reverts on overflow. |
|
|
358
|
+
| Bitmap slot collision | `_executedFor[token]` vs emergency exit bitmap | Emergency exit uses `address(bytes20(keccak256(abi.encode(terminalToken))))` as a separate bitmap key. Verify this cannot collide with any legitimate token address. |
|
|
359
|
+
| Root flush on disable | `_mapToken()` with `bytes32(0)` | Disabling a token calls `_sendRoot()` to flush unsent entries. If the bridge is down, this flush reverts and the token cannot be disabled. |
|
|
360
|
+
| CCIP amount validation skip | `JBCCIPSucker._sendRootOverAMB()` | Amount validation is intentionally skipped (reverting would lock tokens). If CCIP delivers fewer tokens than expected, claims are underfunded. |
|
|
361
|
+
| Nonce gap acceptance | `fromRemote()` | Inbox nonce only requires `> current`, not `== current + 1`. For CCIP where messages can arrive out of order, intermediate roots are lost. |
|
|
362
|
+
|
|
363
|
+
## Coverage Gaps
|
|
364
|
+
|
|
365
|
+
The test suite covers core flows but these areas have limited or no coverage:
|
|
366
|
+
|
|
367
|
+
- **CCIP amount mismatch handling**: CCIP intentionally skips amount validation (reverting would lock tokens). No tests verify behavior when the delivered token amount differs from the amount encoded in the merkle root.
|
|
368
|
+
- **Arbitrum retryable ticket redemption ordering**: L1->L2 creates two independent retryable tickets (one for tokens, one for merkle root). No tests simulate the message arriving before tokens, verifying that `_addToBalance` correctly checks `amountToAddToBalanceOf` to prevent unbacked minting.
|
|
369
|
+
- **Multi-hop Celo WETH wrapping**: Celo wraps native ETH to WETH before bridging and unwraps on the other side. No tests cover edge cases like partial WETH unwrap failures or WETH contract balance manipulation.
|
|
370
|
+
- **Emergency exit under active bridging**: No tests for the scenario where a user calls `prepare()`, tokens are in the outbox, `toRemote()` is called (sending the root), and then emergency hatch is enabled -- verifying that `numberOfClaimsSent` correctly prevents double-spend across both chains.
|
|
371
|
+
- **Concurrent deprecation and bridging**: No tests for the timing window between `SENDING_DISABLED` and `DEPRECATED` states where `fromRemote()` can still accept roots but `prepare()`/`toRemote()` are blocked.
|
|
372
|
+
- **Token mapping flush under reentrancy**: `_mapToken` calls `_sendRoot()` when disabling a token with unsent outbox entries. No tests verify this flush is safe under reentrancy from the bridge callback.
|
|
373
|
+
|
|
374
|
+
## Compiler and Version Info
|
|
375
|
+
|
|
376
|
+
- **Solidity**: 0.8.28
|
|
377
|
+
- **EVM target**: Cancun
|
|
378
|
+
- **Optimizer**: 200 runs (via-IR is NOT enabled)
|
|
379
|
+
- **Dependencies**: OpenZeppelin 5.x, Arbitrum SDK, Chainlink CCIP, nana-core-v6
|
|
380
|
+
- **Build**: `forge build` (Foundry)
|
|
381
|
+
|
|
382
|
+
## How to Report Findings
|
|
383
|
+
|
|
384
|
+
For each finding:
|
|
385
|
+
|
|
386
|
+
1. **Title** -- one line, starts with severity (CRITICAL/HIGH/MEDIUM/LOW)
|
|
387
|
+
2. **Affected contract(s)** -- exact file path and line numbers
|
|
388
|
+
3. **Description** -- what is wrong, in plain language
|
|
389
|
+
4. **Trigger sequence** -- step-by-step, minimal steps to reproduce (include which chain each step happens on)
|
|
390
|
+
5. **Impact** -- what an attacker gains, what a user loses (with numbers if possible)
|
|
391
|
+
6. **Proof** -- code trace showing the exact execution path, or a Foundry test
|
|
392
|
+
7. **Fix** -- minimal code change that resolves the issue
|
|
393
|
+
|
|
394
|
+
**Severity guide:**
|
|
395
|
+
- **CRITICAL**: Double-claim, unbacked minting, bridge fund loss. Exploitable with no preconditions.
|
|
396
|
+
- **HIGH**: Conditional fund loss, authentication bypass, or broken cross-chain invariant.
|
|
397
|
+
- **MEDIUM**: Value leakage, griefing, stuck tokens (recoverable via emergency hatch).
|
|
398
|
+
- **LOW**: Informational, edge-case-only with no material impact.
|
package/CHANGE_LOG.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# nana-suckers-v6 Changelog (v5 -> v6)
|
|
2
2
|
|
|
3
|
-
This document describes all changes between `nana-suckers` (v5, Solidity 0.8.23) and `nana-suckers-v6` (v6, Solidity 0.8.
|
|
3
|
+
This document describes all changes between `nana-suckers` (v5, Solidity 0.8.23) and `nana-suckers-v6` (v6, Solidity 0.8.28).
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
|
|
7
|
+
The dominant theme of this release is **cross-VM preparation for Solana/SVM** — addresses throughout the sucker architecture are widened from `address` (20 bytes) to `bytes32` (32 bytes) to support non-EVM chains.
|
|
8
|
+
|
|
9
|
+
- **`address` → `bytes32` throughout**: All cross-chain identifiers (peer addresses, beneficiaries, remote tokens) widened to `bytes32` for Solana/SVM compatibility. `uint128` amount caps added for SVM compatibility.
|
|
10
|
+
- **Message versioning**: New `version` field in `JBMessageRoot` prevents v5/v6 message incompatibility. v6 messages use `MESSAGE_VERSION = 1`.
|
|
11
|
+
- **Anti-spam redesigned**: Per-token `minBridgeAmount` replaced by a global `toRemoteFee` (max 0.001 ETH) paid to the fee project on every `toRemote()` call.
|
|
12
|
+
- **`MANUAL` add-to-balance mode removed**: Balance is always added atomically during `claim()`, simplifying the sucker lifecycle.
|
|
13
|
+
- **New Celo support**: `JBCeloSucker` handles Celo's non-ETH native gas token via WETH wrapping.
|
|
4
14
|
|
|
5
15
|
---
|
|
6
16
|
|
|
@@ -22,6 +32,8 @@ The most pervasive breaking change in v6 is the systematic replacement of `addre
|
|
|
22
32
|
|
|
23
33
|
All callers of `peer()` and `prepare()` must update to use `bytes32`. For EVM-to-EVM usage, addresses are left-padded to 32 bytes via `_toBytes32(address)`.
|
|
24
34
|
|
|
35
|
+
> **Cross-repo impact**: `nana-omnichain-deployers-v6` and `nana-fee-project-deployer-v6` both updated `JBTokenMapping.remoteToken` from `address` to `bytes32`. `nana-permission-ids-v6` split `SUCKER_SAFETY` into two permissions (`SUCKER_SAFETY` + `SET_SUCKER_DEPRECATION`) to match the v6 separation.
|
|
36
|
+
|
|
25
37
|
### 1.2 Struct Field Type Changes
|
|
26
38
|
|
|
27
39
|
| Struct | Field | v5 Type | v6 Type |
|
|
@@ -354,7 +366,7 @@ See section 2.4 above.
|
|
|
354
366
|
|
|
355
367
|
### 7.9 Solidity Version
|
|
356
368
|
|
|
357
|
-
All contracts upgraded from `pragma solidity 0.8.23` to `pragma solidity 0.8.
|
|
369
|
+
All contracts upgraded from `pragma solidity 0.8.23` to `pragma solidity 0.8.28`.
|
|
358
370
|
|
|
359
371
|
### 7.10 Named Arguments
|
|
360
372
|
|
package/README.md
CHANGED
|
@@ -2,31 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Cross-chain bridging for Juicebox V6 projects. Suckers let users cash out project tokens on one chain, move the backing funds across a bridge, and mint the same number of project tokens on another chain -- all via merkle-tree-based claims and chain-specific bridges.
|
|
4
4
|
|
|
5
|
-
<details>
|
|
6
|
-
<summary>Table of Contents</summary>
|
|
7
|
-
<ol>
|
|
8
|
-
<li><a href="#what-are-suckers">What are Suckers?</a></li>
|
|
9
|
-
<li><a href="#architecture">Architecture</a></li>
|
|
10
|
-
<li><a href="#bridging-flow">Bridging Flow</a></li>
|
|
11
|
-
<li><a href="#bridging-tokens">Bridging Tokens</a></li>
|
|
12
|
-
<li><a href="#token-mapping">Token Mapping</a></li>
|
|
13
|
-
<li><a href="#deprecation-lifecycle">Deprecation Lifecycle</a></li>
|
|
14
|
-
<li><a href="#emergency-hatch">Emergency Hatch</a></li>
|
|
15
|
-
<li><a href="#launching-suckers">Launching Suckers</a></li>
|
|
16
|
-
<li><a href="#managing-suckers">Managing Suckers</a></li>
|
|
17
|
-
<li><a href="#using-the-relayer">Using the Relayer</a></li>
|
|
18
|
-
<li><a href="#resources">Resources</a></li>
|
|
19
|
-
<li><a href="#repository-layout">Repository Layout</a></li>
|
|
20
|
-
<li><a href="#usage">Usage</a></li>
|
|
21
|
-
<ul>
|
|
22
|
-
<li><a href="#install">Install</a></li>
|
|
23
|
-
<li><a href="#develop">Develop</a></li>
|
|
24
|
-
<li><a href="#scripts">Scripts</a></li>
|
|
25
|
-
<li><a href="#tips">Tips</a></li>
|
|
26
|
-
</ul>
|
|
27
|
-
</ol>
|
|
28
|
-
</details>
|
|
29
|
-
|
|
30
5
|
_If you're having trouble understanding this contract, take a look at the [core protocol contracts](https://github.com/Bananapus/nana-core-v6) and the [documentation](https://docs.juicebox.money/) first. If you have questions, reach out on [Discord](https://discord.com/invite/ErQYmth4dS)._
|
|
31
6
|
|
|
32
7
|
## What are Suckers?
|
|
@@ -381,6 +356,14 @@ Users can do this manually, but it's a hassle. The [`bananapus-sucker-relayer`](
|
|
|
381
356
|
- [`juicerkle`](https://github.com/Bananapus/juicerkle) -- Service that returns available claims for a beneficiary (generates merkle proofs). Includes a [Go merkle tree implementation](https://github.com/Bananapus/juicerkle/blob/master/tree/tree.go) for computing roots and building/verifying proofs.
|
|
382
357
|
- [`juicerkle-tester`](https://github.com/Bananapus/juicerkle-tester) -- End-to-end bridging test: deploys projects, tokens, and suckers, then bridges between them. Useful as a bridging walkthrough.
|
|
383
358
|
|
|
359
|
+
## Risks
|
|
360
|
+
|
|
361
|
+
- **Out-of-order nonce delivery (CCIP).** `fromRemote` only accepts roots with a nonce strictly greater than the current inbox nonce. If CCIP delivers messages out of order, earlier nonces are silently skipped and every claim in those skipped roots becomes permanently unclaimable on the destination chain. Affected users must use the emergency hatch on the source chain to recover their funds.
|
|
362
|
+
- **Emergency hatch is irreversible per-token.** Calling `enableEmergencyHatchFor` permanently disables bridging for that token on that sucker. Once opened, the token can never be bridged by this sucker again -- a new sucker must be deployed to restore bridging for that token.
|
|
363
|
+
- **`msg.value` required for Arbitrum L1-to-L2 transport.** `JBArbitrumSucker` uses `unsafeCreateRetryableTicket` for L1-to-L2 messages, which requires `msg.value` to cover the retryable ticket cost. OP Stack suckers (`JBOptimismSucker`, `JBBaseSucker`, `JBCeloSucker`) do not require `msg.value` for transport. Callers must know which sucker type they are interacting with, or the `toRemote` call will revert.
|
|
364
|
+
- **Merkle tree depth is fixed at 32.** The outbox tree supports approximately 4 billion leaves, which is practically unlimited, but the tree is append-only and never pruned. Every leaf persists for the lifetime of the sucker, and proofs always require exactly 32 siblings regardless of tree size.
|
|
365
|
+
- **Token mapping immutability.** Once an outbox tree has any entries for a given token, the mapping between local and remote token is locked forever. The mapping can be disabled (set `remoteToken` to `bytes32(0)`) and later re-enabled, but only to the same remote token. Mapping to a different remote token requires deploying a new sucker.
|
|
366
|
+
|
|
384
367
|
## Repository Layout
|
|
385
368
|
|
|
386
369
|
```
|
|
@@ -406,11 +389,19 @@ nana-suckers-v6/
|
|
|
406
389
|
│ └── MerkleLib.sol - Incremental merkle tree (depth 32).
|
|
407
390
|
└── test/
|
|
408
391
|
├── Fork.t.sol - Fork tests.
|
|
392
|
+
├── ForkArbitrum.t.sol - Arbitrum fork tests.
|
|
393
|
+
├── ForkCelo.t.sol - Celo fork tests.
|
|
394
|
+
├── ForkClaim.t.sol - Fork claim tests.
|
|
395
|
+
├── ForkMainnet.t.sol - Mainnet fork tests.
|
|
396
|
+
├── ForkOPStack.t.sol - OP Stack fork tests.
|
|
409
397
|
├── InteropCompat.t.sol - Cross-VM compatibility tests.
|
|
410
398
|
├── SuckerAttacks.t.sol - Security-focused attack tests.
|
|
411
399
|
├── SuckerDeepAttacks.t.sol - Deep attack scenario tests.
|
|
400
|
+
├── SuckerRegressions.t.sol - Regression tests.
|
|
401
|
+
├── TestAuditGaps.sol - Audit gap coverage tests.
|
|
412
402
|
├── mocks/ - Mock contracts for testing.
|
|
413
|
-
|
|
403
|
+
├── regression/ - Regression-specific tests.
|
|
404
|
+
└── unit/ - Unit tests (merkle, registry, deployer, emergency, arb, ccip, invariants).
|
|
414
405
|
```
|
|
415
406
|
|
|
416
407
|
## Usage
|
package/RISKS.md
CHANGED
|
@@ -42,26 +42,25 @@ Forward-looking risk catalog for the JBSucker cross-chain bridging system.
|
|
|
42
42
|
- Wrong native mapping can cause permanent loss: e.g., bridging ETH to a non-WETH ERC-20 address on the remote chain.
|
|
43
43
|
- **`fromRemote` accepts roots for unmapped tokens.** By design, to avoid permanent loss of already-bridged tokens. Claims against those roots fail at the mapping lookup. This means stale token data can accumulate in inbox storage indefinitely.
|
|
44
44
|
- **minGas too low = permanent fund loss.** If `minGas` is below the actual gas needed for the remote call, the bridge message will fail on the remote chain. The OP/CCIP implementations enforce `MESSENGER_ERC20_MIN_GAS_LIMIT` (200k), but the actual gas needed could be higher depending on the remote token implementation.
|
|
45
|
+
- **Cross-reference: omnichain deployer token mapping.** When suckers are deployed through `JBOmnichainDeployer`, the `MAP_SUCKER_TOKEN` permission is granted to the sucker registry with `projectId=0` (wildcard). This means the registry can map tokens for ALL projects, not just the one being deployed. See [nana-omnichain-deployers-v6 RISKS.md](../nana-omnichain-deployers-v6/RISKS.md) section 3 for the permission escalation analysis.
|
|
45
46
|
|
|
46
47
|
## 5. Fee Collection Risks
|
|
47
48
|
|
|
48
49
|
- **Best-effort fee collection.** `toRemoteFee` is a centralized storage variable on `JBSuckerRegistry` (ETH, in wei) — uniform across all suckers and all tokens, non-bypassable by integrators. It is paid into `FEE_PROJECT_ID` (typically project ID 1) via `terminal.pay()`. If the fee project has no primary terminal for `NATIVE_TOKEN`, or if `terminal.pay()` reverts for any reason, `toRemote()` silently proceeds without collecting the fee. This means fee collection is best-effort — it can fail if the fee project's terminal is misconfigured, paused, or removed — but the fee amount cannot be set to 0 by users calling `toRemote()`.
|
|
49
|
-
- **Registry-controlled fee.** The registry owner can adjust `toRemoteFee` via `JBSuckerRegistry.setToRemoteFee()`, up to the hard cap of `MAX_TO_REMOTE_FEE` (0.001 ether). A single call applies to all suckers globally. This mitigates ETH price risk: if ETH price changes significantly, the registry owner can adjust the fee without deploying new suckers. Because fee control is centralized, individual sucker clones have no per-clone ownership (`Ownable` has been removed from `JBSucker`) and no `transferOwnership()` or `renounceOwnership()`.
|
|
50
50
|
- **Renounced registry ownership risk.** If the registry owner calls `renounceOwnership()`, `setToRemoteFee()` becomes permanently uncallable and the fee is frozen at its current value across all suckers. This is a deliberate trade-off: it allows the registry owner to credibly commit to a fee level, but eliminates the ability to respond to future ETH price changes. The fee is still capped at `MAX_TO_REMOTE_FEE`, so the maximum downside is bounded.
|
|
51
51
|
- **Immutable fee project.** `FEE_PROJECT_ID` is set at construction and cannot be changed. If the fee project is abandoned or its terminal removed, there is no way to redirect fees without deploying new suckers.
|
|
52
|
-
- **
|
|
53
|
-
- **ETH price risk (mitigated).** `toRemoteFee` is denominated in wei but is adjustable by the registry owner (up to `MAX_TO_REMOTE_FEE`). A significant ETH price increase can be mitigated by lowering the fee; a significant decrease can be mitigated by raising it. If registry ownership has been renounced, the fee is frozen across all suckers and the only recourse is deploying a new registry and new suckers.
|
|
52
|
+
- **Cross-reference: sucker registration path.** Suckers are deployed via `JBSuckerRegistry.deploySuckersFor`, which requires `DEPLOY_SUCKERS` permission from the project owner. The registry's `deploy` function uses `CREATE2` with a deployer-specific salt. The sucker's `peer()` address is deterministic — a misconfigured peer means the sucker accepts messages from the wrong remote address. See [nana-omnichain-deployers-v6 RISKS.md](../nana-omnichain-deployers-v6/RISKS.md) for deployer-level risks.
|
|
54
53
|
|
|
55
54
|
## 6. Deprecation Lifecycle
|
|
56
55
|
|
|
57
56
|
- **State machine: ENABLED -> DEPRECATION_PENDING -> SENDING_DISABLED -> DEPRECATED.**
|
|
58
57
|
- `DEPRECATION_PENDING`: fully functional, warning only. `block.timestamp < deprecatedAfter - _maxMessagingDelay()`.
|
|
59
58
|
- `SENDING_DISABLED`: no new `prepare()` or `toRemote()`. `block.timestamp >= deprecatedAfter - _maxMessagingDelay()` but `< deprecatedAfter`.
|
|
60
|
-
- `DEPRECATED`:
|
|
59
|
+
- `DEPRECATED`: outbound sends blocked. Incoming roots are still accepted to prevent stranding already-sent tokens. Emergency exits allowed.
|
|
61
60
|
- **Irrecoverability once SENDING_DISABLED.** `setDeprecation` reverts in SENDING_DISABLED and DEPRECATED states. Once the sucker enters SENDING_DISABLED, there is no way to cancel or extend the deprecation.
|
|
62
61
|
- **Messaging delay = 14 days.** `_maxMessagingDelay()` returns 14 days for all implementations. The deprecation timestamp must be at least `block.timestamp + 14 days` in the future. This is generous for OP/Arb (minutes to hours) but may be insufficient if a bridge has an extended outage.
|
|
63
62
|
- **Stuck tokens during deprecation.** Tokens that were `prepare()`d but not yet `toRemote()`d before SENDING_DISABLED cannot be sent to the remote chain. They can only be recovered via emergency exit after the sucker reaches DEPRECATED state.
|
|
64
|
-
- **Both sides must deprecate.** The deprecation must be called on both the local and remote sucker with matching timestamps. If only one side deprecates, the other side continues accepting roots while the deprecated side blocks sends
|
|
63
|
+
- **Both sides must deprecate.** The deprecation must be called on both the local and remote sucker with matching timestamps. If only one side deprecates, the other side continues accepting roots while the deprecated side blocks outbound sends. The deprecated side still accepts incoming roots, so tokens sent before deprecation can be claimed.
|
|
65
64
|
|
|
66
65
|
## 7. Emergency Hatch
|
|
67
66
|
|
|
@@ -69,7 +68,7 @@ Forward-looking risk catalog for the JBSucker cross-chain bridging system.
|
|
|
69
68
|
1. Per-token: `enableEmergencyHatchFor(tokens)` -- requires `SUCKER_SAFETY` permission from project owner. Allows emergency exit for specific tokens while the sucker is still ENABLED.
|
|
70
69
|
2. Global: deprecation reaching SENDING_DISABLED or DEPRECATED state. Allows emergency exit for all tokens.
|
|
71
70
|
- **Claim vs emergency exit use separate bitmap slots.** Emergency exit uses `_executedFor[keccak256(abi.encode(terminalToken))]` while regular claims use `_executedFor[terminalToken]`. This means a leaf that was emergency-exited locally could theoretically also be claimed remotely if the root was already sent -- double-spend is prevented only by the `numberOfClaimsSent` check.
|
|
72
|
-
- **`numberOfClaimsSent` is the critical guard.** Emergency exit reverts if `outbox.numberOfClaimsSent - 1 >= index`. This means leaves at indices below `numberOfClaimsSent` cannot be emergency-exited (they may have been sent to the remote peer). If `_sendRootOverAMB` silently fails, these leaves are permanently locked.
|
|
71
|
+
- **`numberOfClaimsSent` is the critical guard.** Emergency exit reverts if `outbox.numberOfClaimsSent != 0 && outbox.numberOfClaimsSent - 1 >= index`. The `numberOfClaimsSent != 0` precondition prevents underflow when no root has ever been sent — in that case, all leaves are available for emergency exit. This means leaves at indices below `numberOfClaimsSent` cannot be emergency-exited (they may have been sent to the remote peer). If `_sendRootOverAMB` silently fails, these leaves are permanently locked.
|
|
73
72
|
- **Emergency exit decrements `outbox.balance`.** If emergency exits drain the outbox balance below the amount that was already sent to the bridge, the accounting becomes inconsistent. The contract guards against this by only allowing exit for unsent leaves.
|
|
74
73
|
- **Emergency hatch + minting.** Emergency exit calls `_handleClaim`, which mints project tokens via the controller. If the controller or token contract is broken/missing, emergency exits also revert -- there is no "raw withdrawal" of terminal tokens without minting.
|
|
75
74
|
|
|
@@ -96,3 +95,21 @@ Forward-looking risk catalog for the JBSucker cross-chain bridging system.
|
|
|
96
95
|
- **Claim and emergency exit slot independence.** A regular claim (inbox path) and an emergency exit (outbox path) for the same index on the same token use different bitmap keys and do not interfere. Tested in `test_merkleTree_claimAndEmergencyExitSlotIndependence`.
|
|
97
96
|
- **Tree count monotonically increases.** `MerkleLib.Tree.count` only increments (append-only). No operation decreases the count. Tested in `invariant_treeCountMonotonicallyIncreases`.
|
|
98
97
|
- **Message version gate.** `fromRemote` rejects any message where `root.version != MESSAGE_VERSION`. Tested in `test_merkleTree_messageVersionValidation`.
|
|
98
|
+
|
|
99
|
+
## 10. Accepted Behaviors
|
|
100
|
+
|
|
101
|
+
### 10.1 Stale nonce messages silently ignored (not reverted)
|
|
102
|
+
|
|
103
|
+
`fromRemote` does not revert when receiving a message with a nonce <= the current inbox nonce. Instead, it emits `StaleRootRejected` and returns silently. This is intentional for native ETH bridges: reverting a message that carries native ETH (e.g., OP bridge `relayMessage` with value) would lose the ETH. Silent acceptance preserves bridge funds while discarding the stale root. Monitoring systems should watch for `StaleRootRejected` events as indicators of bridge message ordering issues.
|
|
104
|
+
|
|
105
|
+
### 10.2 Emergency hatch is irreversible
|
|
106
|
+
|
|
107
|
+
Once `enableEmergencyHatchFor(token)` is called, the token mapping is permanently disabled (`enabled = false`, `emergencyHatch = true`). There is no mechanism to re-enable the mapping or close the hatch. This is a conscious trade-off: reversibility would require additional access control and state transitions that could be exploited to trap tokens. The irreversibility forces a clean deployment of a new sucker when recovery is complete.
|
|
108
|
+
|
|
109
|
+
### 10.3 Registry-controlled fee with ETH price adjustability
|
|
110
|
+
|
|
111
|
+
The registry owner can adjust `toRemoteFee` via `JBSuckerRegistry.setToRemoteFee()`, up to the hard cap of `MAX_TO_REMOTE_FEE` (0.001 ether). A single call applies to all suckers globally. This mitigates ETH price risk: if ETH price changes significantly, the registry owner can adjust the fee without deploying new suckers. Because fee control is centralized, individual sucker clones have no per-clone ownership (`Ownable` has been removed from `JBSucker`) and no `transferOwnership()` or `renounceOwnership()`. If registry ownership is renounced, the fee is frozen and the only recourse is deploying a new registry and new suckers.
|
|
112
|
+
|
|
113
|
+
### 10.4 Fee is paid to the protocol project, not the sucker's project
|
|
114
|
+
|
|
115
|
+
The fee is paid to `FEE_PROJECT_ID` (the protocol project), not to the sucker's own `projectId()`. The protocol project always has a native token terminal, ensuring reliable fee collection. The sucker's project does not directly benefit from the anti-spam fee.
|
package/SKILLS.md
CHANGED
|
@@ -28,9 +28,9 @@ Cross-chain token and fund bridging for Juicebox V6 projects, using merkle trees
|
|
|
28
28
|
| Function | Contract | What it does |
|
|
29
29
|
|----------|----------|--------------|
|
|
30
30
|
| `prepare(projectTokenCount, beneficiary, minTokensReclaimed, token)` | `JBSucker` | Transfers project tokens (ERC-20) from caller via `safeTransferFrom`, cashes them out at the project's primary terminal for the specified terminal token, inserts a leaf into the outbox merkle tree. `beneficiary` is `bytes32` for cross-VM compatibility. Amounts are capped at `uint128` for SVM compatibility. Reverts if token not mapped, sucker deprecated/sending-disabled, beneficiary is zero, or project has no ERC-20 token. |
|
|
31
|
-
| `toRemote(token)` | `JBSucker` | Sends the outbox merkle root and accumulated funds for `token` to the peer sucker
|
|
31
|
+
| `toRemote(token)` | `JBSucker` | Sends the outbox merkle root and accumulated funds for `token` to the peer sucker via the bridge. Deducts `toRemoteFee` from `msg.value` (paid into fee project), passes remainder as transport payment. Increments outbox nonce and updates `numberOfClaimsSent`. Reverts if outbox is empty or emergency hatch is open. |
|
|
32
32
|
| `setToRemoteFee(fee)` | `JBSuckerRegistry` | Sets the global `toRemoteFee` for all suckers. Restricted to the registry owner via OpenZeppelin `Ownable` (`onlyOwner`). The fee must be <= `MAX_TO_REMOTE_FEE` (0.001 ether). Emits `ToRemoteFeeChanged`. |
|
|
33
|
-
| `fromRemote(root)` | `JBSucker` | Receives a merkle root from the remote peer. Validates `MESSAGE_VERSION` (reverts on mismatch). Updates inbox tree only if received nonce > current inbox nonce
|
|
33
|
+
| `fromRemote(root)` | `JBSucker` | Receives a merkle root from the remote peer. Validates `MESSAGE_VERSION` (reverts on mismatch). Updates inbox tree only if received nonce > current inbox nonce. Accepts roots in all states including `DEPRECATED` to prevent stranding already-sent tokens. Does NOT revert on stale nonce -- emits `StaleRootRejected` instead (to avoid losing native tokens sent with the message). |
|
|
34
34
|
| `claim(claimData)` | `JBSucker` | Verifies a merkle proof against the inbox tree, marks the leaf as executed (prevents double-spend), mints project tokens for the beneficiary via `IJBController.mintTokensOf` (with `useReservedPercent: false`), and adds terminal tokens to the project's balance. |
|
|
35
35
|
| `claim(claims[])` | `JBSucker` | Batch version -- iterates and calls `claim(JBClaim)` for each. |
|
|
36
36
|
| `mapToken(map)` | `JBSucker` | Maps a local terminal token to a remote token. Requires `MAP_SUCKER_TOKEN` permission. Setting `remoteToken` to `bytes32(0)` disables bridging and sends a final root to flush remaining outbox. Cannot remap to a different remote token once outbox has entries (prevents double-spend). Can re-enable a previously disabled token to the same remote address. Reverts if emergency hatch is active for the token. |
|
|
@@ -100,6 +100,46 @@ Cross-chain token and fund bridging for Juicebox V6 projects, using merkle trees
|
|
|
100
100
|
| `SET_SUCKER_DEPRECATION` | `JBSucker.setDeprecation` | Setting the deprecation timestamp |
|
|
101
101
|
| `MINT_TOKENS` | Needed on the sucker address | Sucker must have this to mint project tokens on claim |
|
|
102
102
|
|
|
103
|
+
## Errors
|
|
104
|
+
|
|
105
|
+
| Error | Contract | When |
|
|
106
|
+
|-------|----------|------|
|
|
107
|
+
| `JBSucker_AmountExceedsUint128` | `JBSucker` | `projectTokenCount` or `terminalTokenAmount` exceeds `uint128` (SVM cap) |
|
|
108
|
+
| `JBSucker_BelowMinGas` | `JBSucker` | Token mapping `minGas` is below `MESSENGER_ERC20_MIN_GAS_LIMIT` |
|
|
109
|
+
| `JBSucker_Deprecated` | `JBSucker` | `prepare` or `toRemote` called when sucker is `SENDING_DISABLED` or `DEPRECATED` |
|
|
110
|
+
| `JBSucker_DeprecationTimestampTooSoon` | `JBSucker` | `setDeprecation` timestamp is less than `_maxMessagingDelay()` in the future |
|
|
111
|
+
| `JBSucker_ExpectedMsgValue` | `JBSucker` | `toRemote` called without required `msg.value` for transport payment |
|
|
112
|
+
| `JBSucker_InsufficientBalance` | `JBSucker` | Emergency hatch exit amount exceeds outbox balance |
|
|
113
|
+
| `JBSucker_InsufficientMsgValue` | `JBSucker` | `msg.value` is less than the `toRemoteFee` |
|
|
114
|
+
| `JBSucker_InvalidMessageVersion` | `JBSucker` | `fromRemote` receives a message with wrong `MESSAGE_VERSION` |
|
|
115
|
+
| `JBSucker_InvalidNativeRemoteAddress` | `JBSucker` | `NATIVE_TOKEN` mapped to a non-native, non-zero remote address (base validation) |
|
|
116
|
+
| `JBSucker_InvalidProof` | `JBSucker` | Merkle proof does not match the inbox root |
|
|
117
|
+
| `JBSucker_LeafAlreadyExecuted` | `JBSucker` | `claim` or emergency exit for a leaf that was already processed |
|
|
118
|
+
| `JBSucker_NoTerminalForToken` | `JBSucker` | Project has no terminal for the specified token |
|
|
119
|
+
| `JBSucker_NotPeer` | `JBSucker` | `fromRemote` called by an address that is not the recognized peer |
|
|
120
|
+
| `JBSucker_NothingToSend` | `JBSucker` | `toRemote` called when outbox has no pending entries |
|
|
121
|
+
| `JBSucker_TokenAlreadyMapped` | `JBSucker` | Attempting to remap a token to a different remote address when outbox has entries |
|
|
122
|
+
| `JBSucker_TokenHasInvalidEmergencyHatchState` | `JBSucker` | Operation incompatible with the token's emergency hatch state |
|
|
123
|
+
| `JBSucker_TokenNotMapped` | `JBSucker` | `prepare` called for a token that has no mapping |
|
|
124
|
+
| `JBSucker_UnexpectedMsgValue` | `JBSucker` | `msg.value` sent when not expected |
|
|
125
|
+
| `JBSucker_ZeroBeneficiary` | `JBSucker` | `prepare` called with `bytes32(0)` beneficiary |
|
|
126
|
+
| `JBSucker_ZeroERC20Token` | `JBSucker` | `prepare` called but project has no ERC-20 token deployed |
|
|
127
|
+
| `JBCCIPSucker_InvalidRouter` | `JBCCIPSucker` | Zero address passed as `CCIP_ROUTER` at construction time |
|
|
128
|
+
| `JBArbitrumSucker_NotEnoughGas` | `JBArbitrumSucker` | `msg.value` insufficient for Arbitrum retryable ticket cost |
|
|
129
|
+
| `JBSuckerRegistry_FeeExceedsMax` | `JBSuckerRegistry` | `setToRemoteFee` called with fee > `MAX_TO_REMOTE_FEE` |
|
|
130
|
+
| `JBSuckerRegistry_InvalidDeployer` | `JBSuckerRegistry` | Deployer not on the allowlist |
|
|
131
|
+
| `JBSuckerRegistry_SuckerDoesNotBelongToProject` | `JBSuckerRegistry` | Sucker is not registered for the given project |
|
|
132
|
+
| `JBSuckerRegistry_SuckerIsNotDeprecated` | `JBSuckerRegistry` | `removeDeprecatedSucker` called on a non-deprecated sucker |
|
|
133
|
+
| `JBSuckerDeployer_AlreadyConfigured` | `JBSuckerDeployer` | `configureSingleton` or `setChainSpecificConstants` called a second time |
|
|
134
|
+
| `JBSuckerDeployer_DeployerIsNotConfigured` | `JBSuckerDeployer` | `createForSender` called before singleton is configured |
|
|
135
|
+
| `JBSuckerDeployer_InvalidLayerSpecificConfiguration` | `JBSuckerDeployer` | Invalid bridge addresses passed to `setChainSpecificConstants` |
|
|
136
|
+
| `JBSuckerDeployer_LayerSpecificNotConfigured` | `JBSuckerDeployer` | `configureSingleton` called before `setChainSpecificConstants` |
|
|
137
|
+
| `JBSuckerDeployer_Unauthorized` | `JBSuckerDeployer` | Caller is not `LAYER_SPECIFIC_CONFIGURATOR` |
|
|
138
|
+
| `JBSuckerDeployer_ZeroConfiguratorAddress` | `JBSuckerDeployer` | Constructor passed `address(0)` for configurator |
|
|
139
|
+
| `JBCCIPSuckerDeployer_InvalidCCIPRouter` | `JBCCIPSuckerDeployer` | Zero address passed as CCIP router |
|
|
140
|
+
| `CCIPHelper_UnsupportedChain` | `CCIPHelper` | Chain ID has no known CCIP chain selector mapping |
|
|
141
|
+
| `MerkleLib_InsertTreeIsFull` | `MerkleLib` | Tree has reached max capacity (2^32 - 1 leaves) |
|
|
142
|
+
|
|
103
143
|
## Gotchas
|
|
104
144
|
|
|
105
145
|
- **`remoteToken` is `bytes32`, not `address`.** All remote token addresses in `JBTokenMapping`, `JBRemoteToken`, and `JBMessageRoot` are `bytes32` for cross-VM compatibility (e.g., Solana). Convert EVM addresses with `bytes32(uint256(uint160(addr)))`.
|
|
@@ -108,17 +148,14 @@ Cross-chain token and fund bridging for Juicebox V6 projects, using merkle trees
|
|
|
108
148
|
- Suckers are deployed as minimal clones (`LibClone.cloneDeterministic`). The singleton's constructor calls `_disableInitializers()` so it cannot be initialized directly. Only clones can be initialized.
|
|
109
149
|
- For suckers to be peers, the same `salt` AND the same caller address must be used on both chains when calling `deploySuckersFor`. The registry hashes `keccak256(abi.encode(msg.sender, salt))`, and the deployer hashes `keccak256(abi.encodePacked(msg.sender, salt))` again.
|
|
110
150
|
- `peer()` defaults to `bytes32(uint256(uint160(address(this))))` -- suckers expect to be deployed at matching addresses on both chains via deterministic deployment. Can be overridden for cross-VM peers (e.g., Solana PDA addresses).
|
|
111
|
-
- `
|
|
112
|
-
-
|
|
113
|
-
- `_validateTokenMapping` in `JBSucker` enforces that native token (`NATIVE_TOKEN`) can only map to `NATIVE_TOKEN` or `bytes32(0)`. `JBCCIPSucker` and `JBCeloSucker` override this to only enforce minimum gas (allowing `NATIVE_TOKEN` to map to any remote address since the remote chain may not have native ETH).
|
|
114
|
-
- Emergency hatch is irreversible per-token. Once opened, the token can never be bridged again by that sucker. The invariant is: if `emergencyHatch == true` then `enabled == false`.
|
|
115
|
-
- `setDeprecation` requires the timestamp to be at least `_maxMessagingDelay()` (14 days) in the future. Once in `SENDING_DISABLED` or `DEPRECATED` state, deprecation cannot be modified.
|
|
116
|
-
- Deployer `setChainSpecificConstants` and `configureSingleton` are both one-shot functions -- they revert if called twice.
|
|
151
|
+
- `JBCCIPSucker` and `JBCeloSucker` override `_validateTokenMapping` to only enforce minimum gas, allowing `NATIVE_TOKEN` to map to any remote address (the remote chain may not have native ETH). Base `JBSucker` restricts `NATIVE_TOKEN` to mapping only to `NATIVE_TOKEN` or `bytes32(0)`.
|
|
152
|
+
- Emergency hatch is irreversible per-token. Once opened, the token can never be bridged again by that sucker. Invariant: `emergencyHatch == true` implies `enabled == false`.
|
|
117
153
|
- `MESSENGER_BASE_GAS_LIMIT` is 300,000. `MESSENGER_ERC20_MIN_GAS_LIMIT` is 200,000. Token mappings must specify `minGas >= 200,000` for ERC-20s. `JBCCIPSucker` requires `minGas >= 200,000` for ALL tokens (including native) because CCIP wraps native to WETH.
|
|
118
154
|
- `JBArbitrumSucker` uses `unsafeCreateRetryableTicket` (not `safeCreateRetryableTicket`) to avoid L2 address aliasing of the refund address.
|
|
119
155
|
- The outbox tree tracks `numberOfClaimsSent` separately from `tree.count`. Emergency hatch exit is only available for leaves whose index >= `numberOfClaimsSent` (not yet sent to remote). A `numberOfClaimsSent` of 0 means no root has ever been sent, so all leaves can be emergency-exited.
|
|
120
156
|
- Both `projectTokenCount` and `terminalTokenAmount` are capped at `uint128` in `_insertIntoTree` for SVM (Solana) compatibility.
|
|
121
157
|
- Nonce ordering is non-sequential: `fromRemote` accepts any nonce strictly greater than the current inbox nonce. Out-of-order nonces (from CCIP) cause earlier nonces to be silently skipped, making their claims permanently unclaimable on that chain. The sender must use the emergency hatch on the source chain to recover.
|
|
158
|
+
- `toRemote` fee payment is best-effort: if the fee project has no native token terminal or `terminal.pay()` reverts, `toRemote` proceeds without collecting the fee. The caller still receives project tokens from the fee payment when it succeeds.
|
|
122
159
|
- `JBCCIPSucker` transport payment refund uses a low-level `call` that does NOT revert on failure. If the refund fails (e.g., caller is a non-payable contract), the excess ETH is permanently stuck. The `TransportPaymentRefundFailed` event provides observability.
|
|
123
160
|
- The sucker has an unrestricted `receive()` function -- it must accept ETH from bridges, WETH unwrapping, and terminal cash-outs. Excess ETH increases `amountToAddToBalanceOf` for the project (not a double-spend risk).
|
|
124
161
|
|
|
@@ -169,7 +206,7 @@ ENABLED --> DEPRECATION_PENDING --> SENDING_DISABLED --> DEPRECATED
|
|
|
169
206
|
| `ENABLED` | `deprecatedAfter == 0` | yes | yes | yes | yes | per-token only |
|
|
170
207
|
| `DEPRECATION_PENDING` | `now < deprecatedAfter - 14 days` | yes | yes | yes | yes | per-token only |
|
|
171
208
|
| `SENDING_DISABLED` | `now < deprecatedAfter` | no | no | yes | yes | all tokens |
|
|
172
|
-
| `DEPRECATED` | `now >= deprecatedAfter` | no | no |
|
|
209
|
+
| `DEPRECATED` | `now >= deprecatedAfter` | no | no | yes | yes | all tokens |
|
|
173
210
|
|
|
174
211
|
## Example Integration
|
|
175
212
|
|
package/STYLE_GUIDE.md
CHANGED
|
@@ -21,7 +21,7 @@ One contract/interface/struct/enum per file. Name the file after the type it con
|
|
|
21
21
|
|
|
22
22
|
```solidity
|
|
23
23
|
// Contracts — pin to exact version
|
|
24
|
-
pragma solidity 0.8.
|
|
24
|
+
pragma solidity 0.8.28;
|
|
25
25
|
|
|
26
26
|
// Interfaces, structs, enums — caret for forward compatibility
|
|
27
27
|
pragma solidity ^0.8.0;
|
|
@@ -328,7 +328,7 @@ Standard config across all repos:
|
|
|
328
328
|
|
|
329
329
|
```toml
|
|
330
330
|
[profile.default]
|
|
331
|
-
solc = '0.8.
|
|
331
|
+
solc = '0.8.28'
|
|
332
332
|
evm_version = 'cancun'
|
|
333
333
|
optimizer_runs = 200
|
|
334
334
|
libs = ["node_modules", "lib"]
|