Filip's World
Man's character is his fate.

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.

Contents

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.
Back to top