# Jb Patterns

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

- Skill: `modbender/jb-patterns` (Agent Skill)
- Install (CLI): `npx skillmds@latest add modbender/jb-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/modbender/jb-patterns/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: modbender (https://skillmd.com/u/modbender)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/modbender/jb-patterns

---


# 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

| Mechanism | Behavior | Use For |
|-----------|----------|---------|
| **Payout Limit** | Resets each cycle | Recurring distributions (vesting) |
| **Surplus Allowance** | One-time per ruleset | Discretionary treasury access |
| **Cycle Duration** | Determines distribution frequency | Monthly = 30 days |

### 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
                })
            ]
        })
    ]
});
```

### 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

```solidity
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

```solidity
// 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

```solidity
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

```solidity
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

```solidity
// 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:

```solidity
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

```solidity
// 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

| Requirement | Use Resolver? |
|-------------|---------------|
| Static tier images (IPFS) | No - use `encodedIPFSUri` in tier config |
| Dynamic/generative art | **Yes** |
| Composable/layered NFTs | **Yes** |
| On-chain SVG storage | **Yes** |
| Token-specific metadata | **Yes** |
| Standard ERC-721 metadata | No - 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**:

| Requirement | Why Resolver Isn't Enough |
|-------------|---------------------------|
| Dynamic cash out weights | Cash out calculation is in the hook, not resolver |
| First-owner tracking | Rewards original minters, not current holders |
| Phase enforcement | Different rules per game phase |
| Governance integration | Scorecard 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

```solidity
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)

```solidity
// 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:

```solidity
// 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

```solidity
// 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 Case | Fits Pattern? |
|----------|---------------|
| Sports predictions | **Yes** - teams = tiers, outcomes = weights |
| Fantasy leagues | **Yes** - players draft teams, scoring determines payouts |
| Tournament brackets | **Yes** - bracket picks = tiers |
| Election predictions | **Yes** - candidates = tiers |
| Price predictions | **Yes** - price ranges = tiers |
| Art competitions | **Yes** - entries = tiers, votes = weights |
| Standard NFT collection | **No** - use Pattern 6 instead |
| Fixed-price redemptions | **No** - 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 Limitation | Custom Token Solution |
|--------------------------|----------------------|
| No transfer fees | Implement tax-on-transfer |
| Fixed supply mechanics | Use rebasing/elastic supply |
| No governance features | Extend ERC20Votes |
| Immutable name/symbol | Add setName/setSymbol functions |
| Uniform holder treatment | Add 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

```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;
}
```

### Example: Transfer Tax Token

Revenue-generating token that collects fees on every transfer:

```solidity
// 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**:
```solidity
// 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:

```solidity
// 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**:
```solidity
// 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:

```solidity
// 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:

```solidity
// 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**:
```solidity
// 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**:

| Layer | Protects | Mechanism |
|-------|----------|-----------|
| Token vesting | Holder's tokens | Transfer restrictions |
| Treasury vesting | Treasury funds | Payout 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**:

| Scenario | Use Token Vesting | Use Treasury Vesting |
|----------|-------------------|---------------------|
| Team allocations with cliff | ✅ | Optional |
| Investor lock-ups | ✅ | Optional |
| 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:

```solidity
// 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 Case | Custom Token? | Alternative |
|----------|---------------|-------------|
| Simple fundraising | No | Use credits or JBERC20 |
| Transfer fees/taxes | **Yes** | - |
| Rebasing mechanics | **Yes** | - |
| Governance voting | **Yes** | External governance |
| Pre-existing token | **Yes** | Migrate to new project |
| Per-holder token vesting | **Yes** | - |
| Treasury fund vesting | No | Use payout limits (Pattern 1) |
| Compliance restrictions | **Yes** | - |
| Editable name/symbol | **Yes** | Redeploy token |
| Concentration limits | **Yes** | - |

### Key Tradeoffs

| Aspect | Standard Token | Custom Token |
|--------|----------------|--------------|
| Complexity | Low | High |
| Audit burden | Audited by JB | Your responsibility |
| Gas costs | Optimized | Variable |
| Integration | Seamless | Requires testing |
| Flexibility | Limited | Full control |
| Risk | Low | Higher (custom code) |

### Critical Constraints

1. **18 decimals mandatory** - All Juicebox math assumes 18 decimals
2. **Controller must be authorized** - Mi

…(truncated)
