skills$openclaw/jb-patterns
mejango6.5k

by mejango

jb-patterns – OpenClaw Skill

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

6.5k stars5.8k forksSecurity L1
Updated Feb 7, 2026Created Feb 7, 2026coding

Skill Snapshot

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

Maintainer

mejango

mejango

Maintains jb-patterns in the OpenClaw Skills directory.

View GitHub profile
File Explorer
1 files
jb-patterns
SKILL.md
76.9 KB
SKILL.md

name: jb-patterns description: | Common Juicebox V5 design patterns for vesting, NFT treasuries, terminal wrappers, yield integration, and governance-minimal configurations. Use when: (1) need treasury vesting without custom contracts, (2) building NFT-gated redemptions, (3) extending revnet functionality via pay wrappers, (4) implementing custom ERC20 tokens, (5) integrating yield protocols like Aave, (6) deciding between native mechanics vs custom code. Covers 11 patterns including terminal wrapper for dynamic pay-time splits, yield-generating hooks for Aave/DeFi integration, and token interception. Golden rule: prefer configuration over custom contracts.

Juicebox V5 Design Patterns

Proven patterns for common use cases using native Juicebox mechanics. Always prefer configuration over custom contracts.

Golden Rule

Before writing custom code, ask: "Can this be achieved with payout limits, surplus allowance, splits, and cycling rulesets?"


Pattern 1: Vesting via Native Mechanics

Use case: Release funds to a beneficiary over time (e.g., team vesting, milestone-based releases)

Solution: Use cycling rulesets with payout limits

How It Works

MechanismBehaviorUse For
Payout LimitResets each cycleRecurring distributions (vesting)
Surplus AllowanceOne-time per rulesetDiscretionary treasury access
Cycle DurationDetermines distribution frequencyMonthly = 30 days

Configuration

JBRulesetConfig({
    duration: 30 days,                    // Monthly cycles
    // ... other config
    fundAccessLimitGroups: [
        JBFundAccessLimitGroup({
            terminal: address(TERMINAL),
            token: JBConstants.NATIVE_TOKEN,
            payoutLimits: [
                JBCurrencyAmount({
                    amount: 6.67 ether,   // Monthly vesting amount
                    currency: nativeCurrency
                })
            ],
            surplusAllowances: [
                JBCurrencyAmount({
                    amount: 20 ether,     // One-time treasury (doesn't reset)
                    currency: nativeCurrency
                })
            ]
        })
    ]
});

Capital Flow

Month 0: Balance = 100 ETH
         Surplus = Balance - Payout Limit = 93.33 ETH (redeemable)

Month 1: Team calls sendPayoutsOf() → receives 6.67 ETH
         Balance = 93.33 ETH
         Surplus = 86.67 ETH

Month 12: All vested, Balance = 20 ETH (treasury allowance)

Key Insight

  • Payout limits protect vesting funds from redemption
  • Surplus = unvested funds available for token holder cash outs
  • No custom contracts needed

Pattern 2: NFT-Gated Treasury

Use case: Sell NFTs, allow holders to redeem against treasury surplus

Solution: Use nana-721-hook-v5 with native cash outs

Configuration

  1. Deploy project with JB721TiersHookProjectDeployer
  2. Configure 721 hook as data hook for pay AND cash out
  3. Set cashOutTaxRate: 0 for full redemption value
JBRulesetMetadata({
    cashOutTaxRate: 0,              // Full redemption
    useDataHookForPay: true,        // 721 hook mints NFTs
    useDataHookForCashOut: true,    // 721 hook handles burns
    dataHook: address(0),           // Set by deployer
    // ...
});

How Cash Outs Work

  1. User calls cashOutTokensOf() on terminal
  2. 721 hook intercepts, calculates: (NFT price / total prices) × surplus
  3. NFT is burned, ETH sent to user

No custom cash out hook needed - the 721 hook handles everything.


Pattern 3: Governance-Minimal Treasury

Use case: Immutable treasury with no admin controls

Solution: Transfer ownership to burn address after setup

Configuration

// 1. Deploy project with restrictive metadata
JBRulesetMetadata({
    allowOwnerMinting: false,
    allowTerminalMigration: false,
    allowSetTerminals: false,
    allowSetController: false,
    allowAddAccountingContext: false,
    allowAddPriceFeed: false,
    // ...
});

// 2. After deployment, burn ownership
PROJECTS.transferFrom(deployer, 0x000000000000000000000000000000000000dEaD, projectId);

What This Achieves

  • No one can change rulesets
  • No one can add/remove terminals
  • No one can mint tokens arbitrarily
  • Payouts/cash outs work as configured forever

Pattern 4: Split Recipients Without Custom Hooks

Use case: Distribute payouts to multiple addresses

Solution: Use native splits with direct beneficiaries

Configuration

JBSplit[] memory splits = new JBSplit[](3);

splits[0] = JBSplit({
    percent: 500_000_000,           // 50%
    beneficiary: payable(team1),
    projectId: 0,
    hook: IJBSplitHook(address(0)), // No hook needed!
    // ...
});

splits[1] = JBSplit({
    percent: 300_000_000,           // 30%
    beneficiary: payable(team2),
    // ...
});

splits[2] = JBSplit({
    percent: 200_000_000,           // 20%
    beneficiary: payable(treasury),
    // ...
});

Only use split hooks when you need custom logic (e.g., swapping tokens, adding to LP).


Pattern 5: NFT + Vesting Combined

Use case: Sell NFTs with funds vesting to team over time, holders can exit by burning

Solution: Combine patterns 1 + 2

Architecture

