skills$openclaw/jb-terminal-wrapper
mejango8.5k

by mejango

jb-terminal-wrapper – OpenClaw Skill

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

8.5k stars3.4k forksSecurity L1
Updated Feb 7, 2026Created Feb 7, 2026coding

Skill Snapshot

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

Maintainer

mejango

mejango

Maintains jb-terminal-wrapper in the OpenClaw Skills directory.

View GitHub profile
File Explorer
1 files
jb-terminal-wrapper
SKILL.md
14.6 KB
SKILL.md

name: jb-terminal-wrapper description: | Terminal wrapper pattern for extending JBMultiTerminal functionality. Use when: (1) need dynamic splits at pay time, (2) revnet can't modify ruleset data hooks, (3) want atomic pay + distribute operations, (4) need to intercept/redirect tokens before delivery, (5) implementing pay-time configuration, (6) cash out + bridge/swap in one tx, (7) cash out + stake redeemed funds. Covers IJBTerminal implementation, _acceptFunds pattern from JBSwapTerminalRegistry, beneficiary manipulation for both pay and cash out flows, and the critical mental model that wrappers are additive (not restrictive).

Terminal Wrapper Pattern

Problem

Revnets and other projects often need extended payment functionality that can't be achieved through ruleset data hooks alone. Common needs include:

  • Dynamic splits specified at payment time
  • Token interception and redirection
  • Atomic multi-step operations (pay + distribute)
  • Client-specific features without breaking permissionless access

Context / Trigger Conditions

Apply this pattern when:

  • Building payment flows that need dynamic configuration
  • Working with revnets where ruleset hooks can't be edited
  • Need to bundle multiple operations atomically
  • Want to intercept tokens for further processing
  • Implementing "pay and do X" flows

Solution

Core Architecture

Create a custom IJBTerminal that wraps JBMultiTerminal. Use a shared _acceptFunds helper (pattern from JBSwapTerminalRegistry) to handle ETH/ERC20 consistently:

contract PayWithSplitsTerminal is IJBTerminal {
    using SafeERC20 for IERC20;

    IJBMultiTerminal public immutable MULTI_TERMINAL;
    IJBController public immutable CONTROLLER;

    constructor(IJBMultiTerminal _multiTerminal, IJBController _controller) {
        MULTI_TERMINAL = _multiTerminal;
        CONTROLLER = _controller;
    }

    function pay(
        uint256 projectId,
        address token,
        uint256 amount,
        address beneficiary,
        uint256 minReturnedTokens,
        string calldata memo,
        bytes calldata metadata
    ) external payable returns (uint256 beneficiaryTokenCount) {
        // 1. Parse custom metadata
        (JBSplit[] memory splits, bytes memory innerMetadata) = _parseMetadata(metadata);

        // 2. Configure splits if provided
        if (splits.length > 0) {
            _configureSplits(projectId, splits);
        }

        // 3. Accept funds (handles ETH/ERC20 uniformly)
        uint256 valueToSend = _acceptFunds(token, amount, address(MULTI_TERMINAL));

        // 4. Forward to underlying terminal
        beneficiaryTokenCount = MULTI_TERMINAL.pay{value: valueToSend}(
            projectId,
            token,
            amount,
            beneficiary,
            minReturnedTokens,
            memo,
            innerMetadata
        );

        // 5. Distribute reserved tokens
        CONTROLLER.sendReservedTokensToSplitsOf(projectId);

        return beneficiaryTokenCount;
    }

    /// @notice Accept funds from caller and prepare for forwarding.
    /// @dev Pattern from JBSwapTerminalRegistry - consolidates token handling.
    function _acceptFunds(
        address token,
        uint256 amount,
        address spender
    ) internal returns (uint256 valueToSend) {
        if (token == JBConstants.NATIVE_TOKEN) {
            return msg.value; // Forward ETH
        }

        // ERC20: pull from sender, approve spender
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
        IERC20(token).forceApprove(spender, amount);
        return 0; // No ETH to forward
    }
}

Beneficiary Manipulation Pattern

Intercept tokens by setting beneficiary to the wrapper itself:

function payAndStake(
    uint256 projectId,
    address token,
    uint256 amount,
    uint256 minReturnedTokens,
    bytes calldata metadata
) external payable returns (uint256 tokenCount) {
    // Parse user's desired destination from metadata
    (address finalDestination, bytes memory stakingParams) = abi.decode(
        metadata,
        (address, bytes)
    );

    // Receive tokens to this contract
    tokenCount = MULTI_TERMINAL.pay{value: msg.value}(
        projectId,
        token,
        amount,
        address(this),  // <-- Wrapper receives tokens
        minReturnedTokens,
        "",
        ""
    );

    // Do something with the tokens
    IERC20 projectToken = IERC20(CONTROLLER.TOKENS().tokenOf(projectId));

    // Example: stake them somewhere on behalf of user
    _stakeTokens(projectToken, tokenCount, finalDestination, stakingParams);

    return tokenCount;
}

