@bananapus/suckers-v6
v0.0.14
Published
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-specif
Readme
Juicebox Suckers
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.
If you're having trouble understanding this contract, take a look at the core protocol contracts and the documentation first. If you have questions, reach out on Discord.
What are Suckers?
JBSucker contracts are deployed in pairs, with one on each network being bridged to or from. The JBSucker contract implements core logic, and is extended by network-specific implementations adapted to each bridge:
| Sucker | Networks | Description |
|--------|----------|-------------|
| JBCCIPSucker | Any CCIP-connected chains | Uses Chainlink CCIP (ccipSend/ccipReceive). Handles native token wrapping/unwrapping for chains with different native assets. Supports Ethereum, Optimism, Base, Arbitrum, Polygon, Avalanche, BNB Chain, and their testnets. |
| JBOptimismSucker | Ethereum and Optimism | Uses the OP Standard Bridge and the OP Messenger. |
| JBBaseSucker | Ethereum and Base | A thin wrapper around JBOptimismSucker with Base chain IDs. |
| JBCeloSucker | Ethereum and Celo | Extends JBOptimismSucker for Celo, an OP Stack chain with a custom gas token (CELO, not ETH). Wraps native ETH to WETH before bridging as ERC-20 via the OP Standard Bridge, and unwraps WETH back to native ETH on the receiving end. Allows NATIVE_TOKEN to map to ERC-20 addresses. |
| JBArbitrumSucker | Ethereum and Arbitrum | Uses the Arbitrum Inbox and the Arbitrum Gateway. Handles L1<->L2 retryable tickets and address aliasing. |
Suckers use two merkle trees to track project token claims associated with each terminal token they support:
- The outbox tree tracks tokens on the local chain -- the network that the sucker is on.
- The inbox tree tracks tokens which have been bridged from the peer chain -- the network that the sucker's peer is on.
For example, a sucker which supports bridging ETH and USDC would have four trees -- an inbox and outbox tree for each token. These trees are append-only, and when they're bridged over to the other chain, they aren't deleted -- they only update the remote inbox tree with the latest root.
To insert project tokens into the outbox tree, users call JBSucker.prepare(...) with the amount of project tokens to bridge and the terminal token to bridge with them. The sucker cashes out those project tokens to reclaim the chosen terminal token from the project's primary terminal. Then it inserts a claim with this information into the outbox tree.
Anyone can bridge an outbox tree to the peer chain by calling JBSucker.toRemote(token). The outbox tree then becomes the peer sucker's inbox tree for that token. Users can claim their tokens on the peer chain by providing a merkle proof which shows that their claim is in the inbox tree.
Architecture
On each network:
graph TD;
A[JBSuckerRegistry] -->|exposes| B["deploySuckersFor(...)"]
B -->|calls| C[IJBSuckerDeployer]
C -->|deploys| D[JBSucker]
A -->|tracks| DFor an example project deployed on mainnet and Optimism with a JBOptimismSucker on each network:
graph TD;
subgraph Mainnet
A[Project] -->|cashed-out funds| B[JBOptimismSucker]
B -->|burns/mints tokens| A
end
subgraph Optimism
C[Project] -->|cashed-out funds| D[JBOptimismSucker]
D -->|burns/mints tokens| C
end
B <-->|merkle roots/funds| DContracts
| Contract | Description |
|----------|-------------|
| JBSucker | Abstract base. Manages outbox/inbox merkle trees, prepare/toRemote/claim lifecycle, token mapping, deprecation, and emergency hatch. Deployed as clones via Initializable. Uses ERC2771Context for meta-transactions. Has immutable FEE_PROJECT_ID (typically project ID 1) and immutable REGISTRY reference. Reads the toRemoteFee from the registry via REGISTRY.toRemoteFee() on each toRemote() call. |
| JBCCIPSucker | Extends JBSucker. Bridges via Chainlink CCIP (ccipSend/ccipReceive). Supports any CCIP-connected chain pair. Wraps native ETH to WETH before bridging (CCIP only transports ERC-20s) and unwraps on the receiving end. Can map NATIVE_TOKEN to ERC-20 addresses on the remote chain (unlike OP/Arbitrum suckers). |
| JBOptimismSucker | Extends JBSucker. Bridges via OP Standard Bridge + OP Messenger. No msg.value required for transport. |
| JBBaseSucker | Thin wrapper around JBOptimismSucker with Base chain IDs (Ethereum 1 <-> Base 8453, Sepolia 11155111 <-> Base Sepolia 84532). |
| JBCeloSucker | Extends JBOptimismSucker for Celo (OP Stack, custom gas token CELO). Wraps native ETH → WETH before bridging as ERC-20. Unwraps received WETH → native ETH via _addToBalance override. Removes NATIVE_TOKEN → NATIVE_TOKEN restriction. Sends messenger messages with nativeValue = 0 (Celo's native token is CELO, not ETH). |
| JBArbitrumSucker | Extends JBSucker. Bridges via Arbitrum Inbox + Gateway Router. Uses unsafeCreateRetryableTicket for L1->L2 (to avoid address aliasing of refund address) and ArbSys.sendTxToL1 for L2->L1. Requires msg.value for L1->L2 transport payment. |
| JBSuckerRegistry | Tracks all suckers per project. Manages deployer allowlist (owner-only). Entry point for deploySuckersFor. Can remove deprecated suckers via removeDeprecatedSucker. Owns the global toRemoteFee (ETH fee in wei, capped at MAX_TO_REMOTE_FEE = 0.001 ether), adjustable by the registry owner via setToRemoteFee(). All sucker clones read this fee from the registry. |
| JBSuckerDeployer | Abstract base deployer. Clones a singleton sucker via LibClone.cloneDeterministic and initializes it. Two-phase setup: setChainSpecificConstants then configureSingleton. |
| JBCCIPSuckerDeployer | Deployer for JBCCIPSucker. Stores CCIP router, remote chain ID, and CCIP chain selector. |
| JBOptimismSuckerDeployer | Deployer for JBOptimismSucker. Stores OP Messenger and OP Bridge addresses. |
| JBBaseSuckerDeployer | Thin wrapper around JBOptimismSuckerDeployer for Base. |
| JBCeloSuckerDeployer | Deployer for JBCeloSucker. Extends JBOptimismSuckerDeployer with wrappedNative (IWrappedNativeToken) storage for the local chain's WETH address. |
| JBArbitrumSuckerDeployer | Deployer for JBArbitrumSucker. Stores Arbitrum Inbox, Gateway Router, and layer (JBLayer.L1 or JBLayer.L2). |
| MerkleLib | Incremental merkle tree (depth 32, max ~4 billion leaves, modeled on eth2 deposit contract). Used for outbox/inbox trees. Gas-optimized with inline assembly for root() and branchRoot(). |
| CCIPHelper | CCIP router addresses, chain selectors, and WETH addresses per chain. Covers Ethereum, Optimism, Arbitrum, Base, Polygon, Avalanche, and BNB Chain (mainnet and testnets). |
| ARBAddresses | Arbitrum bridge contract addresses (Inbox, Gateway Router) for mainnet and Sepolia. |
| ARBChains | Arbitrum chain ID constants. |
Bridging Flow
Chain A Chain B
| |
| 1. prepare(tokenCount, ...) |
| - transfers project tokens |
| - cashes out for terminal tkn |
| - inserts leaf into outbox |
| |
| 2. toRemote(token) |
| - sends merkle root + funds -->|
| |
| 3. fromRemote(root)
| - validates message version
| - updates inbox tree (if nonce > current)
| |
| 4. claim(proof)
| - verifies merkle proof
| - mints project tokens
| - adds funds to balanceEach toRemote call increments the outbox nonce and sends the complete merkle root. On the receiving side, fromRemote only accepts roots with a nonce strictly greater than the current inbox nonce. If nonces arrive out of order (possible with CCIP), earlier nonces are silently skipped and their claims become unclaimable on that chain. The sender would need to use the emergency hatch on the source chain to recover funds from skipped roots.
Messages include a MESSAGE_VERSION (currently 1) to reject incompatible messages from peers running different protocol versions.
Bridging Tokens
Imagine that "OhioDAO" is deployed on Ethereum mainnet and Optimism:
- It has the $OHIO ERC-20 project token and a
JBOptimismSuckerdeployed on each network. - Its suckers map mainnet ETH to Optimism ETH, and vice versa.
Each sucker has mappings from terminal tokens on the local chain to associated terminal tokens on the remote chain.
Here's how Jimmy can bridge his $OHIO tokens (and the corresponding ETH) from mainnet to Optimism.
1. Pay the project. Jimmy pays OhioDAO 1 ETH on Ethereum mainnet:
JBMultiTerminal.pay{value: 1 ether}({
projectId: 12,
token: JBConstants.NATIVE_TOKEN,
amount: 1 ether,
beneficiary: jimmy,
minReturnedTokens: 0,
memo: "OhioDAO rules",
metadata: ""
});OhioDAO's ruleset has a weight of 1e18, so Jimmy receives 1 $OHIO (1e18 tokens).
2. Approve the sucker. Before bridging, Jimmy approves the JBOptimismSucker to transfer his $OHIO:
JBERC20.approve({
spender: address(optimismSucker),
value: 1e18
});3. Prepare the bridge. Jimmy calls prepare(...) on the mainnet sucker. Note that beneficiary is bytes32 for cross-VM compatibility (e.g., Solana public keys):
JBOptimismSucker.prepare({
projectTokenCount: 1e18,
beneficiary: bytes32(uint256(uint160(jimmy))), // bytes32 for cross-VM compat
minTokensReclaimed: 0,
token: JBConstants.NATIVE_TOKEN
});The sucker transfers Jimmy's $OHIO to itself, cashes them out using OhioDAO's primary ETH terminal, and inserts a leaf into the ETH outbox tree. The leaf is a keccak256 hash of the beneficiary (bytes32), the project token count, and the terminal token amount reclaimed. Both amounts are capped at uint128 for SVM compatibility.
4. Bridge to remote. Jimmy (or anyone) calls toRemote(...):
JBOptimismSucker.toRemote(JBConstants.NATIVE_TOKEN);This sends the outbox merkle root and the accumulated ETH to the peer sucker on Optimism. The outbox balance is cleared and the nonce incremented. After the bridge completes, the Optimism sucker's ETH inbox tree is updated with the new root containing Jimmy's claim.
5. Claim on the remote chain. Jimmy claims his $OHIO on Optimism by calling claim(...) with a JBClaim:
struct JBClaim {
address token; // The terminal token to claim
JBLeaf leaf; // The leaf data
bytes32[32] proof; // Merkle proof (TREE_DEPTH = 32)
}The JBLeaf:
struct JBLeaf {
uint256 index; // Position in the merkle tree
bytes32 beneficiary; // Recipient address (bytes32 for cross-VM compat)
uint256 projectTokenCount; // Project tokens to mint
uint256 terminalTokenAmount; // Terminal tokens reclaimed
}Building these claims manually requires tracking every insertion and computing merkle proofs. The juicerkle service simplifies this -- POST a JSON request to /claims:
{
"chainId": 10,
"sucker": "0x5678...",
"token": "0x000000000000000000000000000000000000EEEe",
"beneficiary": "0x1234..."
}| Field | Type | Description |
|-------|------|-------------|
| chainId | int | Network ID for the sucker being claimed from. |
| sucker | string | Address of the sucker being claimed from. |
| token | string | Terminal token whose inbox tree is being claimed from. |
| beneficiary | string | Address to get available claims for. |
The service looks through the entire inbox tree and returns all available claims as JBClaim structs ready to pass to claim(...).
The bridged ETH is added to OhioDAO's Optimism balance when the claim is processed.
Token Mapping
Token mappings define which local terminal token corresponds to which remote terminal token. Key rules:
remoteTokenisbytes32, notaddress-- this supports cross-VM compatibility (e.g., Solana program addresses). For EVM addresses, left-pad with zeros:bytes32(uint256(uint160(address))).- Immutable once used. After an outbox tree has entries for a token, the mapping cannot be changed to a different remote token. It can only be disabled (by setting
remoteTokentobytes32(0)), which triggers a final root flush to settle outstanding claims. A disabled mapping can be re-enabled back to the same remote token. - Minimum gas enforcement. ERC-20 mappings must specify
minGas >= MESSENGER_ERC20_MIN_GAS_LIMIT(200,000). Native token mappings on the baseJBSuckerdo not require minimum gas, butJBCCIPSuckerrequires it for all tokens (because CCIP wraps native to WETH, an ERC-20 transfer). - Native token rules. On
JBSucker(OP/Arb),NATIVE_TOKENcan only map toNATIVE_TOKENorbytes32(0).JBCCIPSuckerandJBCeloSuckeroverride this to allowNATIVE_TOKENmapping to any remote address (for chains where ETH is an ERC-20). toRemoteFeeis a global fee (ETH, in wei) stored on theJBSuckerRegistryand adjustable by the registry owner viasetToRemoteFee(), up to a hard cap ofMAX_TO_REMOTE_FEE(0.001 ether). Each sucker clone reads the fee from its immutableREGISTRYreference viaREGISTRY.toRemoteFee(). It is paid into the fee project (determined byFEE_PROJECT_ID, typically project ID 1) viaterminal.pay()on eachtoRemote()call, making spam economically costly. The caller receives fee project tokens in return (incentivizing relayers). The fee is best-effort: if the fee project has no native token terminal, or ifterminal.pay()reverts,toRemote()proceeds without collecting the fee. A "nothing to send" guard also prevents free repeated calls when nothing has changed.
struct JBTokenMapping {
address localToken; // Local terminal token address
uint32 minGas; // Minimum gas for bridging
bytes32 remoteToken; // Remote token (bytes32 for cross-VM compat)
}Deprecation Lifecycle
Suckers have a four-state deprecation lifecycle controlled by setDeprecation(timestamp):
ENABLED --> DEPRECATION_PENDING --> SENDING_DISABLED --> DEPRECATED| State | Condition | Behavior |
|-------|-----------|----------|
| ENABLED | deprecatedAfter == 0 | Fully functional. All operations allowed. |
| DEPRECATION_PENDING | now < deprecatedAfter - _maxMessagingDelay() | Warning state. All operations still allowed. Deprecation can be cancelled by setting timestamp to 0. |
| SENDING_DISABLED | now < deprecatedAfter | No new prepare or toRemote calls. Incoming fromRemote still accepted. Emergency exit available for all tokens. |
| DEPRECATED | now >= deprecatedAfter | Fully shut down. No new fromRemote accepted. Emergency exit available for all tokens. |
The deprecation timestamp must be at least _maxMessagingDelay() (14 days) in the future. This ensures in-flight messages have time to arrive before the sucker stops accepting them. Once in SENDING_DISABLED or DEPRECATED state, the deprecation can no longer be modified.
Permission: SET_SUCKER_DEPRECATION from the project owner.
Emergency Hatch
The emergency hatch lets users exit on the chain where they deposited when the bridge is broken or a token is no longer compatible.
- Per-token activation. Call
enableEmergencyHatchFor(tokens)withSUCKER_SAFETYpermission. This is irreversible -- once opened for a token, that token can never be bridged by this sucker again. - Automatic activation. When the sucker reaches
SENDING_DISABLEDorDEPRECATEDstate, all tokens automatically allow emergency exit. - Who can exit. Only users whose leaves have NOT already been sent to the remote chain (i.e., leaf index >=
numberOfClaimsSent) can use the emergency hatch. This prevents double-spending where the same leaf is claimed on both chains. - How it works. Users call
exitThroughEmergencyHatch(claimData)with a proof against the outbox tree (not the inbox tree). The sucker mints project tokens and adds terminal tokens to the project balance, just like a normal claim.
Launching Suckers
Requirements for deploying a sucker pair:
- Projects on both chains. Project IDs don't have to match.
- 0% cash out tax rate. Both projects must have a
cashOutTaxRateof0so suckers can fully cash out project tokens for terminal tokens. - Owner minting enabled. Both projects must have
allowOwnerMintingset totrueso suckers can mint bridged project tokens. - ERC-20 project token. Both projects must have a deployed ERC-20 token (via
JBController.deployERC20For(...)). The sucker usessafeTransferFromto pull project tokens from the caller.
Suckers are deployed through the JBSuckerRegistry on each chain. The registry maps local tokens to remote tokens during deployment, so it needs permission:
// Give the registry MAP_SUCKER_TOKEN permission for project 12
uint256[] memory permissionIds = new uint256[](1);
permissionIds[0] = JBPermissionIds.MAP_SUCKER_TOKEN;
permissions.setPermissionsFor(
projectOwner,
JBPermissionsData({
operator: address(registry),
projectId: 12,
permissionIds: permissionIds
})
);Now deploy the suckers with a token mapping:
// Map mainnet ETH to Optimism ETH
JBTokenMapping[] memory mappings = new JBTokenMapping[](1);
mappings[0] = JBTokenMapping({
localToken: JBConstants.NATIVE_TOKEN,
minGas: 200_000,
remoteToken: bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN))) // bytes32
});
JBSuckerDeployerConfig[] memory configs = new JBSuckerDeployerConfig[](1);
configs[0] = JBSuckerDeployerConfig({
deployer: IJBSuckerDeployer(optimismSuckerDeployer),
mappings: mappings
});
// Must use the same salt and caller on both chains
bytes32 salt = keccak256("my-project-suckers-v1");
address[] memory suckers = registry.deploySuckersFor(12, salt, configs);- The
JBTokenMappingmaps local mainnet ETH to remote Optimism ETH.remoteTokenisbytes32, notaddress. For EVM addresses, usebytes32(uint256(uint160(addr))).minGasrequires a gas limit of at least 200,000 for ERC-20s. If your token has expensive transfer logic, you may need more.- The fee for
toRemote()is set globally on theJBSuckerRegistryby the registry owner viasetToRemoteFee(), up toMAX_TO_REMOTE_FEE(0.001 ether). Each sucker clone reads this fee from the registry. It is not part of the token mapping.
- The
JBSuckerDeployerConfigspecifies which deployer to use. You can only use approved deployers through the registry -- check forSuckerDeployerAllowedevents or contact the registry's owner. - For the suckers to be peers, the
salthas to match on both chains and the same address must calldeploySuckersFor(...).
Finally, give the sucker permission to mint bridged project tokens:
uint256[] memory mintPermissionIds = new uint256[](1);
mintPermissionIds[0] = JBPermissionIds.MINT_TOKENS;
permissions.setPermissionsFor(
projectOwner,
JBPermissionsData({
operator: suckers[0],
projectId: 12,
permissionIds: mintPermissionIds
})
);Repeat this process on the other chain to deploy the peer sucker, and the project is ready for bridging.
Managing Suckers
Once configured, suckers manage themselves. Stay up-to-date on changes to the bridge infrastructure used by your sucker of choice. If a change causes suckers to become incompatible with the underlying bridge, there are two options.
Always perform these actions on BOTH sides of the sucker pair.
Disable a token
If a bridge change affects only certain tokens, call mapToken(...) with remoteToken set to bytes32(0) to disable that token. This triggers a final toRemote to flush remaining outbox funds to the peer. If the bridge won't allow a final transfer with the remaining funds, activate the emergency hatch for the affected tokens instead.
The emergency hatch lets depositors withdraw their funds on the chain where they deposited. Only those whose funds have not been sent to the remote chain can withdraw. Once opened for a token, that token can never be bridged by this sucker again -- deploy a new sucker instead.
Deprecate the suckers
If the bridging infrastructure will no longer work, deprecate the sucker to begin shutdown. Call setDeprecation(timestamp) with a timestamp at least 14 days (_maxMessagingDelay()) in the future. The sucker transitions through DEPRECATION_PENDING -> SENDING_DISABLED -> DEPRECATED. After full deprecation, all tokens allow exit through the emergency hatch and no new messages are accepted. This protects against future fake or malicious bridge messages.
When deprecating, ensure no pending bridge messages need retrying -- once deprecation completes, those messages will be rejected.
Using the Relayer
Bridging from L1 to L2 is straightforward. Bridging from L2 to L1 requires extra steps to finalize the withdrawal. For OP Stack networks like Optimism or Base, this follows the withdrawal flow:
- The withdrawal initiating transaction, which the user submits on L2.
- The withdrawal proving transaction, which the user submits on L1 to prove that the withdrawal is legitimate (based on a merkle patricia trie root).
- The withdrawal finalizing transaction, which the user submits on L1 after the fault challenge period has passed.
Users can do this manually, but it's a hassle. The bananapus-sucker-relayer automates proving and finalizing withdrawals using OpenZeppelin Defender. Project creators set up a Defender account, configure a relayer through their dashboard, and fund it with ETH for gas.
Resources
MerkleLib-- Incremental merkle tree based on Nomad's implementation and the eth2 deposit contract.juicerkle-- Service that returns available claims for a beneficiary (generates merkle proofs). Includes a Go merkle tree implementation for computing roots and building/verifying proofs.juicerkle-tester-- End-to-end bridging test: deploys projects, tokens, and suckers, then bridges between them. Useful as a bridging walkthrough.
Repository Layout
nana-suckers-v6/
├── script/
│ ├── Deploy.s.sol - Deployment script.
│ └── helpers/
│ └── SuckerDeploymentLib.sol - Internal helpers for deployment.
├── src/
│ ├── JBSucker.sol - Abstract base sucker implementation.
│ ├── JBCCIPSucker.sol - Chainlink CCIP bridge implementation.
│ ├── JBOptimismSucker.sol - OP Stack bridge implementation.
│ ├── JBBaseSucker.sol - Base-specific wrapper around JBOptimismSucker.
│ ├── JBCeloSucker.sol - Celo-specific wrapper around JBOptimismSucker (custom gas token).
│ ├── JBArbitrumSucker.sol - Arbitrum bridge implementation.
│ ├── JBSuckerRegistry.sol - Registry tracking suckers per project.
│ ├── deployers/ - Deployers for each kind of sucker.
│ ├── enums/ - JBLayer, JBSuckerState.
│ ├── interfaces/ - Contract interfaces.
│ ├── libraries/ - ARBAddresses, ARBChains, CCIPHelper.
│ ├── structs/ - JBClaim, JBLeaf, JBMessageRoot, JBOutboxTree, etc.
│ └── utils/
│ └── MerkleLib.sol - Incremental merkle tree (depth 32).
└── test/
├── Fork.t.sol - Fork tests.
├── InteropCompat.t.sol - Cross-VM compatibility tests.
├── SuckerAttacks.t.sol - Security-focused attack tests.
├── SuckerDeepAttacks.t.sol - Deep attack scenario tests.
├── mocks/ - Mock contracts for testing.
└── unit/ - Unit tests (merkle, registry, deployer, emergency, arb).Usage
Install
For projects using npm to manage dependencies (recommended):
npm install @bananapus/suckers-v6For projects using forge to manage dependencies:
forge install Bananapus/nana-suckers-v6If you're using forge, add @bananapus/suckers-v6/=lib/nana-suckers-v6/ to remappings.txt. You'll also need to install nana-suckers-v6's dependencies and add similar remappings for them.
Develop
nana-suckers-v6 uses npm (version >=20.0.0) for package management and the Foundry development toolchain for builds, tests, and deployments. To get set up, install Node.js and install Foundry:
curl -L https://foundry.paradigm.xyz | shDownload and install dependencies with:
npm ci && forge installIf you run into trouble with forge install, try using git submodule update --init --recursive to ensure that nested submodules have been properly initialized.
| Command | Description |
|---------|-------------|
| forge build | Compile the contracts and write artifacts to out. |
| forge test | Run the tests. |
| forge test -vvvv | Run tests with full traces. |
| forge fmt | Lint. |
| forge coverage | Generate a test coverage report. |
| forge build --sizes | Get contract sizes. |
| forge clean | Remove build artifacts and cache. |
| foundryup | Update Foundry. Run this periodically. |
To learn more, visit the Foundry Book docs.
Scripts
| Command | Description |
|---------|-------------|
| npm test | Run local tests. |
| npm run coverage | Generate an LCOV test coverage report. |
| npm run artifacts | Fetch Sphinx artifacts and write them to deployments/. |
Tips
To view test coverage, run npm run coverage to generate an LCOV test report. You can use an extension like Coverage Gutters to view coverage in your editor.
If you're using Nomic Foundation's Solidity extension in VSCode, you may run into LSP errors because the extension cannot find dependencies outside of lib. You can often fix this by running:
forge remappings >> remappings.txtThis makes the extension aware of default remappings.
