If someone launches Juicebox projects on multiple chains, they can add suckers to them to allow anyone to burn the project's tokens on one chain (i.e. the local chain), and receive the same amount of tokens on the other chain (i.e. the remote chain). The sucker redeems the tokens on the local chain, and moves the funds it receives to the remote chain.
Table of Contents
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.
How to install nana-suckers
in another project.
For projects using npm
to manage dependencies (recommended):
npm install @bananapus/suckers
For projects using forge
to manage dependencies (not recommended):
forge install Bananapus/nana-suckers
If you're using forge
to manage dependencies, add @bananapus/suckers/=lib/nana-suckers/
to remappings.txt
. You'll also need to install nana-suckers
' dependencies and add similar remappings for them.
nana-suckers
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 | sh
You can download and install dependencies with:
npm ci && forge install
If you run into trouble with forge install
, try using git submodule update --init --recursive
to ensure that nested submodules have been properly initialized.
Some useful commands:
Command | Description |
---|---|
forge build |
Compile the contracts and write artifacts to out . |
forge fmt |
Lint. |
forge test |
Run the tests. |
forge build --sizes |
Get contract sizes. |
forge coverage |
Generate a test coverage report. |
foundryup |
Update foundry. Run this periodically. |
forge clean |
Remove the build artifacts and cache directories. |
To learn more, visit the Foundry Book docs.
For convenience, several utility commands are available in package.json
.
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/
|
nana-suckers
manages deployments with Sphinx. To run the deployment scripts, install the npm devDependencies
with:
`npm ci --also=dev`
You'll also need to set up a .env
file based on .example.env
. Then run one of the following commands:
Command | Description |
---|---|
npm run deploy:mainnets |
Propose mainnet deployments. |
npm run deploy:testnets |
Propose testnet deployments. |
Your teammates can review and approve the proposed deployments in the Sphinx UI. Once approved, the deployments will be executed.
You can use the Sphinx CLI to run the deployment scripts without paying for Sphinx. First, install the npm devDependencies
with:
`npm ci --also=dev`
You can deploy the contracts like so:
PRIVATE_KEY="0x123β¦" RPC_ETHEREUM_SEPOLIA="https://rpc.ankr.com/eth_sepolia" npx sphinx deploy script/Deploy.s.sol --network ethereum_sepolia
This example deploys nana-suckers
to the Sepolia testnet using the specified private key. You can configure new networks in foundry.toml
.
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.txt
This makes the extension aware of default remappings.
The root directory contains this README, an MIT license, and config files. The important source directories are:
nana-suckers/
βββ script/
β βββ Deploy.s.sol - Deployment script.
β βββ helpers/ - Internal helpers for the deployment script.
βββ src/
β βββ JBArbitrumSucker.sol - Arbitrum-specific JBSucker.
β βββ JBBaseSucker.sol - Base-specific JBSucker.
β βββ JBOptimismSucker.sol - Optimism-specific JBSucker.
β βββ JBSucker.sol - The basic sucker implementation.
β βββ JBSuckerRegistry.sol - Tracks suckers on each chain.
β βββ deployers/ - Deployers for each kind of sucker.
β βββ enums/ - Enums.
β βββ extensions/
β β βββ JBAllowanceSucker.sol - An extension which uses overflow allowance instead of redemptions.
β βββ interfaces/ - Contract interfaces.
β βββ libraries/ - Libraries.
β βββ structs/ - Structs.
β βββ utils/
β βββ MerkleLib.sol - The incremental merkle tree implementation suckers use to store claims.
βββ test/
βββ Fork.t.sol - Fork tests.
βββ mocks/ - Mock contracts for testing.
βββ unit/
βββ merkle.t.sol - Merkle tree unit tests.
βββ registry.t.sol - A registry unit test.
Other directories:
nana-suckers/
βββ .github/
β βββ workflows/ - CI/CD workflows.
βββ deployments/ - Sphinx deployment logs.
On each network (Ethereum, Arbitrum, Optimism, and Base):
graph TD;
A[JBSuckerRegistry] -->|exposes| B["deploySuckersFor(β¦)"]
B -->|calls| C[IJBSuckerDeployer]
C -->|deploys| D[JBSucker]
A -->|tracks| D
For an example project deployed on mainnet and Optimism with a JBOptimismSucker
on each network:
graph TD;
subgraph Mainnet
A[Project] -->|redeemed funds| B[JBOptimismSucker]
B -->|burns/mints tokens| A
end
subgraph Optimism
C[Project] -->|redeemed funds| D[JBOptimismSucker]
D -->|burns/mints tokens| C
end
B <-->|merkle roots/funds| D
This description is adapted from Bridging in Juicebox v4.
Juicebox v4 introduces the JBSucker
contracts for bridging project tokens and funds (terminal tokens) across EVM chains. Here's what you'll need to know if you're building a frontend or service which interacts with them.
JBSucker
contracts are deployed in pairs, with one on each network being bridged to or from β for now, suckers bridge between Ethereum mainnet and a specific L2. The JBSucker
contract implements core logic, and is extended by network-specific implementations adapted to each L2's bridging solution:
Sucker | Networks | Description |
---|---|---|
JBOptimismSucker |
Ethereum Mainnet and Optimism | Uses the OP Standard Bridge and the OP Messenger |
JBBaseSucker |
Ethereum Mainnet and Base | A thin wrapper around JBOptimismSucker
|
JBArbitrumSucker |
Ethereum Mainnet and Arbitrum | Uses the Arbitrum Inbox and the Arbitrum Gateway |
Suckers use two merkle trees to track project token claims associated with each terminal token it supports:
- 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 redeems those project tokens to reclaim the chosen terminal token from the project's primary terminal for it. Then the sucker inserts a claim with this information into the outbox tree.
Anyone can bridge an outbox tree to the peer chain by calling JBSucker.toRemote(β¦)
. 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.
Imagine that the "OhioDAO" project is deployed on Ethereum mainnet and Optimism:
- It has the $OHIO ERC-20 project token and a
JBOptimismSucker
deployed 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.
First, Jimmy pays OhioDAO 1 ETH on Ethereum mainnet by calling JBMultiTerminal.pay(β¦)
:
JBMultiTerminal.pay{value: 1 ether}({
projectId: 12,
token: 0x000000000000000000000000000000000000EEEe,
amount: 1 ether,
beneficiary: 0x1234β¦,
minReturnedTokens: 0,
memo: "OhioDAO rules",
metadata: 0x
});
-
projectId
12 is OhioDAO's project ID. - The (terminal)
token
is ETH, represented byJBConstants.NATIVE_TOKEN
- The
beneficiary
0x1234β¦
is Jimmy's address.
OhioDAO's ruleset has a weight
of 1e18
, so Jimmy receives 1 $OHIO in return (1e18
$OHIO). Before he can bridge his $OHIO to Optimism, Jimmy has to call the $OHIO contract's ERC20.approve(β¦)
function to allow the JBOptimismSucker
to use his balance:
JBERC20.approve({
spender: 0x5678β¦,
value: 1e18
});
The spender
0x5678β¦
is the JBOptimismSucker
's Ethereum mainnet address, and the value
is Jimmy's $OHIO balance. Jimmy can now prepare his $OHIO for bridging by calling JBOptimismSucker.prepare(β¦)
:
JBOptimismSucker.prepare({
projectTokenAmount: 1e18,
beneficiary: 0x1234β¦,
minTokensReclaimed: 0,
token: 0x000000000000000000000000000000000000EEEe
});
Once this is called, the sucker:
- Transfers Jimmy's $OHIO to itself.
- Redeems the $OHIO using OhioDAO's primary ETH terminal.
- Adds a claim with this information to its ETH outbox tree.
Specifically, the prepare(β¦)
function inserts a leaf into the ETH outbox tree β the leaf is a keccak256 hash of the beneficiary's address, the amount of $OHIO which was redeemed, and the amount of ETH reclaimed by that redemption.
To bridge the outbox tree over, Jimmy (or someone else) calls JBOptimismSucker.toRemote(β¦)
, which takes one argument β the terminal token whose outbox tree should be bridged. Jimmy wants to bridge the ETH outbox tree, so he passes in 0x000000000000000000000000000000000000EEEe
. After a few minutes, the sucker will have bridged over the outbox tree and the ETH it got by redeeming Jimmy's $OHIO, which calls the peer sucker's JBOptimismSucker.fromRemote(β¦)
function. The Optimism OhioDAO sucker's ETH inbox tree is updated with the new merkle root which contains Jimmy's claim.
Jimmy can claim his $OHIO on Optimism by calling JBOptimismSucker.claim(β¦)
, which takes a single JBClaim
as its argument. JBClaim
looks like this:
struct JBClaim {
address token;
JBLeaf leaf;
// Must be `JBSucker.TREE_DEPTH` long.
bytes32[32] proof;
}
Here's the JBLeaf
struct:
/// @notice A leaf in the inbox or outbox tree of a `JBSucker`. Used to `claim` tokens from the inbox tree.
struct JBLeaf {
uint256 index;
address beneficiary;
uint256 projectTokenAmount;
uint256 terminalTokenAmount;
}
These claims can be difficult for integrators to put together β they would have to track every insertion and build merkle proofs for each one. To make this easier, I wrote the juicerkle
service which returns all of the available claims for a specific beneficiary. To use it, POST
a json request to /claims
:
Field | JS Type | Description |
---|---|---|
chainId |
int |
The network ID for the sucker contract being claimed from. |
sucker |
string |
The address of the sucker being claimed from. |
token |
string |
The address of the terminal token whose inbox tree is being claimed from. |
beneficiary |
string |
The address of the beneficiary we're getting the available claims for. |
Jimmy's claims request looks like this:
{
"chainId": 10,
"sucker": "0x5678β¦",
"token": "0x000000000000000000000000000000000000EEEe",
"beneficiary": "0x1234β¦" // jimmy.eth
}
The chainId
is Optimism's network ID. Jimmy's getting his claims for the ETH inbox tree of the JBOptimismSucker
at 0x5678β¦
. The juicerkle
service will look through the entire inbox tree and return all of Jimmy's available claims as JBClaim
structs. The response looks like this:
[
{
Token: "0x000000000000000000000000000000000000eeee",
Leaf: {
Index: 0,
Beneficiary: "0x1234β¦", // jimmy.eth
ProjectTokenAmount: 1000000000000000000, // 1e18
TerminalTokenAmount: 1000000000000000000, // 1e18
},
Proof: [
[
229, 206, 51, 48, 16, 242, 169, 29, 47, 33, 39, 105, 34, 55, 172, 232,
217, 243, 168, 149, 38, 202, 133, 68, 191, 119, 165, 97, 59, 232, 212,
14,
],
[
33, 40, 178, 36, 156, 7, 175, 252, 47, 196, 238, 239, 170, 52, 239, 153,
66, 111, 173, 24, 113, 164, 25, 185, 54, 47, 170, 32, 232, 56, 97, 254,
],
// More 32-byte chunksβ¦
],
},
// More claimsβ¦
];
Jimmy calls JBOptimismSucker.claim(β¦)
with this to claim his $OHIO on Optimism. If the sucker's ADD_TO_BALANCE_MODE
is set to ON_CLAIM
, the bridged ETH associated with Jimmy's $OHIO is immediately added to OhioDAO's balance. Otherwise, it will be added once someone calls JBOptimismSucker.addOutstandingAmountToBalance(β¦)
.
There are a few requirements for launching a sucker pair:
- Projects must already be deployed on both chains. The project IDs don't have to match.
- Both projects must have a 100% redemption rate for the suckers to redeem project tokens for terminal tokens. That is,
JBRulesetMetadata.redemptionRate
must be10_000
, which isJBConstants.MAX_REDEMPTION_RATE
. - Both projects must allow owner minting for the suckers to mint bridged project tokens. That is,
JBRulesetMetadata.allowOwnerMinting
must betrue
. - Both projects must have an ERC-20 project token. If one doesn't, launch it with
JBController.deployERC20For(β¦)
.
Suckers are deployed through the JBSuckerRegistry
on each chain. In the process of deploying the suckers, the sucker registry maps local tokens to remote tokens, so we'll have to give it permission:
JBPermissionsData memory mapTokenPermission = JBPermissionsData({
operator: 0x9ABCβ¦,
projectId: 12,
permissionIds: [28], // JBPermissionIds.MAP_SUCKER_TOKEN == 28
});
JBPermissions.setPermissionsFor({
account: 0x1234β¦,
permissionsData: mapTokenPermission
});
In this example, the project owner 0x1234β¦
gives the JBSuckerRegistry
at 0x9ABCβ¦
permission to map tokens for project 12's suckers. Now the owner can deploy the suckers:
JBTokenMapping memory ethMapping = JBTokenMapping({
localToken: 0x000000000000000000000000000000000000EEEe,
minGas: 100_000, // 100k gas minimum
remoteToken: 0x000000000000000000000000000000000000EEEe,
minBridgeAmount: 25e15, // 0.025 ETH
});
JBSuckerDeployerConfig memory config = JBSuckerDeployerConfig({
deployer: 0xcdefβ¦,
mappings: [ethMapping]
});
JBSuckerRegistry.deploySuckersFor({
projectId: 12,
salt: 0xfce167d38e3d9c2a0375c172d979c39c696f2450616565c1c3284e00f0fac074,
configurations: [config]
});
- The
JBTokenMapping
maps local mainnet ETH to remote Optimism ETH.- To prevent spam, the mapping has a
minBridgeAmount
β ours blocks attempts to bridge less than 0.025 ETH. - To prevent transactions from failing, our
minGas
requires a gas limit greater than 100,000 wei. - These are good starting values, but you may need to adjust them β if your token has expensive transfer logic, you may need a higher
minGas
.
- To prevent spam, the mapping has a
- The
JBSuckerDeployerConfig
uses theJBOptimismSuckerDeployer
at0xcdefβ¦
to deploy the sucker.- You can only use approved sucker deployers through the registry. Check for
SuckerDeployerAllowed
events or contact the registry's owner to figure out which deployers are approved.
- You can only use approved sucker deployers through the registry. Check for
- We call
JBSuckerRegistry.deploySuckersFor(β¦)
with the project's ID (12), a randomly generated 32-byte salt, and the configuration.- For the suckers to be peers, the
salt
has to match on both chains and the same address must calldeploySuckersFor(β¦)
.
- For the suckers to be peers, the
The suckers are deployed! We have to give the sucker permission to mint bridged project tokens:
JBPermissionsData memory mintPermission = JBPermissionsData({
operator: 0x1357β¦,
projectId: 12,
permissionIds: [9], // JBPermissionIds.MINT_TOKENS == 9
});
JBPermissions.setPermissionsFor({
account: 0x1234β¦,
permissionsData: mintPermission
});
In this example, the project owner 0x1234β¦
gives their new JBSucker
at 0x1357β¦
permission to mint project 12's tokens.
Repeat this process on the other chain to deploy the peer sucker, and the project should be ready for bridging.
This tech is still under construction β expect this to change.
Bridging from L1 to L2 is straightforward. Bridging from L2 to L1 usually requires an extra step to finalize the withdrawal, which is different for each L2. For OP Stack networks like Optimism or Base, this is 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 that commits to the state of the L2ToL1MessagePasser's storage on L2)
- The withdrawal finalizing transaction, which the user submits on L1 after the fault challenge period has passed, to actually run the transaction on L1.
Users can do this manually, but it's a hassle. To simplify this process, 0xBA5ED wrote the bananapus-sucker-relayer
, a tool which automatically proves and finalizes withdrawals from Optimism or Base to Ethereum mainnet. It listens for withdrawals and automatically completes the withdrawal process using OpenZeppelin Defender.
To use the relayer, project creators have to create an OpenZeppelin Defender account, set up a relayer through their dashboard, and fund it with ETH (to pay gas fees). This relayer is still in development, so expect changes.
- The
nana-suckers
contracts use Nomad'sMerkleLib
merkle tree implementation, which is based on the eth2 deposit contract. I couldn't find a comparable implementation in Golang, so I wrote one which you're welcome to use: thetree
package in thejuicerkle
project. It provides utilities for calculating roots, as well as building and verifying merkle proofs. I use this implementation in thejuicerkle
service to generate claims. - To thoroughly test
juicerkle
in practice, I built the end-to-endjuicerkle-tester
. As well as testing thejuicerkle
service, it serves as a useful bridging process walkthrough β it deploys appropriately configured projects, tokens, and suckers, and bridges between them.
Once configured suckers should manage themselves, however its important to stay up-to-date on changes to the bridge infrastructure that is used by the sucker of your choice. In the case that a change is made that would cause suckers to no longer be functional/compatible with the underlying bridge infrastructure there are two options: (note, make sure to perform these actions on BOTH sides of the suckers)
In the case that a change to the underlying bridge causes only a single (or few) tokens to no longer function you might want to disable just those tokens. Your first step should be to call mapToken(...)
with the token you wish to disable and remoteToken
set to address(0)
to disable it.
If this does not work because the bridge will not let you perform a final transfer with the remaining funds then you can activate the EmergencyHatch
for the tokens that are giving issues.
Enabling the EmergencyHatch
allows tokens to be withdrawn by their depositors on the chain where they were deposited. Only those whose funds have not been moved to the remote chain can withdraw using the EmergencyHatch
.
An important side-note is that once an EmergencyHatch is opened for a token, the token will never be able to be bridged using this sucker. You can however deploy a new sucker for that token.
In the case that the bridiging infrastructure will no longer work you should deprecate the sucker, this will make it so that the sucker will start its shutdown procedure. Depending on the sucker implementation this will have a minimum duration which is needed to ensure that no funds/roots get lost while in transit. After this duration all tokens will allow for exit through the EmergencyHatch
and no new messages will be accepted.
This makes it so that even if at some point in the future the bridge starts sending fake/malicious transfers the sucker will reject all of these.
When deprecating suckers make sure that your bridge infrastructure does not have pending messages that can/should be retried. Once the deprecation is complete these messages will no longer be accepted by the sucker.