┌─────────────────────────────────────────────────┐
│  JB Project with 721 Hook                       │
│                                                 │
│  • NFT tier: 100 supply, 1 ETH each            │
│  • Payout limit: 6.67 ETH/month (vesting)      │
│  • Surplus allowance: 20 ETH (treasury)        │
│  • Cash out tax: 0%                            │
│  • Owner: burn address                         │
│                                                 │
│  Treasury Flow:                                │
│  ├── Month 0: 80 ETH surplus (all unvested)   │
│  ├── Month 6: 40 ETH surplus                  │
│  └── Month 12: 0 ETH surplus (fully vested)   │
│                                                 │
│  NFT Holder: Can burn anytime for pro-rata    │
│              share of current surplus          │
└─────────────────────────────────────────────────┘

Complete Example

See the Drip x Juicebox deployment script for a full implementation:

  • 100 NFTs at 1 ETH each
  • 20 ETH immediate treasury (surplus allowance)
  • 80 ETH vests over 12 months (payout limits)
  • NFT holders can burn to exit at any time
  • Zero custom contracts

Pattern 6: Custom NFT Content via Resolver

Use case: NFT project with custom artwork, composable assets, or dynamic metadata while using 721-hook off-the-shelf

Solution: Implement IJB721TokenUriResolver for custom content, use standard 721-hook for treasury mechanics

Why This Pattern?

The 721-hook handles all the hard stuff:

  • Payment processing and tier selection
  • Token minting and supply tracking
  • Cash out weight calculations
  • Reserved token mechanics

You only need custom code for content generation (artwork, metadata, composability).

Architecture

┌─────────────────────────────────────────────────────────────┐
│  Standard 721-Hook (off-the-shelf)                          │
│  ├── Handles payments, minting, cash outs                   │
│  ├── Manages tier supply and pricing                        │
│  └── Calls tokenUriResolver.tokenUriOf() for metadata       │
│                           │                                 │
│                           ▼                                 │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Custom TokenUriResolver (your code)                │   │
│  │  ├── Implements IJB721TokenUriResolver              │   │
│  │  ├── tokenUriOf() → dynamic SVG/metadata            │   │
│  │  └── Custom behaviors (composability, decoration)   │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Interface

interface IJB721TokenUriResolver {
    /// @notice Get the token URI for a given token.
    /// @param hook The 721 hook address.
    /// @param tokenId The token ID.
    /// @return The token URI (typically base64-encoded JSON with SVG).
    function tokenUriOf(address hook, uint256 tokenId)
        external view returns (string memory);
}

Basic Resolver Implementation

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

import {IJB721TokenUriResolver} from "@bananapus/721-hook/src/interfaces/IJB721TokenUriResolver.sol";
import {IJB721TiersHook} from "@bananapus/721-hook/src/interfaces/IJB721TiersHook.sol";

contract CustomTokenUriResolver is IJB721TokenUriResolver {
    /// @notice Generate token URI with custom artwork/metadata.
    function tokenUriOf(address hook, uint256 tokenId)
        external view override returns (string memory)
    {
        // Get tier info from the hook
        IJB721TiersHook tiersHook = IJB721TiersHook(hook);
        uint256 tierId = tiersHook.tierIdOfToken(tokenId);

        // Generate your custom metadata/artwork
        string memory name = _getNameForTier(tierId);
        string memory svg = _generateSvgForToken(tokenId, tierId);

        // Return base64-encoded JSON
        return string(abi.encodePacked(
            "data:application/json;base64,",
            Base64.encode(bytes(abi.encodePacked(
                '{"name":"', name, '",',
                '"image":"data:image/svg+xml;base64,', Base64.encode(bytes(svg)), '"}'
            )))
        ));
    }

    function _getNameForTier(uint256 tierId) internal view returns (string memory) {
        // Your tier naming logic
    }

    function _generateSvgForToken(uint256 tokenId, uint256 tierId) internal view returns (string memory) {
        // Your SVG generation logic
    }
}

Advanced: Composable NFTs (Banny Pattern)

For composable NFTs where items can be attached to base tokens:

contract ComposableTokenUriResolver is IJB721TokenUriResolver {
    // Track which items are attached to which base tokens
    mapping(address hook => mapping(uint256 baseTokenId => uint256[])) public attachedItems;

    // Prevent changes for a duration (e.g., 7 days)
    mapping(address hook => mapping(uint256 tokenId => uint256)) public lockedUntil;

    /// @notice Attach items to a base token.
    function decorateWith(
        address hook,
        uint256 baseTokenId,
        uint256[] calldata itemIds
    ) external {
        // Verify caller owns both base token and items
        require(IJB721TiersHook(hook).ownerOf(baseTokenId) == msg.sender);
        require(lockedUntil[hook][baseTokenId] < block.timestamp, "LOCKED");

        for (uint256 i; i < itemIds.length; i++) {
            require(IJB721TiersHook(hook).ownerOf(itemIds[i]) == msg.sender);
            // Transfer item to this contract (escrow while attached)
            IJB721TiersHook(hook).transferFrom(msg.sender, address(this), itemIds[i]);
        }

        attachedItems[hook][baseTokenId] = itemIds;
    }

    /// @notice Lock outfit changes for 7 days.
    function lockChangesFor(address hook, uint256 baseTokenId) external {
        require(IJB721TiersHook(hook).ownerOf(baseTokenId) == msg.sender);
        lockedUntil[hook][baseTokenId] = block.timestamp + 7 days;
    }

    /// @notice Generate composite SVG from base + attached items.
    function tokenUriOf(address hook, uint256 tokenId) external view override returns (string memory) {
        uint256[] memory items = attachedItems[hook][tokenId];

        // Generate layered SVG combining base + all attached items
        string memory svg = _generateCompositeSvg(hook, tokenId, items);

        return _encodeAsDataUri(svg);
    }
}

