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
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
- Deploy project with
JB721TiersHookProjectDeployer - Configure 721 hook as data hook for pay AND cash out
- Set
cashOutTaxRate: 0for 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
- User calls
cashOutTokensOf()on terminal - 721 hook intercepts, calculates:
(NFT price / total prices) × surplus - 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
| 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 decorationDeploy.s.sol- Deployment with custom resolverDrop1.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
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 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
- Phase transitions via rulesets: Use ruleset durations to enforce timing
- Refund window: Allow early exit before outcomes are known
- Quorum design: Too high = deadlock, too low = manipulation
- First-owner vs current-owner: Decide who receives rewards
- 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 weightsDefifaGovernor.sol- On-chain voting for scorecard ratificationDefifaDeployer.sol- Factory for launching gamesDefifaTokenUriResolver.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
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:
| Layer | Protects | Mechanism |
|---|---|---|
| Token vesting | Holder's tokens | Transfer restrictions |
| Treasury vesting | Treasury funds | Payout limits |
For comprehensive protection, use both:
- Treasury vesting (Pattern 1): Prevents premature fund withdrawal
- 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:
// 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
- 18 decimals mandatory - All Juicebox math assumes 18 decimals
- Controller must be authorized - Mi
…(truncated)