skills$openclaw/jb-suckers
mejango6.2k

by mejango

jb-suckers – OpenClaw Skill

jb-suckers is an OpenClaw Skills integration for coding workflows. |

6.2k stars4.4k forksSecurity L1
Updated Feb 7, 2026Created Feb 7, 2026coding

Skill Snapshot

namejb-suckers
description| OpenClaw Skills integration.
ownermejango
repositorymejango/juicypath: jb-suckers
languageMarkdown
licenseMIT
topics
securityL1
installopenclaw add @mejango/juicy:jb-suckers
last updatedFeb 7, 2026

Maintainer

mejango

mejango

Maintains jb-suckers in the OpenClaw Skills directory.

View GitHub profile
File Explorer
1 files
jb-suckers
SKILL.md
15.2 KB
SKILL.md

name: jb-suckers description: | Juicebox V5 sucker contracts for cross-chain token bridging. Use when: (1) implementing bridge functionality, (2) understanding prepare/toRemote/claim flow, (3) working with merkle proofs for cross-chain claims, (4) querying sucker pairs from registry, (5) handling emergency exits, (6) debugging "claimable" vs "pending" states, (7) encoding sucker transaction calldata. Covers JBSucker, JBOptimismSucker, JBArbitrumSucker, JBCCIPSucker, and JBSuckerRegistry.

Juicebox V5 Suckers - Cross-Chain Token Bridging

Problem

Bridging project tokens between chains while maintaining their proportional treasury backing requires understanding a complex three-phase protocol with merkle proofs, chain-specific AMBs, and careful state management.

Context / Trigger Conditions

Apply this knowledge when:

  • Building cross-chain bridging UIs
  • Encoding prepare(), toRemote(), or claim() transactions
  • Querying pending/claimable bridge transactions
  • Fetching merkle proofs from Juicerkle
  • Understanding why a bridge is "stuck" in pending state
  • Implementing emergency exit flows
  • Working with JBSuckerRegistry to find bridge routes

Solution

What Are Suckers?

Suckers are specialized bridge contracts that link Juicebox projects across chains and move project tokens AND their proportional treasury backing between them.

Why Suckers are necessary: Project IDs cannot be coordinated across chains—each chain assigns the next available ID independently. If you deploy to Ethereum you might get project #42, and deploying to Optimism might give you project #17. Suckers connect these separate projects so they function as a single "omnichain project" with unified token bridging.

Unlike standard token bridges:

  • Tokens are burned on source chain via cash-out
  • Proportional ETH/USDC moves with the tokens
  • Recipient receives newly minted tokens on destination
  • Treasury value follows the tokens

The Three-Phase Bridge Flow

PHASE 1: PREPARE (Source Chain)
┌─────────────────────────────────────────────────────────────┐
│ User calls: sucker.prepare(                                  │
│   projectTokenCount,  // Amount to bridge                   │
│   beneficiary,        // Recipient on remote chain          │
│   minTokensReclaimed, // Slippage protection                │
│   token               // Terminal token (ETH/USDC address)  │
│ )                                                           │
│                                                             │
│ What happens:                                               │
│ 1. Project tokens transferred from user to sucker           │
│ 2. Sucker calls terminal.cashOutTokensOf()                  │
│ 3. Receives proportional ETH/USDC from treasury             │
│ 4. Creates leaf in outbox merkle tree                       │
│ 5. Emits InsertToOutboxTree event                          │
│                                                             │
│ Status: PENDING                                             │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
PHASE 2: EXECUTE (Cross-Chain Message)
┌─────────────────────────────────────────────────────────────┐
│ User/Relayer calls: sucker.toRemote(token)                  │
│                                                             │
│ What happens:                                               │
│ 1. Computes merkle root of all pending outbox leaves        │
│ 2. Increments nonce                                         │
│ 3. Sends JBMessageRoot via AMB:                             │
│    - OP Stack: IOPMessenger.sendMessage()                   │
│    - Arbitrum: IInbox.unsafeCreateRetryableTicket()         │
│    - CCIP: ICCIPRouter.ccipSend()                          │
│ 4. Transfers ETH/tokens to peer sucker                      │
│ 5. Emits RootToRemote event                                │
│                                                             │
│ Status: CLAIMABLE (on destination)                          │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
PHASE 3: CLAIM (Destination Chain)
┌─────────────────────────────────────────────────────────────┐
│ User calls: peerSucker.claim(claimData)                     │
│                                                             │
│ claimData = {                                               │
│   token: address,                                           │
│   leaf: { index, beneficiary, projectTokenCount,            │
│           terminalTokenAmount },                            │
│   proof: bytes32[32]  // Merkle proof from Juicerkle        │
│ }                                                           │
│                                                             │
│ What happens:                                               │
│ 1. Validates merkle proof against inbox root                │
│ 2. Checks leaf not already executed (prevents double-spend) │
│ 3. Marks leaf as executed in bitmap                         │
│ 4. Mints project tokens to beneficiary                      │
│ 5. Adds terminal tokens to project balance                  │
│ 6. Emits Claimed event                                      │
│                                                             │
│ Status: CLAIMED                                             │
└─────────────────────────────────────────────────────────────┘