Deployment Integration

// 1. Deploy your custom resolver
CustomTokenUriResolver resolver = new CustomTokenUriResolver();

// 2. Configure 721 hook with resolver
REVDeploy721TiersHookConfig memory hookConfig = REVDeploy721TiersHookConfig({
    baseline721HookConfiguration: JBDeploy721TiersHookConfig({
        // ... tier configs
        tokenUriResolver: IJB721TokenUriResolver(address(resolver)),
        // ...
    }),
    // ...
});

// 3. Deploy project/revnet with hook config
deployer.deployWith721sFor(projectId, hookConfig, ...);

When to Use This Pattern

RequirementUse Resolver?
Static tier images (IPFS)No - use encodedIPFSUri in tier config
Dynamic/generative artYes
Composable/layered NFTsYes
On-chain SVG storageYes
Token-specific metadataYes
Standard ERC-721 metadataNo - use default

Reference Implementation

banny-retail-v5: https://github.com/mejango/banny-retail-v5

  • Banny721TokenUriResolver.sol - Composable SVG NFTs with outfit decoration
  • Deploy.s.sol - Deployment with custom resolver
  • Drop1.s.sol - Adding tiers with custom categories

Key features demonstrated:

  • On-chain SVG storage with hash verification
  • Composable outfits (attach items to base Banny)
  • Outfit locking (7-day freeze)
  • Category-based slot system
  • Multi-chain deployment via Revnet

Pattern 7: Prediction Games with Dynamic Cash Out Weights

Use case: Games where outcomes determine payout distribution (prediction markets, fantasy sports, competitions)

Solution: Extend 721-hook with custom delegate, use on-chain governance for outcome resolution

Why This Pattern Requires Extending 721-Hook

Unlike Pattern 6 (resolver-only), prediction games need to change core treasury mechanics:

RequirementWhy Resolver Isn't Enough
Dynamic cash out weightsCash out calculation is in the hook, not resolver
First-owner trackingRewards original minters, not current holders
Phase enforcementDifferent rules per game phase
Governance integrationScorecard ratification triggers weight changes

Architecture

┌─────────────────────────────────────────────────────────────┐
│  Game Lifecycle (via Juicebox Rulesets)                     │
│                                                             │
│  COUNTDOWN → MINT → REFUND → SCORING → COMPLETE             │
│      │         │       │        │          │                │
│      │    Players   Early    Holders    Winners             │
│      │    mint      exit     vote on    cash out            │
│      │    NFTs      OK       scorecard  winnings            │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│  Custom Delegate (extends JB721Hook)                        │
│  ├── Tracks first owners (for fair reward distribution)     │
│  ├── Phase-aware cash out logic                             │
│  ├── Dynamic tier weights (set by governor)                 │
│  └── Enforces phase restrictions                            │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│  Governor Contract                                          │
│  ├── NFT holders propose scorecards                         │
│  ├── Tier-weighted voting (own 25% of tier = 25% of votes)  │
│  ├── 50% quorum required for ratification                   │
│  └── Ratification sets tier cash out weights                │
└─────────────────────────────────────────────────────────────┘

Game Phases

enum DefifaGamePhase {
    COUNTDOWN,           // Game announced, no minting yet
    MINT,                // Players can mint NFTs (pick teams)
    REFUND,              // Early exit window (get mint cost back)
    SCORING,             // Game over, holders vote on scorecard
    COMPLETE,            // Scorecard ratified, winners cash out
    NO_CONTEST_INEVITABLE, // Not enough participation
    NO_CONTEST           // Game cancelled, full refunds
}

Dynamic Cash Out Weights

Standard 721-hook: cashOutWeight = tierPrice (fixed)

Defifa pattern: cashOutWeight = scorecardWeight[tierId] (dynamic)

// Total weight is 1e18 (100%), distributed among tiers by scorecard
uint256 constant TOTAL_CASH_OUT_WEIGHT = 1e18;

struct DefifaTierCashOutWeight {
    uint256 id;           // Tier ID
    uint256 cashOutWeight; // Share of total (e.g., 0.5e18 = 50%)
}

// Example: 4-team tournament, Team A wins
// Team A: 1e18 (100% of pot)
// Team B: 0
// Team C: 0
// Team D: 0

// Example: Fantasy league with scoring
// Team A (1st): 0.5e18 (50%)
// Team B (2nd): 0.3e18 (30%)
// Team C (3rd): 0.15e18 (15%)
// Team D (4th): 0.05e18 (5%)

First Owner Tracking

Critical for fair games - rewards go to original minters, not secondary buyers:

// Track who first minted each token
mapping(uint256 tokenId => address) public firstOwnerOf;

// In _processPayment():
firstOwnerOf[tokenId] = beneficiary;

// In cash out calculation:
// Only firstOwnerOf[tokenId] receives the full reward
// Current owner can transfer, but original minter gets payout

Governor Voting

// Attestation power = share of tier tokens you own
// If you own 25 of 100 tokens in Tier 1, you have 25% of Tier 1's voting power

function attestToScorecardFrom(
    address attester,
    DefifaScorecard calldata scorecard
) external {
    // Verify attester hasn't already voted
    // Add attester's voting power to scorecard
    // If quorum reached, scorecard can be ratified
}

function ratifyScorecard(DefifaScorecard calldata scorecard) external {
    // Verify scorecard has 50% attestation across all minted tiers
    // Set tier cash out weights on delegate
    // Game moves to COMPLETE phase
}

When to Use This Pattern