Metadata Encoding (Client Side)

import { encodeAbiParameters, parseAbiParameters } from 'viem';

// For dynamic splits
const metadata = encodeAbiParameters(
  parseAbiParameters('(address preferredBeneficiary, uint256 percent, uint256 lockedUntil)[], bytes'),
  [
    [
      { preferredBeneficiary: '0x...', percent: 500000000n, lockedUntil: 0n }, // 50%
      { preferredBeneficiary: '0x...', percent: 500000000n, lockedUntil: 0n }, // 50%
    ],
    '0x' // Inner metadata for MultiTerminal
  ]
);

// For beneficiary redirection
const metadata = encodeAbiParameters(
  parseAbiParameters('address finalDestination, bytes stakingParams'),
  [userAddress, stakingCalldata]
);

Critical Mental Model

┌─────────────────────────────────────────────────────────────────┐
│                    WRAPPER IS ADDITIVE                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   Client A ──► PayWithSplitsTerminal ──► JBMultiTerminal       │
│                    (gets special features)                      │
│                                                                 │
│   Client B ────────────────────────────► JBMultiTerminal       │
│                    (still works!)                               │
│                                                                 │
│   BOTH ARE VALID. The wrapper cannot block direct access.       │
│   This is a FEATURE, not a bug. Permissionless = good.          │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Bad thinking: "I'll use a wrapper to block payments that don't meet criteria X" Reality: Users can always call JBMultiTerminal.pay() directly

Good thinking: "I'll use a wrapper to provide enhanced functionality for clients that opt in"

Use Cases

Use CaseHow Wrapper Helps
Pay Wrappers
Dynamic splits at pay timeParse splits from metadata, configure before pay
Pay + distribute reservedAtomic operation, no separate tx needed
Token interceptionReceive to self, then stake/lock/forward
Referral trackingParse referrer from metadata, record on-chain
Conditional logicCheck conditions before forwarding to MultiTerminal
Multi-hop paymentsReceive tokens, swap, pay another project
Cash Out Wrappers
Cash out + bridgeIntercept redeemed funds, bridge to another chain
Cash out + swapSwap redeemed ETH to stablecoin before delivery
Cash out + stakeStake redeemed funds in another protocol
Cash out + LPAdd redeemed funds to liquidity pool

Cash Out Wrapper Pattern

Same beneficiary-to-self trick works for cash outs:

/// @notice Cash out with automatic swap to different token.
function cashOutAndSwap(
    address holder,
    uint256 projectId,
    uint256 tokenCount,
    address tokenToReclaim,
    uint256 minTokensReclaimed,
    address tokenOut,       // Custom param: swap to this
    uint256 minAmountOut,   // Custom param: slippage
    address beneficiary,
    bytes calldata metadata
) external returns (uint256 amountOut) {
    // 1. Cash out to THIS contract (intercept funds)
    uint256 reclaimAmount = MULTI_TERMINAL.cashOutTokensOf(
        holder,
        projectId,
        tokenCount,
        tokenToReclaim,
        minTokensReclaimed,
        address(this),  // <-- Wrapper receives redeemed funds
        metadata
    );

    // 2. Swap redeemed tokens to desired output
    amountOut = _swap(tokenToReclaim, tokenOut, reclaimAmount, minAmountOut);

    // 3. Send swapped tokens to beneficiary
    _sendFunds(tokenOut, amountOut, beneficiary);

    return amountOut;
}

/// @notice Cash out with automatic bridging.
function cashOutAndBridge(
    address holder,
    uint256 projectId,
    uint256 tokenCount,
    address tokenToReclaim,
    uint256 minTokensReclaimed,
    address beneficiary,
    uint256 destChainId,
    bytes calldata metadata
) external returns (uint256 reclaimAmount) {
    // 1. Cash out to this contract
    reclaimAmount = MULTI_TERMINAL.cashOutTokensOf(
        holder,
        projectId,
        tokenCount,
        tokenToReclaim,
        minTokensReclaimed,
        address(this),
        metadata
    );

    // 2. Bridge funds to destination chain
    _bridgeFunds(tokenToReclaim, reclaimAmount, beneficiary, destChainId);

    return reclaimAmount;
}

Comparison with Swap Terminal

Swap Terminal is a canonical example of this pattern:

User pays with USDC ──► SwapTerminal ──► Swaps to ETH ──► JBMultiTerminal
                        (wraps + transforms)

Your wrapper follows the same architecture but with different transformation logic.

Verification

  1. Deploy wrapper pointing to existing JBMultiTerminal
  2. Test that direct MultiTerminal payments still work (permissionless)
  3. Test that wrapper payments get enhanced behavior
  4. Verify atomic operations complete or revert together
  5. Test metadata parsing edge cases (empty, malformed)