Key Contracts

ContractPurpose
JBSuckerAbstract base with core bridging logic
JBOptimismSuckerOP Stack bridges (Optimism, Base)
JBArbitrumSuckerArbitrum Inbox/Outbox messaging
JBCCIPSuckerChainlink CCIP for L2↔L2
JBSuckerRegistryDeploys and tracks sucker pairs

Querying Sucker Pairs

// Get all bridge destinations for a project
const pairs = await publicClient.readContract({
  address: JB_SUCKER_REGISTRY,
  abi: [{
    name: 'suckerPairsOf',
    type: 'function',
    inputs: [{ name: 'projectId', type: 'uint256' }],
    outputs: [{
      name: 'pairs',
      type: 'tuple[]',
      components: [
        { name: 'local', type: 'address' },
        { name: 'remote', type: 'address' },
        { name: 'remoteChainId', type: 'uint256' }
      ]
    }],
    stateMutability: 'view'
  }],
  functionName: 'suckerPairsOf',
  args: [projectId]
});

// pairs = [
//   { local: '0x...', remote: '0x...', remoteChainId: 10n },
//   { local: '0x...', remote: '0x...', remoteChainId: 8453n }
// ]

Encoding Transactions

Prepare (Step 1):

import { encodeFunctionData } from 'viem';

const prepareData = encodeFunctionData({
  abi: [{
    name: 'prepare',
    type: 'function',
    inputs: [
      { name: 'projectTokenCount', type: 'uint256' },
      { name: 'beneficiary', type: 'address' },
      { name: 'minTokensReclaimed', type: 'uint256' },
      { name: 'token', type: 'address' }
    ],
    outputs: [],
    stateMutability: 'nonpayable'
  }],
  functionName: 'prepare',
  args: [
    parseUnits('100', 18),     // 100 project tokens
    beneficiaryAddress,
    parseUnits('0.9', 18),     // 10% slippage allowed
    NATIVE_TOKEN               // 0xEEEE...EEEe for ETH
  ]
});

// Send transaction
await walletClient.sendTransaction({
  to: suckerAddress,
  data: prepareData
});

Execute (Step 2):

// Estimate fee via simulation (binary search)
async function estimateBridgeFee(sucker, token) {
  let low = 0n;
  let high = parseUnits('0.04', 18);

  for (let i = 0; i < 10; i++) {
    const mid = (low + high) / 2n;
    try {
      await publicClient.simulateContract({
        address: sucker,
        abi: SUCKER_ABI,
        functionName: 'toRemote',
        args: [token],
        value: mid
      });
      high = mid; // Success - try lower
    } catch {
      low = mid;  // Failed - try higher
    }
  }
  return (high * 110n) / 100n; // Add 10% buffer
}