Use CaseFits Pattern?
Sports predictionsYes - teams = tiers, outcomes = weights
Fantasy leaguesYes - players draft teams, scoring determines payouts
Tournament bracketsYes - bracket picks = tiers
Election predictionsYes - candidates = tiers
Price predictionsYes - price ranges = tiers
Art competitionsYes - entries = tiers, votes = weights
Standard NFT collectionNo - use Pattern 6 instead
Fixed-price redemptionsNo - use native 721-hook

Key Implementation Considerations

  1. Phase transitions via rulesets: Use ruleset durations to enforce timing
  2. Refund window: Allow early exit before outcomes are known
  3. Quorum design: Too high = deadlock, too low = manipulation
  4. First-owner vs current-owner: Decide who receives rewards
  5. No-contest handling: What if not enough participation?

Reference Implementation

defifa-collection-deployer-v5: https://github.com/BallKidz/defifa-collection-deployer-v5

Key contracts:

  • DefifaDelegate.sol - Extends JB721Hook with phase logic and dynamic weights
  • DefifaGovernor.sol - On-chain voting for scorecard ratification
  • DefifaDeployer.sol - Factory for launching games
  • DefifaTokenUriResolver.sol - Dynamic metadata showing pot share

Features demonstrated:

  • Phase-based game lifecycle
  • Tier-weighted governance voting
  • Dynamic cash out weight redistribution
  • First-owner tracking for fair rewards
  • No-contest handling for failed games

Pattern 8: Custom ERC20 Project Tokens

Use case: Projects requiring custom tokenomics beyond standard mint/burn mechanics

Solution: Implement IJBToken interface and use setTokenFor() instead of deployERC20For()

Why This Pattern?

Default Juicebox tokens (credits or JBERC20) work for most projects, but some use cases require custom token logic:

Default Token LimitationCustom Token Solution
No transfer feesImplement tax-on-transfer
Fixed supply mechanicsUse rebasing/elastic supply
No governance featuresExtend ERC20Votes
Immutable name/symbolAdd setName/setSymbol functions
Uniform holder treatmentAdd allowlists/denylists

Architecture

┌─────────────────────────────────────────────────────────────┐
│  Juicebox Protocol (unchanged)                              │
│  ├── JBController calls mint/burn on token                  │
│  ├── JBTokens tracks credits + token supply                 │
│  └── JBMultiTerminal handles payments/cash outs             │
│                           │                                 │
│                           ▼                                 │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Custom ERC20 Token (your code)                     │   │
│  │  ├── Implements IJBToken interface                  │   │
│  │  ├── Authorizes JBController for mint/burn          │   │
│  │  ├── Uses 18 decimals (REQUIRED)                    │   │
│  │  └── Custom logic: taxes, rebasing, governance, etc │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Interface Requirements

interface IJBToken is IERC20 {
    /// @notice Must return true for the target project ID
    function canBeAddedTo(uint256 projectId) external view returns (bool);

    /// @notice Called by JBController when payments are received
    function mint(address holder, uint256 amount) external;

    /// @notice Called by JBController when tokens are cashed out
    function burn(address holder, uint256 amount) external;
}

Example: Transfer Tax Token

Revenue-generating token that collects fees on every transfer:

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

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract TaxedProjectToken is ERC20 {
    uint256 public constant TAX_BPS = 100; // 1%
    address public immutable controller;
    address public immutable treasury;
    uint256 public immutable projectId;

    constructor(
        string memory name,
        string memory symbol,
        address _controller,
        uint256 _projectId,
        address _treasury
    ) ERC20(name, symbol) {
        controller = _controller;
        projectId = _projectId;
        treasury = _treasury;
    }

    function decimals() public pure override returns (uint8) { return 18; }

    function canBeAddedTo(uint256 _projectId) external view returns (bool) {
        return _projectId == projectId;
    }

    function mint(address to, uint256 amount) external {
        require(msg.sender == controller, "UNAUTHORIZED");
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) external {
        require(msg.sender == controller, "UNAUTHORIZED");
        _burn(from, amount);
    }

    function _update(address from, address to, uint256 amount) internal override {
        // No tax on mints, burns, or controller operations
        if (from == address(0) || to == address(0) || msg.sender == controller) {
            super._update(from, to, amount);
            return;
        }

        // Apply transfer tax
        uint256 tax = (amount * TAX_BPS) / 10000;
        super._update(from, treasury, tax);
        super._update(from, to, amount - tax);
    }
}

Deployment:

// 1. Deploy custom token (before or after project creation)
TaxedProjectToken token = new TaxedProjectToken(
    "Taxed Token",
    "TAX",
    address(CONTROLLER),
    projectId,
    treasuryAddress
);

// 2. Set as project token (requires SET_TOKEN permission)
CONTROLLER.setTokenFor(projectId, IJBToken(address(token)));

Example: Governance Token with Voting

Enable on-chain governance while maintaining treasury mechanics:

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

import {ERC20Votes, ERC20} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";

contract GovernanceProjectToken is ERC20Votes {
    address public immutable controller;
    uint256 public immutable projectId;

    constructor(
        string memory name,
        string memory symbol,
        address _controller,
        uint256 _projectId
    ) ERC20(name, symbol) EIP712(name, "1") {
        controller = _controller;
        projectId = _projectId;
    }

    function decimals() public pure override returns (uint8) { return 18; }

    function canBeAddedTo(uint256 _projectId) external view returns (bool) {
        return _projectId == projectId;
    }

    function mint(address to, uint256 amount) external {
        require(msg.sender == controller, "UNAUTHORIZED");
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) external {
        require(msg.sender == controller, "UNAUTHORIZED");
        _burn(from, amount);
    }

    // Inherits: delegate(), delegateBySig(), getVotes(), getPastVotes(), etc.
}

Usage with Governor:

