@bananapus/suckers-v6 0.0.9 → 0.0.11
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 +9 -2
- package/ARCHITECTURE.md +2 -2
- package/AUDIT_INSTRUCTIONS.md +302 -0
- package/CHANGE_LOG.md +371 -0
- package/README.md +8 -10
- package/RISKS.md +85 -35
- package/SKILLS.md +16 -16
- package/STYLE_GUIDE.md +16 -1
- package/USER_JOURNEYS.md +247 -0
- package/package.json +4 -4
- package/script/Deploy.s.sol +160 -88
- package/script/helpers/SuckerDeploymentLib.sol +32 -13
- package/src/JBArbitrumSucker.sol +31 -38
- package/src/JBBaseSucker.sol +4 -4
- package/src/JBCCIPSucker.sol +15 -8
- package/src/JBCeloSucker.sol +47 -45
- package/src/JBOptimismSucker.sol +4 -4
- package/src/JBSucker.sol +222 -175
- package/src/JBSuckerRegistry.sol +27 -5
- package/src/deployers/JBArbitrumSuckerDeployer.sol +3 -2
- package/src/deployers/JBSuckerDeployer.sol +4 -4
- package/src/interfaces/IJBSucker.sol +0 -11
- package/src/interfaces/IJBSuckerRegistry.sol +18 -0
- package/src/structs/JBRemoteToken.sol +0 -2
- package/src/structs/JBTokenMapping.sol +0 -2
- package/test/Fork.t.sol +14 -8
- package/test/ForkArbitrum.t.sol +399 -0
- package/test/ForkCelo.t.sol +14 -44
- package/test/ForkMainnet.t.sol +9 -5
- package/test/ForkOPStack.t.sol +419 -0
- package/test/InteropCompat.t.sol +5 -10
- package/test/SuckerAttacks.t.sol +20 -15
- package/test/SuckerDeepAttacks.t.sol +185 -97
- package/test/SuckerRegressions.t.sol +60 -69
- package/test/TestAuditGaps.sol +994 -0
- package/test/regression/MapTokensDust.t.sol +20 -33
- package/test/unit/ccip_native_interop.t.sol +20 -54
- package/test/unit/ccip_refund.t.sol +11 -6
- package/test/unit/deployer.t.sol +54 -9
- package/test/unit/emergency.t.sol +8 -10
- package/test/unit/invariants.t.sol +8 -13
- package/test/unit/merkle.t.sol +9 -2
- package/test/unit/multi_chain_evolution.t.sol +21 -59
- package/src/enums/JBAddToBalanceMode.sol +0 -11
- package/test/unit/manual_balance.t.sol +0 -487
package/ADMINISTRATION.md
CHANGED
|
@@ -7,7 +7,7 @@ Admin privileges and their scope in nana-suckers-v6.
|
|
|
7
7
|
### Registry Owner
|
|
8
8
|
|
|
9
9
|
- **How assigned:** Set at `JBSuckerRegistry` construction via the `initialOwner` parameter. Transferable via OpenZeppelin `Ownable`.
|
|
10
|
-
- **Scope:** Controls which sucker deployer contracts are approved for use by the registry. Initially expected to be JuiceboxDAO (project ID 1).
|
|
10
|
+
- **Scope:** Controls which sucker deployer contracts are approved for use by the registry, and sets the global `toRemoteFee` applied to all suckers. Initially expected to be JuiceboxDAO (project ID 1).
|
|
11
11
|
- **Permission ID:** None (uses `onlyOwner` modifier from OpenZeppelin `Ownable`).
|
|
12
12
|
|
|
13
13
|
### Project Owner
|
|
@@ -55,6 +55,7 @@ Admin privileges and their scope in nana-suckers-v6.
|
|
|
55
55
|
| `allowSuckerDeployer(deployer)` | Registry Owner | N/A (`onlyOwner`) | Global | Adds a deployer contract to the allowlist, enabling it to be used when deploying suckers. |
|
|
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
|
+
| `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`. |
|
|
58
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
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. |
|
|
60
61
|
|
|
@@ -120,9 +121,11 @@ The following values are set at deploy time and cannot be changed:
|
|
|
120
121
|
| Property | Contract | Set By | Description |
|
|
121
122
|
|----------|----------|--------|-------------|
|
|
122
123
|
| `DIRECTORY` | `JBSucker`, `JBSuckerRegistry`, all deployers | Constructor | The Juicebox directory contract. |
|
|
124
|
+
| `FEE_PROJECT_ID` | `JBSucker` | Constructor | The project that receives `toRemoteFee` payments via `terminal.pay()` on each `toRemote()` call. Typically project ID 1 (the protocol project). Best-effort: fee is silently skipped if the fee project has no native token terminal or if `terminal.pay()` reverts. |
|
|
125
|
+
| `REGISTRY` | `JBSucker` | Constructor | The `JBSuckerRegistry` that this sucker reads the `toRemoteFee` from. |
|
|
126
|
+
| `MAX_TO_REMOTE_FEE` | `JBSuckerRegistry` | Constant | Hard cap on what `toRemoteFee` can be set to (0.001 ether). Prevents the registry owner from setting an excessively high fee. |
|
|
123
127
|
| `TOKENS` | `JBSucker`, all deployers | Constructor | The Juicebox token management contract. |
|
|
124
128
|
| `PROJECTS` | `JBSuckerRegistry` | Derived from `DIRECTORY.PROJECTS()` | The ERC-721 project ownership contract. |
|
|
125
|
-
| `ADD_TO_BALANCE_MODE` | `JBSucker` | Constructor | Whether balance is added on-claim or manually (`ON_CLAIM` or `MANUAL`). |
|
|
126
129
|
| `OPBRIDGE` | `JBOptimismSucker`, `JBBaseSucker`, `JBCeloSucker` | Constructor (from deployer callback) | The OP Standard Bridge address. |
|
|
127
130
|
| `OPMESSENGER` | `JBOptimismSucker`, `JBBaseSucker`, `JBCeloSucker` | Constructor (from deployer callback) | The OP Cross-Domain Messenger address. |
|
|
128
131
|
| `ARBINBOX` | `JBArbitrumSucker` | Constructor (from deployer callback) | The Arbitrum Inbox address. |
|
|
@@ -160,3 +163,7 @@ What admins **cannot** do:
|
|
|
160
163
|
- **Cannot change the peer address.** The peer is determined by deterministic deployment (CREATE2 with sender-specific salt). There is no admin function to change which remote address is trusted.
|
|
161
164
|
|
|
162
165
|
- **Cannot change the project ID.** The `projectId` is set once during `initialize()` and is immutable thereafter (enforced by OpenZeppelin `Initializable`).
|
|
166
|
+
|
|
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
|
+
|
|
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).
|
package/ARCHITECTURE.md
CHANGED
|
@@ -8,12 +8,12 @@ Cross-chain token bridging for Juicebox V6. Allows project tokens and funds to m
|
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
src/
|
|
11
|
-
├── JBSucker.sol — Abstract base: merkle tree management, prepare/claim logic
|
|
11
|
+
├── JBSucker.sol — Abstract base: merkle tree management, prepare/claim logic, FEE_PROJECT_ID, reads toRemoteFee from REGISTRY (immutable IJBSuckerRegistry)
|
|
12
12
|
├── JBBaseSucker.sol — OP Stack base (Optimism, Base)
|
|
13
13
|
├── JBOptimismSucker.sol — Optimism/Base bridge implementation
|
|
14
14
|
├── JBArbitrumSucker.sol — Arbitrum bridge implementation
|
|
15
15
|
├── JBCCIPSucker.sol — Chainlink CCIP bridge implementation
|
|
16
|
-
├── JBSuckerRegistry.sol — Registry of suckers per project, deployment permissions
|
|
16
|
+
├── JBSuckerRegistry.sol — Registry of suckers per project, deployment permissions, centralized toRemoteFee (owner-controlled, applies to all suckers)
|
|
17
17
|
├── deployers/
|
|
18
18
|
│ ├── JBOptimismSuckerDeployer.sol
|
|
19
19
|
│ ├── JBArbitrumSuckerDeployer.sol
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# Audit Instructions
|
|
2
|
+
|
|
3
|
+
You are auditing the Juicebox V6 suckers -- a cross-chain bridging system that lets project token holders move their tokens between chains by cashing out on one chain and minting on another. Dual incremental merkle trees (outbox on source, inbox on destination) track token movements, with chain-specific bridges (OP Stack, Arbitrum, CCIP) transporting the merkle roots and backing assets. Your goal is to find bugs that enable double-claiming, lose bridged funds, or bypass the deprecation lifecycle.
|
|
4
|
+
|
|
5
|
+
Read [RISKS.md](./RISKS.md) for known risks and trust assumptions. Then come back here.
|
|
6
|
+
|
|
7
|
+
## Scope
|
|
8
|
+
|
|
9
|
+
**In scope -- all Solidity in `src/`:**
|
|
10
|
+
```
|
|
11
|
+
src/JBSucker.sol # Abstract base (~1,196 lines)
|
|
12
|
+
src/JBOptimismSucker.sol # OP Stack bridge implementation (~141 lines)
|
|
13
|
+
src/JBBaseSucker.sol # Base (OP Stack variant) (~48 lines)
|
|
14
|
+
src/JBCeloSucker.sol # Celo (OP Stack + WETH wrapping) (~196 lines)
|
|
15
|
+
src/JBArbitrumSucker.sol # Arbitrum bridge implementation (~322 lines)
|
|
16
|
+
src/JBCCIPSucker.sol # Chainlink CCIP implementation (~306 lines)
|
|
17
|
+
src/JBSuckerRegistry.sol # Deployer registry and tracking (~260 lines)
|
|
18
|
+
src/deployers/ # JBSuckerDeployer, JB{Optimism,Base,Celo,Arbitrum,CCIP}SuckerDeployer
|
|
19
|
+
src/utils/MerkleLib.sol # Incremental merkle tree (eth2-style) (~1,030 lines)
|
|
20
|
+
src/structs/ # JBMessageRoot, JBLeaf, JBClaim, JBOutboxTree, etc.
|
|
21
|
+
src/enums/ # JBSuckerState, JBLayer
|
|
22
|
+
src/libraries/ # ARBChains, ARBAddresses, CCIPHelper
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Out of scope:** Test files (`test/`), OpenZeppelin/Arbitrum/CCIP dependencies (assume correct), forge-std.
|
|
26
|
+
|
|
27
|
+
## Architecture
|
|
28
|
+
|
|
29
|
+
### JBSucker (src/JBSucker.sol) -- Abstract Base
|
|
30
|
+
|
|
31
|
+
The core bridging logic. Each sucker instance is associated with one project and deployed as a clone via `Initializable`. Suckers are deployed in pairs (one per chain) with matching CREATE2 addresses so `peer()` returns `_toBytes32(address(this))` by default.
|
|
32
|
+
|
|
33
|
+
**Immutables:** `DIRECTORY`, `TOKENS`.
|
|
34
|
+
|
|
35
|
+
**Key state:**
|
|
36
|
+
- `_outboxOf[token]` -- `JBOutboxTree`: merkle tree, balance, nonce, numberOfClaimsSent per token
|
|
37
|
+
- `_inboxOf[token]` -- `JBInboxTreeRoot`: root hash and nonce per token
|
|
38
|
+
- `_remoteTokenFor[token]` -- `JBRemoteToken`: remote address, enabled flag, emergency hatch, min gas, min bridge amount
|
|
39
|
+
- `_executedFor[token]` -- `BitMap`: tracks which leaf indices have been claimed (prevents double-spend)
|
|
40
|
+
- `deprecatedAfter` -- timestamp for deprecation lifecycle
|
|
41
|
+
|
|
42
|
+
**Key functions:**
|
|
43
|
+
- `initialize(projectId)` -- One-time initialization of the clone with the project ID.
|
|
44
|
+
- `prepare(projectTokenCount, beneficiary, minTokensReclaimed, token)` -- Cash out project tokens, insert a leaf into the outbox merkle tree.
|
|
45
|
+
- `toRemote(token)` -- Send the outbox root and backing assets to the remote chain via the bridge.
|
|
46
|
+
- `fromRemote(JBMessageRoot)` -- Receive a merkle root from the remote peer. Only callable by the authenticated bridge messenger.
|
|
47
|
+
- `claim(JBClaim)` / `claim(JBClaim[])` -- Verify a merkle proof against the inbox root and mint project tokens for the beneficiary.
|
|
48
|
+
- `mapToken(JBTokenMapping)` / `mapTokens(JBTokenMapping[])` -- Map local tokens to remote tokens. Requires `MAP_SUCKER_TOKEN` permission.
|
|
49
|
+
- `exitThroughEmergencyHatch(JBClaim)` -- Reclaim tokens locally when the bridge is broken. Validates against the outbox tree (not inbox).
|
|
50
|
+
- `enableEmergencyHatchFor(address[])` -- Project owner enables emergency exit for specific tokens. Requires `SUCKER_SAFETY` permission.
|
|
51
|
+
- `setDeprecation(uint40 timestamp)` -- Set or clear the deprecation timestamp. Requires `SET_SUCKER_DEPRECATION` permission.
|
|
52
|
+
|
|
53
|
+
**Key internal functions:**
|
|
54
|
+
- `_insertIntoTree(projectTokenCount, token, terminalTokenAmount, beneficiary)` -- Builds leaf hash, inserts into outbox merkle tree, updates balance.
|
|
55
|
+
- `_validate(projectTokenCount, terminalToken, terminalTokenAmount, beneficiary, index, leaves)` -- Verifies merkle proof against inbox root, marks leaf as executed in bitmap.
|
|
56
|
+
- `_validateForEmergencyExit(...)` -- Validates against outbox root, checks `numberOfClaimsSent` bounds, uses separate bitmap slot.
|
|
57
|
+
- `_validateBranchRoot(expectedRoot, ...)` -- Computes `MerkleLib.branchRoot()` and compares to expected root.
|
|
58
|
+
- `_sendRoot(transportPayment, token, remoteToken)` -- Builds `JBMessageRoot`, clears outbox balance, increments nonce, delegates to `_sendRootOverAMB()`.
|
|
59
|
+
- `_pullBackingAssets(projectToken, count, token, minTokensReclaimed)` -- Cashes out project tokens via the primary terminal.
|
|
60
|
+
- `_handleClaim(terminalToken, terminalTokenAmount, projectTokenAmount, beneficiary)` -- Adds terminal tokens to balance and mints project tokens for beneficiary.
|
|
61
|
+
- `_mapToken(map, transportPaymentValue)` -- Token mapping with immutability enforcement (cannot remap once outbox has entries).
|
|
62
|
+
- `_addToBalance(token, amount)` -- Adds terminal tokens to the project's balance via the primary terminal.
|
|
63
|
+
- `_isRemotePeer(sender)` -- Abstract. Verifies the caller is the authenticated bridge messenger representing the remote peer.
|
|
64
|
+
- `_sendRootOverAMB(...)` -- Abstract. Chain-specific bridge logic.
|
|
65
|
+
|
|
66
|
+
### JBOptimismSucker (src/JBOptimismSucker.sol)
|
|
67
|
+
|
|
68
|
+
OP Stack implementation. Uses `IOPMessenger` for messages and `IOPStandardBridge` for token bridging.
|
|
69
|
+
|
|
70
|
+
- `_isRemotePeer(sender)`: Checks `sender == OPMESSENGER && OPMESSENGER.xDomainMessageSender() == peer()`.
|
|
71
|
+
- `_sendRootOverAMB(...)`: Bridges ERC-20 via `OPBRIDGE.bridgeERC20To()`, sends message via `OPMESSENGER.sendMessage()`. Native ETH sent as `msg.value` on the messenger call. Transport payment must be 0 (OP bridge is free).
|
|
72
|
+
|
|
73
|
+
### JBBaseSucker (src/JBBaseSucker.sol)
|
|
74
|
+
|
|
75
|
+
Extends `JBOptimismSucker` with Base chain ID mappings. Same bridge logic.
|
|
76
|
+
|
|
77
|
+
### JBCeloSucker (src/JBCeloSucker.sol)
|
|
78
|
+
|
|
79
|
+
Extends `JBOptimismSucker` for Celo (OP Stack chain with CELO as native gas token, not ETH).
|
|
80
|
+
|
|
81
|
+
- Wraps native ETH to WETH before bridging as ERC-20.
|
|
82
|
+
- Removes the `NATIVE_TOKEN -> NATIVE_TOKEN` restriction so native ETH can map to a remote ERC-20.
|
|
83
|
+
- `_addToBalance()` override: unwraps WETH -> native ETH before adding to project balance.
|
|
84
|
+
- Messenger message sent with `nativeValue = 0` (no ETH attached on Celo).
|
|
85
|
+
|
|
86
|
+
### JBArbitrumSucker (src/JBArbitrumSucker.sol)
|
|
87
|
+
|
|
88
|
+
Arbitrum implementation. Uses `IInbox` for retryable tickets and `IArbGatewayRouter` for token bridging.
|
|
89
|
+
|
|
90
|
+
- `_isRemotePeer(sender)`:
|
|
91
|
+
- **L1 side**: Checks `sender == ARBINBOX.bridge() && IOutbox(bridge.activeOutbox()).l2ToL1Sender() == peer()`.
|
|
92
|
+
- **L2 side**: Checks `sender == AddressAliasHelper.applyL1ToL2Alias(peer())`.
|
|
93
|
+
- `_sendRootOverAMB(...)`:
|
|
94
|
+
- **L1 -> L2**: Creates two independent retryable tickets (one for ERC-20 bridge, one for merkle root message). Non-atomic: tickets are redeemed independently on L2 with no guaranteed ordering. `_addToBalance` checks actual token balance to prevent unbacked minting when message arrives before tokens.
|
|
95
|
+
- **L2 -> L1**: Uses `ArbSys.sendTxToL1()` for message, `IArbL2GatewayRouter.outboundTransfer()` for tokens.
|
|
96
|
+
- Transport payment required from L1 (covers retryable ticket gas).
|
|
97
|
+
|
|
98
|
+
### JBCCIPSucker (src/JBCCIPSucker.sol)
|
|
99
|
+
|
|
100
|
+
Chainlink CCIP implementation. Uses `ICCIPRouter` for cross-chain messaging with token transfer.
|
|
101
|
+
|
|
102
|
+
- `_isRemotePeer(sender)`: Checks `sender == address(this)` (CCIP calls `ccipReceive` which then calls `this.fromRemote()`).
|
|
103
|
+
- `ccipReceive(Client.Any2EVMMessage)`: Entry point from CCIP router. Validates `msg.sender == CCIP_ROUTER`, `origin == peer()`, `sourceChainSelector == REMOTE_CHAIN_SELECTOR`. Unwraps WETH to ETH when `root.token == NATIVE_TOKEN`. Calls `this.fromRemote(root)`.
|
|
104
|
+
- `_sendRootOverAMB(...)`: Wraps native ETH to WETH (CCIP only transports ERC-20s). Builds `Client.EVM2AnyMessage`, gets fee quote, calls `CCIP_ROUTER.ccipSend{value: fees}()`. Refunds excess transport payment (best-effort, does not revert on failure).
|
|
105
|
+
- Amount validation intentionally skipped (CCIP guarantees delivery). Reverting on mismatch would lock tokens.
|
|
106
|
+
|
|
107
|
+
### JBSuckerRegistry (src/JBSuckerRegistry.sol)
|
|
108
|
+
|
|
109
|
+
Manages sucker deployment and tracking.
|
|
110
|
+
|
|
111
|
+
**Key functions:**
|
|
112
|
+
- `deploySuckersFor(projectId, salt, configurations[])` -- Deploys suckers via allowed deployers. Requires `DEPLOY_SUCKERS` permission. Salt includes sender address for deterministic cross-chain deployment.
|
|
113
|
+
- `allowSuckerDeployer(deployer)` / `removeSuckerDeployer(deployer)` -- Owner-only allowlist management.
|
|
114
|
+
- `removeDeprecatedSucker(projectId, sucker)` -- Anyone can remove a fully deprecated sucker from the registry.
|
|
115
|
+
- `isSuckerOf(projectId, addr)` -- Check if a sucker belongs to a project.
|
|
116
|
+
- `suckersOf(projectId)` / `suckerPairsOf(projectId)` -- List project suckers with remote peer info.
|
|
117
|
+
|
|
118
|
+
### MerkleLib (src/utils/MerkleLib.sol)
|
|
119
|
+
|
|
120
|
+
Incremental merkle tree modeled on the eth2 deposit contract. Depth-32, max `2^32 - 1` leaves.
|
|
121
|
+
|
|
122
|
+
**Key functions:**
|
|
123
|
+
- `insert(Tree, node)` -- Insert a leaf. Returns updated tree. Reverts if full.
|
|
124
|
+
- `root(Tree storage)` -- Compute the current root from storage (assembly-optimized).
|
|
125
|
+
- `branchRoot(item, branch, index)` -- Compute root from a leaf, proof branch, and index (assembly-optimized). Used for claim verification.
|
|
126
|
+
|
|
127
|
+
## Key Flows
|
|
128
|
+
|
|
129
|
+
### Prepare (Bridge Out -- Source Chain)
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
User calls prepare(projectTokenCount, beneficiary, minTokensReclaimed, token)
|
|
133
|
+
|
|
|
134
|
+
+--> Validate: beneficiary != 0, project has ERC-20, token is mapped and enabled, sucker not deprecated
|
|
135
|
+
+--> Transfer project tokens from user to sucker
|
|
136
|
+
+--> _pullBackingAssets():
|
|
137
|
+
| Cash out project tokens via terminal.cashOutTokensOf()
|
|
138
|
+
| Sucker gets 0% cashOutTaxRate (special permission from JBOmnichainDeployer)
|
|
139
|
+
| Assert: balance delta matches returned reclaimedAmount
|
|
140
|
+
|
|
|
141
|
+
+--> _insertIntoTree():
|
|
142
|
+
| Guard: amounts fit in uint128 (SVM compatibility)
|
|
143
|
+
| Build leaf hash: keccak256(abi.encode(projectTokenCount, terminalTokenAmount, beneficiary))
|
|
144
|
+
| Insert into outbox merkle tree
|
|
145
|
+
| Update outbox balance
|
|
146
|
+
|
|
|
147
|
+
+--> Emit InsertToOutboxTree(beneficiary, token, hash, index, root, counts)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Bridge (Transport -- Source Chain)
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
Anyone calls toRemote(token)
|
|
154
|
+
|
|
|
155
|
+
+--> Validate: emergency hatch not enabled, nothing-to-send guard, deduct toRemoteFee (if set, read from JBSuckerRegistry via REGISTRY.toRemoteFee(), set globally by registry owner via setToRemoteFee(), capped at MAX_TO_REMOTE_FEE = 0.001 ether), sucker not deprecated
|
|
156
|
+
+--> _sendRoot():
|
|
157
|
+
| Read outbox tree count and balance
|
|
158
|
+
| Clear outbox balance (set to 0)
|
|
159
|
+
| Increment nonce
|
|
160
|
+
| Compute outbox tree root
|
|
161
|
+
| Update numberOfClaimsSent = tree.count
|
|
162
|
+
| Build JBMessageRoot(version, remoteToken.addr, amount, JBInboxTreeRoot(nonce, root))
|
|
163
|
+
|
|
|
164
|
+
+--> _sendRootOverAMB() [chain-specific]:
|
|
165
|
+
OP: bridgeERC20To() + OPMESSENGER.sendMessage()
|
|
166
|
+
ARB: IArbGatewayRouter.outboundTransfer() + ARBINBOX.createRetryableTicket()
|
|
167
|
+
CCIP: CCIP_ROUTER.ccipSend() with token amounts
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Claim (Bridge In -- Destination Chain)
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
Bridge delivers message -> fromRemote(JBMessageRoot) is called
|
|
174
|
+
|
|
|
175
|
+
+--> Validate: caller is authenticated remote peer, message version matches
|
|
176
|
+
+--> If root.remoteRoot.nonce > inbox.nonce && state != DEPRECATED:
|
|
177
|
+
| Update inbox root and nonce
|
|
178
|
+
|
|
|
179
|
+
|
|
180
|
+
Later, anyone calls claim(JBClaim) for a beneficiary:
|
|
181
|
+
|
|
|
182
|
+
+--> _validate():
|
|
183
|
+
| Check _executedFor[token].get(index) is false (not already claimed)
|
|
184
|
+
| Set _executedFor[token].set(index)
|
|
185
|
+
| Build leaf hash from claim data
|
|
186
|
+
| Compute root via MerkleLib.branchRoot(hash, proof, index)
|
|
187
|
+
| Compare to _inboxOf[token].root -- revert if mismatch
|
|
188
|
+
|
|
|
189
|
+
+--> _handleClaim():
|
|
190
|
+
| _addToBalance(terminalToken, terminalTokenAmount)
|
|
191
|
+
| Mint project tokens for beneficiary via controller.mintTokensOf()
|
|
192
|
+
| (useReservedPercent = false -- sucker mints bypass reserved percent)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Deprecation Lifecycle
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
ENABLED (normal operation)
|
|
199
|
+
| setDeprecation(timestamp) where timestamp > block.timestamp + _maxMessagingDelay()
|
|
200
|
+
v
|
|
201
|
+
DEPRECATION_PENDING (warning, all operations still work)
|
|
202
|
+
| block.timestamp reaches (deprecatedAfter - _maxMessagingDelay)
|
|
203
|
+
v
|
|
204
|
+
SENDING_DISABLED (prepare and toRemote blocked, claims still work)
|
|
205
|
+
| block.timestamp reaches deprecatedAfter
|
|
206
|
+
v
|
|
207
|
+
DEPRECATED (fromRemote rejects new roots, claims still work, emergency exit works)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Merkle Tree Proof System
|
|
211
|
+
|
|
212
|
+
### Outbox Tree Construction
|
|
213
|
+
|
|
214
|
+
Each `prepare()` call inserts a leaf into the outbox tree:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
leaf = keccak256(abi.encode(projectTokenCount, terminalTokenAmount, beneficiary))
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The tree is an incremental merkle tree (eth2 deposit contract pattern) with depth 32 and max `2^32 - 1` leaves. Leaves are inserted in order; the tree is append-only.
|
|
221
|
+
|
|
222
|
+
### Inbox Root Verification
|
|
223
|
+
|
|
224
|
+
When `fromRemote()` receives a `JBMessageRoot`, it stores the merkle root in `_inboxOf[token].root`. The nonce must be strictly greater than the current inbox nonce (non-sequential ordering is accepted for CCIP compatibility).
|
|
225
|
+
|
|
226
|
+
### Claim Verification
|
|
227
|
+
|
|
228
|
+
To claim, a user provides:
|
|
229
|
+
- `JBLeaf`: `(index, beneficiary, projectTokenCount, terminalTokenAmount)`
|
|
230
|
+
- `bytes32[32] proof`: the 32-element merkle proof branch
|
|
231
|
+
|
|
232
|
+
`_validate()` rebuilds the leaf hash, computes the root via `MerkleLib.branchRoot()`, and compares it to the stored inbox root. The leaf index is marked in a bitmap to prevent double-claiming.
|
|
233
|
+
|
|
234
|
+
### Emergency Exit Verification
|
|
235
|
+
|
|
236
|
+
`_validateForEmergencyExit()` validates against the **outbox** tree (not inbox). It additionally checks:
|
|
237
|
+
- Emergency hatch is enabled for the token, OR the sucker is deprecated/sending-disabled.
|
|
238
|
+
- `index >= numberOfClaimsSent` (the leaf was NOT part of a root already sent to the remote peer). This prevents double-spending where a leaf could be claimed on both chains.
|
|
239
|
+
- Uses a separate bitmap slot: `address(bytes20(keccak256(abi.encode(terminalToken))))` to avoid collision with the regular claim bitmap.
|
|
240
|
+
|
|
241
|
+
## Token Mapping
|
|
242
|
+
|
|
243
|
+
- **Immutable once outbox has entries**: If `_outboxOf[token].tree.count != 0`, the token can only be disabled (mapped to `bytes32(0)`), not remapped to a different remote token. This prevents double-spending.
|
|
244
|
+
- **Re-enabling supported**: A disabled token (remote = 0) can be re-enabled back to its original remote token.
|
|
245
|
+
- **Disabling triggers root flush**: When mapping to `bytes32(0)` with unsent outbox entries (`numberOfClaimsSent != tree.count`), `_sendRoot()` is called to flush remaining entries before disabling.
|
|
246
|
+
- **Validation**: Base class requires native token to map only to native token or zero. `JBCCIPSucker` and `JBCeloSucker` override this to allow native-to-ERC20 mapping. All implementations enforce `minGas >= MESSENGER_ERC20_MIN_GAS_LIMIT` for ERC-20 tokens.
|
|
247
|
+
|
|
248
|
+
## Priority Audit Areas
|
|
249
|
+
|
|
250
|
+
| Priority | Target | Why |
|
|
251
|
+
|----------|--------|-----|
|
|
252
|
+
| 1 | **Merkle proof verification** (`_validate`, `_validateForEmergencyExit`, `MerkleLib.branchRoot`) | Double-claim or invalid-claim is the highest-impact bug. Verify: leaf hash construction matches insertion, bitmap prevents replay, proof verification is correct, emergency exit uses separate bitmap and checks numberOfClaimsSent bounds. |
|
|
253
|
+
| 2 | **fromRemote authentication** (`_isRemotePeer` in each implementation) | If an attacker can call `fromRemote()` with a crafted root, they can mint arbitrary tokens. Verify each bridge's authentication: OP (xDomainMessageSender), Arbitrum (L1 outbox / L2 alias), CCIP (router + origin + chain selector). |
|
|
254
|
+
| 3 | **Token conservation across bridge** | Verify: outbox balance cleared in `_sendRoot()` matches what the bridge transfers, inbox `_handleClaim()` only distributes `terminalTokenAmount` that was actually received. For CCIP: amount validation is intentionally skipped. For Arbitrum L1->L2: two independent retryable tickets create a non-atomic window. |
|
|
255
|
+
| 4 | **Emergency exit safety** | `numberOfClaimsSent` determines which leaves are safe to emergency-exit. Verify: it's updated only in `_sendRoot()`, accurately tracks what was sent, and the `>= index` comparison is correct (count vs 0-based index). |
|
|
256
|
+
| 5 | **Deprecation lifecycle** | State transitions are timestamp-based. Verify: `_maxMessagingDelay()` provides enough time for in-flight messages, `SENDING_DISABLED` blocks `prepare()` and `toRemote()` but allows `claim()` and `fromRemote()`, `DEPRECATED` blocks `fromRemote()` new roots. |
|
|
257
|
+
| 6 | **Token mapping immutability** | Verify: once `_outboxOf[token].tree.count != 0`, remapping to a different remote token reverts. Disabling triggers root flush. Re-enabling back to the same address works. |
|
|
258
|
+
| 7 | **Arbitrum non-atomic bridging** | L1->L2 creates two independent retryable tickets. Verify: `_addToBalance()` checks `amountToAddToBalanceOf()` which depends on actual token balance, preventing unbacked minting when message arrives before tokens. |
|
|
259
|
+
| 8 | **CCIP-specific: ccipReceive** | Must never revert after CCIP delivers tokens. Verify: WETH unwrap safety, `this.fromRemote()` self-call pattern, transport payment refund (best-effort, stuck ETH accepted). |
|
|
260
|
+
| 9 | **Reentrancy surfaces** | `_pullBackingAssets()` calls `terminal.cashOutTokensOf()` which triggers hooks. `_handleClaim()` calls `terminal.addToBalanceOf()` and `controller.mintTokensOf()`. No ReentrancyGuard. Verify: state is updated before external calls, bitmap prevents re-entry exploits. |
|
|
261
|
+
|
|
262
|
+
## Invariants to Verify
|
|
263
|
+
|
|
264
|
+
1. **No double-claim**: Each leaf index can only be claimed once per token (bitmap enforcement).
|
|
265
|
+
2. **No cross-chain double-spend**: Emergency exit only allows leaves with `index >= numberOfClaimsSent` (not already sent to remote).
|
|
266
|
+
3. **Token conservation**: `outbox.balance` (before `_sendRoot()`) == amount bridged to remote peer == amount available for claims on remote.
|
|
267
|
+
4. **Nonce monotonicity**: Inbox nonce only increases. A stale nonce is rejected.
|
|
268
|
+
5. **Mapping immutability**: After first outbox insertion, token cannot be remapped (only disabled).
|
|
269
|
+
6. **Deprecation irreversibility**: Once `SENDING_DISABLED` or `DEPRECATED`, the sucker cannot return to `ENABLED`.
|
|
270
|
+
7. **Balance accounting**: `amountToAddToBalanceOf(token) = contract.balance(token) - outboxOf[token].balance` is always non-negative.
|
|
271
|
+
8. **Peer symmetry**: `peer()` returns the same address on both chains (CREATE2 determinism).
|
|
272
|
+
|
|
273
|
+
## How to Run Tests
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
cd nana-suckers-v6
|
|
277
|
+
npm install
|
|
278
|
+
forge build
|
|
279
|
+
forge test
|
|
280
|
+
|
|
281
|
+
# Run with high verbosity for debugging
|
|
282
|
+
forge test -vvvv --match-test testExploitName
|
|
283
|
+
|
|
284
|
+
# Write a PoC
|
|
285
|
+
forge test --match-path test/audit/ExploitPoC.t.sol -vvv
|
|
286
|
+
|
|
287
|
+
# Specific test suites
|
|
288
|
+
forge test --match-contract SuckerAttacks # Attack scenarios
|
|
289
|
+
forge test --match-contract SuckerDeepAttacks # Deep attack vectors
|
|
290
|
+
forge test --match-contract SuckerRegressions # Regression tests
|
|
291
|
+
forge test --match-contract InteropCompat # Cross-chain compatibility
|
|
292
|
+
forge test --match-path test/unit/ # Unit tests
|
|
293
|
+
forge test --match-path test/regression/ # Regression tests
|
|
294
|
+
|
|
295
|
+
# Fork tests (require RPC URLs)
|
|
296
|
+
forge test --match-contract Fork --fork-url $ETH_RPC_URL
|
|
297
|
+
forge test --match-contract ForkCelo --fork-url $CELO_RPC_URL
|
|
298
|
+
forge test --match-contract ForkMainnet --fork-url $ETH_RPC_URL
|
|
299
|
+
|
|
300
|
+
# Gas analysis
|
|
301
|
+
forge test --gas-report
|
|
302
|
+
```
|