const fee = await estimateBridgeFee(suckerAddress, NATIVE_TOKEN);

const toRemoteData = encodeFunctionData({
  abi: [{
    name: 'toRemote',
    type: 'function',
    inputs: [{ name: 'token', type: 'address' }],
    outputs: [],
    stateMutability: 'payable'
  }],
  functionName: 'toRemote',
  args: [NATIVE_TOKEN]
});

await walletClient.sendTransaction({
  to: suckerAddress,
  data: toRemoteData,
  value: fee
});

Claim (Step 3):

// Fetch proof from Juicerkle
const JUICERKLE_API = 'https://juicerkle-production.up.railway.app';

// NOTE: Addresses must be lowercase for Juicerkle API
const proofResponse = await fetch(`${JUICERKLE_API}/claims`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    chainId: destinationChainId,
    sucker: peerSuckerAddress.toLowerCase(),
    token: NATIVE_TOKEN.toLowerCase(),
    beneficiary: userAddress.toLowerCase()
  })
});

// Response uses PascalCase
// interface JuicerkleClaim {
//   Token: string;
//   Leaf: { Index, Beneficiary, ProjectTokenCount, TerminalTokenAmount };
//   Proof: number[][]; // Array of 32-byte arrays
// }
const proofs = await proofResponse.json();
const claim = proofs[0];

// Convert Proof from number[][] to bytes32[]
const proofBytes = claim.Proof.map(arr => {
  const hex = arr.map(b => b.toString(16).padStart(2, '0')).join('');
  return `0x${hex}`;
});

const claimData = encodeFunctionData({
  abi: [{
    name: 'claim',
    type: 'function',
    inputs: [{
      name: 'claimData',
      type: 'tuple',
      components: [
        { name: 'token', type: 'address' },
        { name: 'leaf', type: 'tuple', components: [
          { name: 'index', type: 'uint256' },
          { name: 'beneficiary', type: 'address' },
          { name: 'projectTokenCount', type: 'uint256' },
          { name: 'terminalTokenAmount', type: 'uint256' }
        ]},
        { name: 'proof', type: 'bytes32[32]' }
      ]
    }],
    outputs: [],
    stateMutability: 'nonpayable'
  }],
  functionName: 'claim',
  args: [{
    token: claim.Token,
    leaf: {
      index: BigInt(claim.Leaf.Index),
      beneficiary: claim.Leaf.Beneficiary,
      projectTokenCount: BigInt(claim.Leaf.ProjectTokenCount),
      terminalTokenAmount: BigInt(claim.Leaf.TerminalTokenAmount)
    },
    proof: proofBytes
  }]
});

await walletClient.sendTransaction({
  to: peerSuckerAddress,
  data: claimData
});

Querying Bridge Status (Bendystraw)

query SuckerTransactions($suckerGroupId: String!, $status: suckerTransactionStatus) {
  suckerTransactions(
    where: { suckerGroupId: $suckerGroupId, status: $status }
    orderBy: "createdAt"
    orderDirection: "desc"
  ) {
    items {
      id
      chainId
      peerChainId
      sucker
      peer
      beneficiary
      projectTokenCount
      terminalTokenAmount
      token
      status        # "pending" | "claimable" | "claimed"
      index
      root
      createdAt
    }
  }
}

State Transitions

StatusMeaningNext Action
pendingPrepared but not sentCall toRemote()
claimableRoot arrived, awaiting claimCall claim() with proof
claimedCompleteNone

Emergency Exit

If a bridge becomes non-functional:

// 1. Project owner enables emergency hatch
await ownerClient.writeContract({
  address: suckerAddress,
  abi: SUCKER_ABI,
  functionName: 'enableEmergencyHatchFor',
  args: [token]
});

// 2. Users can exit locally (no bridging)
// Retrieves funds from outbox without crossing chains
await userClient.writeContract({
  address: suckerAddress,
  abi: SUCKER_ABI,
  functionName: 'exitThroughEmergencyHatch',
  args: [claimData] // Same structure as claim()
});