// Deploy governor that uses token's voting power
GovernorBravo governor = new GovernorBravo(
    GovernanceProjectToken(token),
    timelockAddress,
    votingDelay,
    votingPeriod,
    proposalThreshold
);

// Token holders delegate and vote
token.delegate(voterAddress);  // Self-delegate to activate voting
governor.propose(...);
governor.castVote(proposalId, support);

Example: Editable Name/Symbol Token

Allow project owners to rebrand without deploying a new token:

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

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IJBProjects} from "@bananapus/core/src/interfaces/IJBProjects.sol";

contract EditableProjectToken is ERC20 {
    IJBProjects public immutable PROJECTS;
    address public immutable controller;
    uint256 public immutable projectId;

    string private _tokenName;
    string private _tokenSymbol;

    event NameUpdated(string oldName, string newName);
    event SymbolUpdated(string oldSymbol, string newSymbol);

    constructor(
        string memory initialName,
        string memory initialSymbol,
        address _controller,
        uint256 _projectId,
        IJBProjects projects
    ) ERC20(initialName, initialSymbol) {
        _tokenName = initialName;
        _tokenSymbol = initialSymbol;
        controller = _controller;
        projectId = _projectId;
        PROJECTS = projects;
    }

    modifier onlyProjectOwner() {
        require(msg.sender == PROJECTS.ownerOf(projectId), "NOT_OWNER");
        _;
    }

    function name() public view override returns (string memory) {
        return _tokenName;
    }

    function symbol() public view override returns (string memory) {
        return _tokenSymbol;
    }

    function decimals() public pure override returns (uint8) { return 18; }

    function canBeAddedTo(uint256 _projectId) external view returns (bool) {
        return _projectId == projectId;
    }

    function mint(address to, uint256 amount) external {
        require(msg.sender == controller, "UNAUTHORIZED");
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) external {
        require(msg.sender == controller, "UNAUTHORIZED");
        _burn(from, amount);
    }

    /// @notice Update token name. Only callable by project owner.
    function setName(string calldata newName) external onlyProjectOwner {
        emit NameUpdated(_tokenName, newName);
        _tokenName = newName;
    }

    /// @notice Update token symbol. Only callable by project owner.
    function setSymbol(string calldata newSymbol) external onlyProjectOwner {
        emit SymbolUpdated(_tokenSymbol, newSymbol);
        _tokenSymbol = newSymbol;
    }
}

Use cases:

  • Project rebranding without migrating liquidity
  • Seasonal/event-based name changes
  • Fixing typos discovered post-launch
  • Community-voted name updates

Tradeoff: Some DEXs and aggregators cache token metadata. Changes may not propagate immediately to all interfaces.

Example: Vesting Token

Enforce time-based vesting at the token level - useful for team allocations, investor locks, or contributor rewards where tokens should vest over time:

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

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IJBProjects} from "@bananapus/core/src/interfaces/IJBProjects.sol";

/// @notice Project token with per-address vesting schedules.
/// @dev Vesting restricts transfers, not minting/burning. Combined with treasury
/// vesting (payout limits), this creates layered protection.
contract VestingProjectToken is ERC20 {
    struct VestingSchedule {
        uint256 totalAmount;     // Total tokens in this schedule
        uint256 released;        // Already released/transferred
        uint40 start;            // Vesting start timestamp
        uint40 cliff;            // Cliff end timestamp (0 = no cliff)
        uint40 duration;         // Total vesting duration from start
    }

    IJBProjects public immutable PROJECTS;
    address public immutable controller;
    uint256 public immutable projectId;

    mapping(address => VestingSchedule) public vestingOf;

    event VestingScheduleSet(
        address indexed beneficiary,
        uint256 totalAmount,
        uint40 start,
        uint40 cliff,
        uint40 duration
    );

    error CliffNotReached();
    error InsufficientVestedBalance();
    error VestingAlreadyExists();

    constructor(
        string memory name,
        string memory symbol,
        address _controller,
        uint256 _projectId,
        IJBProjects projects
    ) ERC20(name, symbol) {
        controller = _controller;
        projectId = _projectId;
        PROJECTS = projects;
    }

    modifier onlyProjectOwner() {
        require(msg.sender == PROJECTS.ownerOf(projectId), "NOT_OWNER");
        _;
    }

    function decimals() public pure override returns (uint8) { return 18; }

    function canBeAddedTo(uint256 _projectId) external view returns (bool) {
        return _projectId == projectId;
    }

    function mint(address to, uint256 amount) external {
        require(msg.sender == controller, "UNAUTHORIZED");
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) external {
        require(msg.sender == controller, "UNAUTHORIZED");
        _burn(from, amount);
    }

    /// @notice Set a vesting schedule for an address.
    /// @dev Call this AFTER minting tokens to the beneficiary.
    /// @param beneficiary Address whose tokens will vest.
    /// @param totalAmount Total tokens subject to vesting (should match balance).
    /// @param start When vesting begins (can be in the past).
    /// @param cliffDuration Seconds until cliff ends (0 for no cliff).
    /// @param vestingDuration Total seconds for full vesting from start.
    function setVestingSchedule(
        address beneficiary,
        uint256 totalAmount,
        uint40 start,
        uint40 cliffDuration,
        uint40 vestingDuration
    ) external onlyProjectOwner {
        if (vestingOf[beneficiary].totalAmount > 0) revert VestingAlreadyExists();

        vestingOf[beneficiary] = VestingSchedule({
            totalAmount: totalAmount,
            released: 0,
            start: start,
            cliff: start + cliffDuration,
            duration: vestingDuration
        });

        emit VestingScheduleSet(
            beneficiary,
            totalAmount,
            start,
            start + cliffDuration,
            vestingDuration
        );
    }

    /// @notice Calculate how many tokens have vested for an address.
    function vestedAmountOf(address account) public view returns (uint256) {
        VestingSchedule memory schedule = vestingOf[account];

        // No vesting schedule = all tokens are vested (freely transferable)
        if (schedule.totalAmount == 0) return balanceOf(account);

        // Before cliff = nothing vested
        if (block.timestamp < schedule.cliff) return 0;

        // After full duration = everything vested
        if (block.timestamp >= schedule.start + schedule.duration) {
            return schedule.totalAmount;
        }

        // Linear vesting between cliff and end
        uint256 elapsed = block.timestamp - schedule.start;
        return (schedule.totalAmount * elapsed) / schedule.duration;
    }

    /// @notice Calculate transferable (vested and unreleased) tokens.
    function transferableOf(address account) public view returns (uint256) {
        VestingSchedule memory schedule = vestingOf[account];

        // No vesting = full balance transferable
        if (schedule.totalAmount == 0) return balanceOf(account);

        uint256 vested = vestedAmountOf(account);
        uint256 locked = schedule.totalAmount > vested
            ? schedule.totalAmount - vested
            : 0;

        uint256 balance = balanceOf(account);
        return balance > locked ? balance - locked : 0;
    }

    function _update(address from, address to, uint256 amount) internal override {
        // Skip vesting checks for mints, burns, and controller operations
        if (from == address(0) || to == address(0) || msg.sender == controller) {
            super._update(from, to, amount);
            return;
        }

        VestingSchedule storage schedule = vestingOf[from];

        // No vesting schedule = normal transfer
        if (schedule.totalAmount == 0) {
            super._update(from, to, amount);
            return;
        }

        // Before cliff = no transfers allowed
        if (block.timestamp < schedule.cliff) revert CliffNotReached();

        // Check transferable amount
        uint256 transferable = transferableOf(from);
        if (amount > transferable) revert InsufficientVestedBalance();

        // Track released amount for accounting
        schedule.released += amount;

        super._update(from, to, amount);
    }
}

