@bananapus/suckers

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


Keywords
defi, ethereum, juicebox
License
MIT
Install
npm install @bananapus/suckers@0.0.24

Documentation

Bananapus Sucker

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
  1. Usage
  2. Repository Layout
  3. Architecture
  4. Description

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.

Usage

Install

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.

Develop

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.

Scripts

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/

Deployments

With Sphinx

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.

Without Sphinx

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.

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.txt

This makes the extension aware of default remappings.

Repository Layout

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.

Architecture

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
Loading

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
Loading

Description

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.

Basics

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:

  1. The amount of project tokens to bridge, and
  2. 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.

Bridging Tokens

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 by JBConstants.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(…).

Launching Suckers

There are a few requirements for launching a sucker pair:

  1. Projects must already be deployed on both chains. The project IDs don't have to match.
  2. Both projects must have a 100% redemption rate for the suckers to redeem project tokens for terminal tokens. That is, JBRulesetMetadata.redemptionRate must be 10_000, which is JBConstants.MAX_REDEMPTION_RATE.
  3. Both projects must allow owner minting for the suckers to mint bridged project tokens. That is, JBRulesetMetadata.allowOwnerMinting must be true.
  4. 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.
  • The JBSuckerDeployerConfig uses the JBOptimismSuckerDeployer at 0xcdef… 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.
  • 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 call deploySuckersFor(…).

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.

Using the Relayer

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:

  1. The withdrawal initiating transaction, which the user submits on L2.
  2. 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)
  3. 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.

Resources

  1. The nana-suckers contracts use Nomad's MerkleLib 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: the tree package in the juicerkle project. It provides utilities for calculating roots, as well as building and verifying merkle proofs. I use this implementation in the juicerkle service to generate claims.
  2. To thoroughly test juicerkle in practice, I built the end-to-end juicerkle-tester. As well as testing the juicerkle service, it serves as a useful bridging process walkthrough – it deploys appropriately configured projects, tokens, and suckers, and bridges between them.

Managing suckers

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)

Disable a token

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.

Deprecate the suckers

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.