Token Mapping

Projects must map which tokens can be bridged:

const mapping = {
  localToken: USDC_MAINNET,
  remoteToken: USDC_OPTIMISM,
  minGas: 300000,        // Minimum gas for cross-chain call
  minBridgeAmount: 10e6  // Minimum 10 USDC to bridge
};

await ownerClient.writeContract({
  address: suckerAddress,
  abi: SUCKER_ABI,
  functionName: 'mapToken',
  args: [mapping]
});

Chain-Specific Notes

OP Stack (Optimism, Base):

  • Uses native OP Messenger
  • Lowest fees (~0.0005-0.002 ETH)
  • Fast finality

Arbitrum:

  • Uses Retryable Tickets
  • Dynamic gas pricing
  • Requires calculating maxSubmissionCost

CCIP (L2↔L2):

  • Highest fees but most flexible
  • Works between any CCIP-supported chains
  • Good for Optimism↔Arbitrum, Base↔Arbitrum

Sucker Deprecation

ENABLED → DEPRECATION_PENDING → SENDING_DISABLED → DEPRECATED
  • DEPRECATION_PENDING: Warning state, still functional
  • SENDING_DISABLED: Cannot prepare new bridges, can still claim
  • DEPRECATED: Only emergency exits allowed

Verification

  1. Check sucker state before bridging: sucker.state()
  2. Verify token is mapped: sucker.remoteTokenFor(localToken)
  3. Check outbox balance: sucker.outboxOf(token).balance
  4. Verify claim proof via Juicerkle before submitting

Example

Complete bridge flow from React:

async function bridgeTokens({
  sourceChainId,
  destChainId,
  suckerAddress,
  amount,
  beneficiary
}: BridgeParams) {
  // 1. Approve project token
  await writeContract({
    address: projectToken,
    abi: erc20Abi,
    functionName: 'approve',
    args: [suckerAddress, amount]
  });

  // 2. Prepare
  await writeContract({
    address: suckerAddress,
    abi: suckerAbi,
    functionName: 'prepare',
    args: [amount, beneficiary, 0n, NATIVE_TOKEN]
  });

  // 3. Execute (can be batched with others)
  const fee = await estimateBridgeFee(suckerAddress, NATIVE_TOKEN);
  await writeContract({
    address: suckerAddress,
    abi: suckerAbi,
    functionName: 'toRemote',
    args: [NATIVE_TOKEN],
    value: fee
  });

  // 4. Wait for root to arrive (check Bendystraw)
  // 5. Claim on destination (separate transaction)
}
  • Merkle tree depth is 32 - proofs are always bytes32[32]
  • Nonces are monotonically increasing - prevents replay attacks
  • Each token has independent outbox/inbox trees
  • addToBalanceMode can be MANUAL or ON_CLAIM
  • Double-spend prevention via executed leaf bitmap
  • Emergency hatch uses separate execution namespace
  • /jb-omnichain-ui - Building omnichain UIs with Relayr and Bendystraw
  • /jb-v5-currency-types - Currency handling for cross-chain projects
  • /jb-bendystraw - Querying cross-chain data
README.md

No README available.

Permissions & Security

Security level L1: Low-risk skills with minimal permissions. Review inputs and outputs before running in production.

Requirements

  • OpenClaw CLI installed and configured.
  • Language: Markdown
  • License: MIT
  • Topics:

FAQ

How do I install jb-suckers?

Run openclaw add @mejango/juicy:jb-suckers in your terminal. This installs jb-suckers into your OpenClaw Skills catalog.

Does this skill run locally or in the cloud?

OpenClaw Skills execute locally by default. Review the SKILL.md and permissions before running any skill.

Where can I verify the source code?

The source repository is available at https://github.com/openclaw/skills/tree/main/skills/mejango/juicy. Review commits and README documentation before installing.