Key Design Decisions:

  • Vesting is per-address, set by project owner after minting
  • No vesting schedule = freely transferable (normal ERC20 behavior)
  • Cliff period: no transfers until cliff is reached
  • Linear vesting after cliff
  • Controller operations (mint/burn for payments/cash outs) bypass vesting

Usage Pattern:

// 1. Deploy and set as project token
VestingProjectToken token = new VestingProjectToken(...);
CONTROLLER.setTokenFor(projectId, IJBToken(address(token)));

// 2. Team member receives tokens via payment or reserved distribution
// (tokens minted by controller - no vesting restriction on mint)

// 3. Project owner sets vesting schedule
token.setVestingSchedule(
    teamMember,
    1_000_000e18,           // 1M tokens vest
    uint40(block.timestamp), // Start now
    365 days,               // 1 year cliff
    4 * 365 days            // 4 year total vest
);

// Result:
// - Year 0-1: 0 tokens transferable (cliff)
// - Year 1: 250k tokens transferable (25% vested)
// - Year 2: 500k tokens transferable (50% vested)
// - Year 4+: All tokens transferable

Combining with Treasury Vesting:

LayerProtectsMechanism
Token vestingHolder's tokensTransfer restrictions
Treasury vestingTreasury fundsPayout limits

For comprehensive protection, use both:

  1. Treasury vesting (Pattern 1): Prevents premature fund withdrawal
  2. Token vesting: Prevents premature token sales by recipients

When to Use Token Vesting vs Treasury Vesting:

ScenarioUse Token VestingUse Treasury Vesting
Team allocations with cliffOptional
Investor lock-upsOptional
Recurring payroll/grants
Milestone-based releases
All-holder protection
Per-person schedules

Tradeoffs:

  • Adds complexity vs standard token
  • Vesting schedules are permanent once set
  • Does not prevent cash outs (controller operations are exempt)
  • Must set schedule after minting, not before

Example: Concentration Limited Token

Prevent any single holder from accumulating too large a share:

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

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract ConcentrationLimitedToken is ERC20 {
    uint256 public maxHolderBps = 200;  // 2% max per holder
    address public immutable controller;
    uint256 public immutable projectId;
    mapping(address => bool) public isExempt;

    constructor(
        string memory name,
        string memory symbol,
        address _controller,
        uint256 _projectId
    ) ERC20(name, symbol) {
        controller = _controller;
        projectId = _projectId;
        isExempt[_controller] = true;  // Controller always exempt
    }

    function decimals() public pure override returns (uint8) { return 18; }

    function canBeAddedTo(uint256 _projectId) external view returns (bool) {
        return _projectId == projectId;
    }

    function mint(address to, uint256 amount) external {
        require(msg.sender == controller, "UNAUTHORIZED");
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) external {
        require(msg.sender == controller, "UNAUTHORIZED");
        _burn(from, amount);
    }

    function _update(address from, address to, uint256 amount) internal override {
        // Skip checks for mints, burns, and exempt addresses
        if (from == address(0) || to == address(0) || isExempt[to]) {
            super._update(from, to, amount);
            return;
        }

        // Check concentration limit
        uint256 maxBalance = (totalSupply() * maxHolderBps) / 10000;
        require(balanceOf(to) + amount <= maxBalance, "EXCEEDS_MAX_HOLDING");

        super._update(from, to, amount);
    }

    function setExempt(address account, bool exempt) external {
        require(msg.sender == controller, "UNAUTHORIZED");
        isExempt[account] = exempt;
    }
}

Use cases:

  • Encourage broad token distribution
  • Prevent governance centralization
  • Reduce market manipulation risk