Example

Complete implementation for pay-time splits:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {IJBTerminal} from "@bananapus/core/src/interfaces/IJBTerminal.sol";
import {IJBMultiTerminal} from "@bananapus/core/src/interfaces/IJBMultiTerminal.sol";
import {IJBController} from "@bananapus/core/src/interfaces/IJBController.sol";
import {IJBSplits} from "@bananapus/core/src/interfaces/IJBSplits.sol";
import {JBSplit} from "@bananapus/core/src/structs/JBSplit.sol";
import {JBSplitGroup} from "@bananapus/core/src/structs/JBSplitGroup.sol";

contract DynamicSplitsTerminal is IJBTerminal {
    IJBMultiTerminal public immutable MULTI_TERMINAL;
    IJBController public immutable CONTROLLER;

    // Split group ID for reserved tokens
    uint256 constant RESERVED_TOKEN_GROUP = 1;

    error InvalidSplitTotal();

    constructor(IJBMultiTerminal _multiTerminal, IJBController _controller) {
        MULTI_TERMINAL = _multiTerminal;
        CONTROLLER = _controller;
    }

    /// @notice Pay a project with dynamic splits specified in metadata
    /// @param projectId The project to pay
    /// @param token The token to pay with (use JBConstants.NATIVE_TOKEN for ETH)
    /// @param amount The amount to pay
    /// @param beneficiary Who receives the project tokens
    /// @param minReturnedTokens Minimum tokens to receive (slippage protection)
    /// @param memo Payment memo
    /// @param metadata ABI-encoded (JBSplit[], bytes innerMetadata)
    function pay(
        uint256 projectId,
        address token,
        uint256 amount,
        address beneficiary,
        uint256 minReturnedTokens,
        string calldata memo,
        bytes calldata metadata
    ) external payable returns (uint256 beneficiaryTokenCount) {
        bytes memory innerMetadata;

        // Parse and apply splits if metadata provided
        if (metadata.length > 0) {
            JBSplit[] memory splits;
            (splits, innerMetadata) = abi.decode(metadata, (JBSplit[], bytes));

            if (splits.length > 0) {
                _validateAndSetSplits(projectId, splits);
            }
        }

        // Forward payment to MultiTerminal
        beneficiaryTokenCount = MULTI_TERMINAL.pay{value: msg.value}(
            projectId,
            token,
            amount,
            beneficiary,
            minReturnedTokens,
            memo,
            innerMetadata
        );

        // Distribute reserved tokens to the new splits
        CONTROLLER.sendReservedTokensToSplitsOf(projectId);

        return beneficiaryTokenCount;
    }

    function _validateAndSetSplits(uint256 projectId, JBSplit[] memory splits) internal {
        // Validate splits sum to 100% (1e9 = JBConstants.SPLITS_TOTAL_PERCENT)
        uint256 total;
        for (uint256 i; i < splits.length; i++) {
            total += splits[i].percent;
        }
        if (total != 1e9) revert InvalidSplitTotal();

        // Get current ruleset
        uint256 rulesetId = CONTROLLER.currentRulesetOf(projectId).id;

        // Set splits for reserved token group
        JBSplitGroup[] memory groups = new JBSplitGroup[](1);
        groups[0] = JBSplitGroup({
            groupId: RESERVED_TOKEN_GROUP,
            splits: splits
        });

        CONTROLLER.setSplitGroupsOf(projectId, rulesetId, groups);
    }

    // Implement other IJBTerminal functions as pass-through...
    function addToBalanceOf(
        uint256 projectId,
        address token,
        uint256 amount,
        bool shouldReturnHeldFees,
        string calldata memo,
        bytes calldata metadata
    ) external payable {
        MULTI_TERMINAL.addToBalanceOf{value: msg.value}(
            projectId, token, amount, shouldReturnHeldFees, memo, metadata
        );
    }
}
  • Wrapper must be granted appropriate permissions if setting splits (add to project's permission system)
  • Consider gas costs of extra operations
  • Metadata parsing adds attack surface - validate carefully
  • For revnets: this is often the ONLY way to add functionality post-deploy
  • Multiple wrappers can exist for different purposes - they don't conflict
  • Wrappers can be chained: WrapperA → WrapperB → MultiTerminal
  • /jb-patterns - All JB V5 design patterns (includes condensed version of this)
  • /jb-pay-hook - Data hooks for pay-time logic (when ruleset allows)
  • /jb-split-hook - Custom split distribution logic
  • /jb-v5-api - Core terminal and controller interfaces
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-terminal-wrapper?

Run openclaw add @mejango/juicy:jb-terminal-wrapper in your terminal. This installs jb-terminal-wrapper 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.