Tradeoff: Liquidity pools and the controller must be marked exempt. New holders during early high-supply periods may hit limits before supply grows.

When to Use This Pattern

Use CaseCustom Token?Alternative
Simple fundraisingNoUse credits or JBERC20
Transfer fees/taxesYes-
Rebasing mechanicsYes-
Governance votingYesExternal governance
Pre-existing tokenYesMigrate to new project
Per-holder token vestingYes-
Treasury fund vestingNoUse payout limits (Pattern 1)
Compliance restrictionsYes-
Editable name/symbolYesRedeploy token
Concentration limitsYes-

Key Tradeoffs

AspectStandard TokenCustom Token
ComplexityLowHigh
Audit burdenAudited by JBYour responsibility
Gas costsOptimizedVariable
IntegrationSeamlessRequires testing
FlexibilityLimitedFull control
RiskLowHigher (custom code)

Critical Constraints

  1. 18 decimals mandatory - All Juicebox math assumes 18 decimals
  2. Controller must be authorized - Mint/burn must work without approval
  3. One token per project - Can't swap tokens after setting
  4. totalSupply() accuracy - Cash outs depend on correct supply
  5. No fee-on-transfer during mint - Minted amount must equal requested

Deployment Checklist

  • Token implements canBeAddedTo(projectId) returning true
  • Token uses exactly 18 decimals
  • Controller address authorized for mint()
  • Controller address authorized for burn() (without approval)
  • Custom logic (taxes, limits) exempts controller operations
  • Tested with Juicebox payment flow
  • Tested with Juicebox cash out flow
  • Tested credit claiming after token is set
  • Security audit completed (recommended)

Decision Tree: When to Write Custom Code

Need custom payment logic?
├── Can 721 hook handle it? → Use 721 hook
├── Can buyback hook handle it? → Use buyback hook
└── Neither works? → Write custom pay hook

Need custom redemption logic?
├── Does 721 hook's burn-to-redeem work? → Use 721 hook
├── Is redemption just against surplus? → Use native cash out
└── Need external data source? → Write custom cash out hook

Need custom payout routing?
├── Can direct beneficiary addresses work? → Use native splits
├── Need token swapping? → Write split hook
├── Need LP deposits? → Write split hook
└── Just multi-recipient? → Use native splits

Need vesting/time-locks?
├── Treasury funds over time? → Use cycling rulesets + payout limits
├── Milestone-based releases? → Queue multiple rulesets
├── Per-holder token locks? → Custom ERC20 with vesting schedules
├── Investor/team cliffs? → Custom ERC20 with vesting schedules
└── Complex conditions? → Consider Revnet or custom

Need time-limited campaign?
├── Fundraise then close forever? → Two rulesets (active + paused)
├── Want immutability? → Burn ownership after deploy
└── May run another campaign? → Keep ownership

Need custom NFT content?
├── Static images per tier? → Use encodedIPFSUri in tier config
├── Dynamic/generative art? → Write IJB721TokenUriResolver
├── Composable/layered NFTs? → Write IJB721TokenUriResolver
└── On-chain SVG? → Write IJB721TokenUriResolver

Need prediction/game mechanics?
├── Fixed redemption values? → Use standard 721-hook
├── Outcome-based payouts? → Extend 721-hook (Defifa pattern)
├── On-chain outcome voting? → Add Governor contract
└── First-owner rewards? → Track in custom delegate

Need custom token mechanics?
├── Standard ERC20 sufficient? → Use deployERC20For()
├── Transfer taxes/fees? → Custom ERC20 with _update override
├── Governance voting? → Custom ERC20Votes
├── Rebasing/elastic supply? → Custom ERC20 (careful with totalSupply)
├── Editable name/symbol? → Custom ERC20 with setName/setSymbol
├── Concentration limits? → Custom ERC20 with max holder checks
├── Per-holder vesting/cliffs? → Custom ERC20 with vesting schedules
└── Pre-existing token? → Wrap with IJBToken interface

Need extended pay functionality on locked project/revnet?
├── Dynamic splits at pay time? → Terminal wrapper
├── Atomic pay + distribute? → Terminal wrapper
├── Token interception/staking? → Terminal wrapper (beneficiary-to-self)
├── Multi-hop payments? → Terminal wrapper
├── Block certain payments? → CAN'T DO (permissionless is a feature)
└── Standard payments work fine? → Use MultiTerminal directly

Anti-Patterns to Avoid

1. Wrapping the 721 Hook

Wrong: Creating a data hook that wraps/delegates to 721 hook Right: Use 721 hook directly, achieve vesting via ruleset configuration

2. Custom Vesting Contracts for Treasury Funds

Wrong: Writing a VestingSplitHook to hold and release funds Right: Use payout limits (reset each cycle) for recurring distributions

Exception: Per-holder token vesting (team cliffs, investor locks) IS appropriate as a custom ERC20. See Pattern 8 - Vesting Token.

3. Multiple Queued Rulesets for Simple Cycles

Wrong: Queueing 12 rulesets for 12-month vesting Right: One ruleset with 30-day duration that cycles automatically

4. Split Hooks for Direct Transfers

Wrong: Split hook that just forwards to an address Right: Set the address as direct split beneficiary

5. Custom Cash Out Hooks for Standard Redemptions

Wrong: Writing hook to calculate pro-rata redemption Right: Set cashOutTaxRate: 0 and let terminal handle it


Pattern 9: Time-Limited Campaign

Use case: Fundraise for a specific period, then close payments permanently

Solution: Deploy with two queued rulesets - active campaign, then paused

Why This Pattern?

Many projects don't need ongoing payments forever. A time-limited campaign is cleaner:

  • Crowdfunds with a deadline
  • NFT mints with a defined window
  • Grant rounds with cutoff dates
  • "Set it and forget it" treasuries

Configuration

// Ruleset 1: Active Campaign
JBRulesetConfig({
    duration: 30 days,              // Campaign length
    weight: 1e18,                   // Token issuance rate
    decayPercent: 0,
    approvalHook: IJBRulesetApprovalHook(address(0)),
    metadata: JBRulesetMetadata({
        // ... normal settings
        pausePay: false,            // Payments ENABLED
    }),
    // ... splits, fund access, etc.
});

// Ruleset 2: Campaign End (queued immediately)
JBRulesetConfig({
    duration: 0,                    // Lasts forever
    weight: 0,                      // No more tokens issued
    decayPercent: 0,
    approvalHook: IJBRulesetApprovalHook(address(0)),
    metadata: JBRulesetMetadata({
        pausePay: true,             // Payments DISABLED
        // Keep cash outs enabled if desired
    }),
    // No payout limits needed - campaign is over
});

Ownership Options

After deployment, the project owner decides:

Option A: Keep Ownership

  • Can queue new rulesets later (run another campaign)
  • Can adjust splits or fund access
  • Maintains flexibility

Option B: Lock Forever

// Transfer ownership to burn address
PROJECTS.transferFrom(
    deployer,
    0x000000000000000000000000000000000000dEaD,
    projectId
);
  • No one can ever change the rules
  • Fully trustless and immutable
  • Cannot be undone

Complete Flow

Deploy with 2 rulesets
         │
         ▼
┌─────────────────────────────────────┐
│  Ruleset 1: Active Campaign         │
│  ├── Duration: 30 days              │
│  ├── Payments: enabled              │
│  └── Tokens issued to payers        │
└─────────────────────────────────────┘
         │
         │ (30 days pass automatically)
         ▼
┌─────────────────────────────────────┐
│  Ruleset 2: Campaign Over           │
│  ├── Duration: forever              │
│  ├── Payments: paused               │
│  └── Cash outs still work           │
└─────────────────────────────────────┘
         │
         ▼
   Owner decides:
   ├── Keep ownership → can modify later
   └── Burn ownership → locked forever

When to Use

ScenarioGood Fit?
One-time crowdfund
NFT mint with deadline
Grant distribution round
Ongoing membership/subscription❌ Use cycling rulesets
Revnet with autonomous issuance❌ Use Revnet deployer

Key Benefits

  1. Simple - Just two rulesets, no custom contracts
  2. Clear expectations - Everyone knows when it ends
  3. Optional immutability - Lock it or keep flexibility
  4. No ongoing management - Set and forget

Pattern 10: Terminal Wrapper (Pay Wrapper)

Use case: Extend payment functionality without modifying rulesets - especially for revnets where hooks can't be edited

Solution: Create an IJBTerminal that wraps JBMultiTerminal, like Swap Terminal does

Why This Pattern?

Revnets and locked projects can't modify ruleset data hooks. But you can still add functionality by wrapping the terminal:

NeedHow Wrapper Solves It
Dynamic splits at pay timeParse from metadata, configure before forwarding
Pay + distribute atomicallyBundle operations in one tx
Token interceptionSet beneficiary to wrapper, then stake/forward
Referral trackingParse referrer from metadata, record on-chain
Multi-hop paymentsReceive tokens, swap, pay another project

Core Architecture

contract PayWithSplitsTerminal is IJBTerminal {
    IJBMultiTerminal public immutable MULTI_TERMINAL;
    IJBController public immutable 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. Forward to underlying terminal
        beneficiaryTokenCount = MULTI_TERMINAL.pay{value: msg.value}(
            projectId, token, amount, beneficiary,
            minReturnedTokens, memo, innerMetadata
        );

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

        return beneficiaryTokenCount;
    }
}

Beneficiary-to-Self Pattern

Intercept tokens by making the wrapper the beneficiary:

function payAndStake(uint256 projectId, ..., bytes calldata metadata) external payable {
    (address finalDestination, bytes memory stakingParams) = abi.decode(metadata, (address, bytes));

    // Wrapper receives tokens
    uint256 tokenCount = MULTI_TERMINAL.pay{value: msg.value}(
        projectId, token, amount,
        address(this),  // <-- Beneficiary is wrapper
        minReturnedTokens, "", ""
    );

    // Do something with them
    _stakeTokens(projectToken, tokenCount, finalDestination, stakingParams);
}

Critical Mental Model

┌─────────────────────────────────────────────────────────────────┐
│                    WRAPPER IS ADDITIVE                          │
├─────────────────────────────────────────────────────────────────┤
│   Client A ──► PayWrapper ──► JBMultiTerminal                   │
│                (gets special features)                          │
│                                                                 │
│   Client B ─────────────────► 
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

```solidity interface IJBToken is IERC20 { /// @notice Must return true for the target project ID function canBeAddedTo(uint256 projectId) external view returns (bool); /// @notice Called by JBController when payments are received function mint(address holder, uint256 amount) external; /// @notice Called by JBController when tokens are cashed out function burn(address holder, uint256 amount) external; } ```

Configuration

```solidity JBRulesetConfig({ duration: 30 days, // Monthly cycles // ... other config fundAccessLimitGroups: [ JBFundAccessLimitGroup({ terminal: address(TERMINAL), token: JBConstants.NATIVE_TOKEN, payoutLimits: [ JBCurrencyAmount({ amount: 6.67 ether, // Monthly vesting amount currency: nativeCurrency }) ], surplusAllowances: [ JBCurrencyAmount({ amount: 20 ether, // One-time treasury (doesn't reset) currency: nativeCurrency }) ] }) ] }); ```

FAQ

How do I install jb-patterns?

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