算法艺术创作
algorithmic-art
通过代码和可调参数创作生成艺术与交互式视觉效果。
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 …
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Proven patterns for common use cases using native Juicebox mechanics. Always prefer configuration over custom contracts.
Before writing custom code, ask: "Can this be achieved with payout limits, surplus allowance, splits, and cycling rulesets?"
Use case: Release funds to a beneficiary over time (e.g., team vesting, milestone-based releases)
Solution: Use cycling rulesets with payout limits
| 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 |
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
})
]
})
]
});
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)
Use case: Sell NFTs, allow holders to redeem against treasury surplus
Solution: Use nana-721-hook-v5 with native cash outs
JB721TiersHookProjectDeployercashOutTaxRate: 0 for full redemption valueJBRulesetMetadata({
cashOutTaxRate: 0, // Full redemption
useDataHookForPay: true, // 721 hook mints NFTs
useDataHookForCashOut: true, // 721 hook handles burns
dataHook: address(0), // Set by deployer
// ...
});
cashOutTokensOf() on terminal(NFT price / total prices) × surplusNo custom cash out hook needed - the 721 hook handles everything.
Use case: Immutable treasury with no admin controls
Solution: Transfer ownership to burn address after setup
// 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);
Use case: Distribute payouts to multiple addresses
Solution: Use native splits with direct beneficiaries
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).
Use case: Sell NFTs with funds vesting to team over time, holders can exit by burning
Solution: Combine patterns 1 + 2
┌─────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────┘
See the Drip x Juicebox deployment script for a full implementation:
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
The 721-hook handles all the hard stuff:
You only need custom code for content generation (artwork, metadata, composability).
┌─────────────────────────────────────────────────────────────┐
│ 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 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);
}
// 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
}
}
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);
}
}
// 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, ...);
| 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 |
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 categoriesKey features demonstrated:
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
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 |
┌─────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────┘
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
}
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%)
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
// 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
}
| 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 |
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 shareFeatures demonstrated:
Use case: Projects requiring custom tokenomics beyond standard mint/burn mechanics
Solution: Implement IJBToken interface and use setTokenFor() instead of deployERC20For()
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 |
┌─────────────────────────────────────────────────────────────┐
│ 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 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;
}
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)));
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);
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:
Tradeoff: Some DEXs and aggregators cache token metadata. Changes may not propagate immediately to all interfaces.
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:
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:
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:
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:
Tradeoff: Liquidity pools and the controller must be marked exempt. New holders during early high-supply periods may hit limits before supply grows.
| 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 | - |
| 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) |
canBeAddedTo(projectId) returning trueNeed 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
Wrong: Creating a data hook that wraps/delegates to 721 hook Right: Use 721 hook directly, achieve vesting via ruleset configuration
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.
Wrong: Queueing 12 rulesets for 12-month vesting Right: One ruleset with 30-day duration that cycles automatically
Wrong: Split hook that just forwards to an address Right: Set the address as direct split beneficiary
Wrong: Writing hook to calculate pro-rata redemption
Right: Set cashOutTaxRate: 0 and let terminal handle it
Use case: Fundraise for a specific period, then close payments permanently
Solution: Deploy with two queued rulesets - active campaign, then paused
Many projects don't need ongoing payments forever. A time-limited campaign is cleaner:
// 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
});
After deployment, the project owner decides:
Option A: Keep Ownership
Option B: Lock Forever
// Transfer ownership to burn address
PROJECTS.transferFrom(
deployer,
0x000000000000000000000000000000000000dEaD,
projectId
);
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
| Scenario | Good Fit? |
|---|---|
| One-time crowdfund | ✅ |
| NFT mint with deadline | ✅ |
| Grant distribution round | ✅ |
| Ongoing membership/subscription | ❌ Use cycling rulesets |
| Revnet with autonomous issuance | ❌ Use Revnet deployer |
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
Revnets and locked projects can't modify ruleset data hooks. But you can still add functionality by wrapping the terminal:
| Need | How Wrapper Solves It |
|---|---|
| Dynamic splits at pay time | Parse from metadata, configure before forwarding |
| Pay + distribute atomically | Bundle operations in one tx |
| Token interception | Set beneficiary to wrapper, then stake/forward |
| Referral tracking | Parse referrer from metadata, record on-chain |
| Multi-hop payments | Receive tokens, swap, pay another project |
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;
}
}
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);
}
┌─────────────────────────────────────────────────────────────────┐
│ WRAPPER IS ADDITIVE │
├─────────────────────────────────────────────────────────────────┤
│ Client A ──► PayWrapper ──► JBMultiTerminal │
│ (gets special features) │
│ │
│ Client B ─────────────────► JBMultiTerminal │
│ (still works!) │
│ │
│ BOTH ARE VALID. Wrapper cannot block direct access. │
│ This is a FEATURE. Permissionless = good. │
└─────────────────────────────────────────────────────────────────┘
Bad thinking: "I'll use a wrapper to block payments that don't meet criteria X"
Good thinking: "I'll provide enhanced functionality for clients that opt in"
Swap Terminal is the canonical example:
User pays USDC ──► SwapTerminal ──► Swaps to ETH ──► JBMultiTerminal
Your wrapper follows the same architecture with different transformation logic.
The same pattern applies to cash outs. Wrap cashOutTokensOf to intercept redeemed funds:
/// @notice Cash out with automatic bridging of redeemed funds.
function cashOutAndBridge(
address holder,
uint256 projectId,
uint256 tokenCount,
address tokenToReclaim,
uint256 minTokensReclaimed,
address beneficiary,
uint256 destChainId, // Custom: where to bridge
bytes calldata metadata
) external returns (uint256 reclaimAmount) {
// 1. Cash out to THIS contract (intercept funds)
reclaimAmount = MULTI_TERMINAL.cashOutTokensOf(
holder,
projectId,
tokenCount,
tokenToReclaim,
minTokensReclaimed,
address(this), // <-- Wrapper receives funds
metadata
);
// 2. Bridge the redeemed funds to destination chain
_bridgeFunds(tokenToReclaim, reclaimAmount, beneficiary, destChainId);
return reclaimAmount;
}
/// @notice Cash out with automatic swap to different token.
function cashOutAndSwap(
address holder,
uint256 projectId,
uint256 tokenCount,
address tokenToReclaim,
uint256 minTokensReclaimed,
address tokenOut, // Custom: swap to this token
uint256 minAmountOut, // Custom: slippage protection
address beneficiary,
bytes calldata metadata
) external returns (uint256 amountOut) {
// 1. Cash out to this contract
uint256 reclaimAmount = MULTI_TERMINAL.cashOutTokensOf(
holder,
projectId,
tokenCount,
tokenToReclaim,
minTokensReclaimed,
address(this),
metadata
);
// 2. Swap redeemed tokens
amountOut = _swap(tokenToReclaim, tokenOut, reclaimAmount, minAmountOut);
// 3. Send swapped tokens to beneficiary
_sendFunds(tokenOut, amountOut, beneficiary);
return amountOut;
}
| Scenario | Terminal Wrapper? | Alternative |
|---|---|---|
| Pay Wrappers | ||
| Revnet needs new pay-time features | Yes | Can't modify hooks |
| Dynamic splits specified by payer | Yes | Native splits are static |
| Atomic pay + distribute | Yes | Two separate txs |
| Token interception/staking | Yes | - |
| Cash Out Wrappers | ||
| Cash out + bridge in one tx | Yes | Two separate txs |
| Cash out + swap to different token | Yes | Manual swap after |
| Cash out + stake redeemed funds | Yes | - |
| Cash out + LP deposit | Yes | - |
| Both | ||
| Simple operations with existing hooks | No | Use MultiTerminal directly |
| Need to block payments/cashouts | No | Wrappers can't enforce this |
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
import {IJBTerminal} from "@bananapus/core/src/interfaces/IJBTerminal.sol";
import {IJBMultiTerminal} from "@bananapus/core/src/interfaces/IJBMultiTerminal.sol";
import {IJBController} from "@bananapus/core/src/interfaces/IJBController.sol";
import {JBSplit} from "@bananapus/core/src/structs/JBSplit.sol";
import {JBSplitGroup} from "@bananapus/core/src/structs/JBSplitGroup.sol";
import {JBConstants} from "@bananapus/core/src/libraries/JBConstants.sol";
import {JBPermissionIds} from "@bananapus/permission-ids/src/JBPermissionIds.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/// @notice Terminal wrapper that allows payers to specify reserved token splits at pay time.
/// @dev Useful for revnets where ruleset hooks can't be modified post-deploy.
/// @dev Follows JBSwapTerminalRegistry pattern with shared _acceptFunds helper.
contract PayWithSplitsTerminal is IJBTerminal {
using SafeERC20 for IERC20;
// ═══════════════════════════════════════════════════════════════════════
// ERRORS
// ═══════════════════════════════════════════════════════════════════════
error InvalidSplitTotal();
// ═══════════════════════════════════════════════════════════════════════
// CONSTANTS
// ═══════════════════════════════════════════════════════════════════════
/// @notice Split group ID for reserved tokens.
uint256 public constant RESERVED_TOKEN_GROUP = 1;
/// @notice The underlying terminal this wrapper forwards to.
IJBMultiTerminal public immutable MULTI_TERMINAL;
/// @notice The controller for setting splits and distributing reserved tokens.
IJBController public immutable CONTROLLER;
// ═══════════════════════════════════════════════════════════════════════
// CONSTRUCTOR
// ═══════════════════════════════════════════════════════════════════════
/// @param _multiTerminal The JBMultiTerminal to wrap.
/// @param _controller The JBController for this deployment.
constructor(IJBMultiTerminal _multiTerminal, IJBController _controller) {
MULTI_TERMINAL = _multiTerminal;
CONTROLLER = _controller;
}
// ═══════════════════════════════════════════════════════════════════════
// EXTERNAL FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════
/// @notice Pay a project with optional dynamic splits specified in metadata.
/// @dev If metadata contains splits, they're applied before payment. Reserved tokens
/// are distributed after payment completes.
/// @param projectId The project to pay.
/// @param token The token to pay with (JBConstants.NATIVE_TOKEN for ETH).
/// @param amount The amount to pay (ignored for ETH, uses msg.value).
/// @param beneficiary Who receives the project tokens.
/// @param minReturnedTokens Minimum tokens to receive (slippage protection).
/// @param memo Payment memo.
/// @param metadata ABI-encoded (JBSplit[] splits, bytes innerMetadata).
/// - splits: Reserved token split configuration (empty array to skip)
/// - innerMetadata: Passed through to MultiTerminal (for hooks, etc.)
/// @return beneficiaryTokenCount The number of tokens minted to beneficiary.
function pay(
uint256 projectId,
address token,
uint256 amount,
address beneficiary,
uint256 minReturnedTokens,
string calldata memo,
bytes calldata metadata
) external payable returns (uint256 beneficiaryTokenCount) {
bytes memory innerMetadata;
// Parse and apply splits if metadata provided
if (metadata.length > 0) {
JBSplit[] memory splits;
(splits, innerMetadata) = abi.decode(metadata, (JBSplit[], bytes));
if (splits.length > 0) {
_validateAndSetSplits(projectId, splits);
}
}
// Accept funds and get value to forward
uint256 valueToSend = _acceptFunds(token, amount, address(MULTI_TERMINAL));
// Forward payment to MultiTerminal
beneficiaryTokenCount = MULTI_TERMINAL.pay{value: valueToSend}(
projectId,
token,
amount,
beneficiary,
minReturnedTokens,
memo,
innerMetadata
);
// Distribute reserved tokens to the configured splits
CONTROLLER.sendReservedTokensToSplitsOf(projectId);
return beneficiaryTokenCount;
}
/// @notice Add to a project's balance without receiving tokens.
/// @dev Pass-through to MultiTerminal.
function addToBalanceOf(
uint256 projectId,
address token,
uint256 amount,
bool shouldReturnHeldFees,
string calldata memo,
bytes calldata metadata
) external payable {
// Accept funds and get value to forward
uint256 valueToSend = _acceptFunds(token, amount, address(MULTI_TERMINAL));
MULTI_TERMINAL.addToBalanceOf{value: valueToSend}(
projectId,
token,
amount,
shouldReturnHeldFees,
memo,
metadata
);
}
/// @notice Check accounting contexts accepted by underlying terminal.
function accountingContextForTokenOf(
uint256 projectId,
address token
) external view returns (JBAccountingContext memory) {
return MULTI_TERMINAL.accountingContextForTokenOf(projectId, token);
}
/// @notice Get all accounting contexts for a project.
function accountingContextsOf(
uint256 projectId
) external view returns (JBAccountingContext[] memory) {
return MULTI_TERMINAL.accountingContextsOf(projectId);
}
/// @notice Get current surplus in a project's terminal.
function currentSurplusOf(
uint256 projectId,
JBAccountingContext[] calldata accountingContexts,
uint256 decimals,
uint256 currency
) external view returns (uint256) {
return MULTI_TERMINAL.currentSurplusOf(
projectId,
accountingContexts,
decimals,
currency
);
}
// ═══════════════════════════════════════════════════════════════════════
// INTERNAL FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════
/// @notice Accept funds from the caller and prepare them for forwarding.
/// @dev For ERC20s: transfers from sender, approves spender.
/// @dev For ETH: returns msg.value to forward.
/// @dev Pattern from JBSwapTerminalRegistry - consolidates token handling.
/// @param token The token being paid (NATIVE_TOKEN for ETH).
/// @param amount The amount being paid.
/// @param spender The address that will spend the tokens (the terminal).
/// @return valueToSend The ETH value to forward (0 for ERC20s).
function _acceptFunds(
address token,
uint256 amount,
address spender
) internal returns (uint256 valueToSend) {
if (token == JBConstants.NATIVE_TOKEN) {
// For ETH, just return msg.value to forward
return msg.value;
}
// For ERC20s: pull tokens from sender
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
// Approve the spender (terminal) to pull tokens
IERC20(token).forceApprove(spender, amount);
// No ETH to forward for ERC20 payments
return 0;
}
/// @notice Validate splits sum to 100% and set them on the controller.
/// @dev This wrapper must have SET_SPLIT_GROUPS permission on the project.
function _validateAndSetSplits(uint256 projectId, JBSplit[] memory splits) internal {
// Validate splits sum to 100%
uint256 total;
for (uint256 i; i < splits.length; i++) {
total += splits[i].percent;
}
if (total != JBConstants.SPLITS_TOTAL_PERCENT) revert InvalidSplitTotal();
// Get current ruleset ID
(JBRuleset memory ruleset,) = CONTROLLER.currentRulesetOf(projectId);
// Build split group
JBSplitGroup[] memory groups = new JBSplitGroup[](1);
groups[0] = JBSplitGroup({
groupId: RESERVED_TOKEN_GROUP,
splits: splits
});
// Set splits (requires SET_SPLIT_GROUPS permission)
CONTROLLER.setSplitGroupsOf(projectId, ruleset.id, groups);
}
/// @notice Required for receiving ETH refunds from terminal.
receive() external payable {}
}
import {
encodeFunctionData,
encodeAbiParameters,
parseAbiParameters,
parseUnits,
Address
} from 'viem';
const SPLITS_TOTAL_PERCENT = 1_000_000_000n; // 1e9
// Build split configuration
function encodeSplits(recipients: { address: Address; percent: number }[]) {
// Convert percentages (0-100) to JB format (0-1e9)
const splits = recipients.map(r => ({
preferredProjectId: 0n,
preferredBeneficiary: r.address,
percent: BigInt(Math.floor(r.percent * 10_000_000)), // 1% = 10_000_000
lockedUntil: 0n,
hook: '0x0000000000000000000000000000000000000000' as Address,
}));
return splits;
}
// Encode metadata for pay call
function encodePayMetadata(
splits: ReturnType<typeof encodeSplits>,
innerMetadata: `0x${string}` = '0x'
) {
return encodeAbiParameters(
parseAbiParameters([
'(uint256 preferredProjectId, address preferredBeneficiary, uint256 percent, uint256 lockedUntil, address hook)[]',
'bytes'
]),
[splits, innerMetadata]
);
}
// Example: Pay with dynamic splits
async function payWithSplits(
walletClient: WalletClient,
wrapperAddress: Address,
projectId: bigint,
paymentAmount: bigint,
recipients: { address: Address; percent: number }[]
) {
const splits = encodeSplits(recipients);
const metadata = encodePayMetadata(splits);
const NATIVE_TOKEN = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE';
const hash = await walletClient.writeContract({
address: wrapperAddress,
abi: PAY_WITH_SPLITS_ABI,
functionName: 'pay',
args: [
projectId,
NATIVE_TOKEN,
paymentAmount,
walletClient.account.address, // beneficiary
0n, // minReturnedTokens
'Payment with custom splits',
metadata
],
value: paymentAmount
});
return hash;
}
// Usage
await payWithSplits(
walletClient,
'0x...wrapper',
3n, // projectId
parseUnits('1', 18), // 1 ETH
[
{ address: '0xaaa...', percent: 50 }, // 50% to address A
{ address: '0xbbb...', percent: 30 }, // 30% to address B
{ address: '0xccc...', percent: 20 }, // 20% to address C
]
);
// Deploy the wrapper
PayWithSplitsTerminal wrapper = new PayWithSplitsTerminal(
IJBMultiTerminal(MULTI_TERMINAL_ADDRESS),
IJBController(CONTROLLER_ADDRESS)
);
// Grant SET_SPLIT_GROUPS permission to wrapper
// (Must be called by project owner or someone with SET_PERMISSIONS)
JBPermissionsData[] memory permissions = new JBPermissionsData[](1);
permissions[0] = JBPermissionsData({
operator: address(wrapper),
projectId: PROJECT_ID,
permissionIds: _asSingletonArray(JBPermissionIds.SET_SPLIT_GROUPS)
});
PERMISSIONS.setPermissionsFor(projectOwner, permissions);
Use case: Deposit contributions to yield protocols (Aave, Compound, etc.), route yield to project balance while allowing principal cash-outs
Solution: Combined pay/cash-out hook that deposits to Aave, tracks principal separately from yield
Create "YeeHaw" style funding where:
┌─────────────────────────────────────────────────────────────────┐
│ Payment Flow │
│ User pays → Hook deposits to Aave → Principal tracked │
│ │
│ Yield Flow │
│ Aave earns yield → Hook withdraws → addToBalanceOf() │
│ → Appears in project balance │
│ → Team uses sendPayoutsOf() │
│ │
│ Cash Out Flow │
│ User cashes out → Hook withdraws from Aave → Direct to user │
│ (bypasses terminal, separate from yield) │
└─────────────────────────────────────────────────────────────────┘
contract YieldHook is IJBPayHook, IJBCashOutHook, IJBRulesetDataHook {
IPool public immutable AAVE_POOL;
IJBTerminal public immutable TERMINAL;
mapping(uint256 projectId => uint256) public principalDeposited;
mapping(uint256 projectId => uint256) public principalWithdrawn;
mapping(uint256 projectId => uint256) public yieldWithdrawn;
struct ProjectConfig {
address principalToken; // USDC, ETH, etc.
address aToken; // Corresponding aToken
uint256 yieldThreshold; // Min yield before transfer
bool active;
}
}
function beforePayRecordedWith(
JBBeforePayRecordedContext calldata context
) external view override returns (
uint256 weight,
JBPayHookSpecification[] memory hookSpecifications
) {
weight = context.weight;
// Forward ALL payment funds to this hook for Aave deposit
hookSpecifications = new JBPayHookSpecification[](1);
hookSpecifications[0] = JBPayHookSpecification({
hook: IJBPayHook(address(this)),
amount: context.amount.value, // Must be explicit, not 0
metadata: ""
});
}
function afterPayRecordedWith(
JBAfterPayRecordedContext calldata context
) external payable override {
ProjectConfig storage config = projectConfigs[context.projectId];
uint256 amount = context.forwardedAmount.value;
// Deposit to Aave
IERC20(config.principalToken).forceApprove(address(AAVE_POOL), amount);
AAVE_POOL.supply(config.principalToken, amount, address(this), 0);
// Track principal
principalDeposited[context.projectId] += amount;
// Check if yield should be transferred
_maybeTransferYield(context.projectId);
}
function beforeCashOutRecordedWith(
JBBeforeCashOutRecordedContext calldata context
) external view override returns (
uint256 cashOutTaxRate,
uint256 cashOutCount,
uint256 totalSupply,
JBCashOutHookSpecification[] memory hookSpecifications
) {
// Calculate user's share of principal
uint256 availablePrincipal = principalDeposited[context.projectId]
- principalWithdrawn[context.projectId];
uint256 userShare = (availablePrincipal * context.cashOutCount) / context.totalSupply;
cashOutTaxRate = 0; // No tax on principal
cashOutCount = context.cashOutCount;
totalSupply = context.totalSupply;
// Route to this hook for Aave withdrawal
hookSpecifications = new JBCashOutHookSpecification[](1);
hookSpecifications[0] = JBCashOutHookSpecification({
hook: IJBCashOutHook(address(this)),
amount: userShare,
metadata: ""
});
}
function afterCashOutRecordedWith(
JBAfterCashOutRecordedContext calldata context
) external payable override {
ProjectConfig storage config = projectConfigs[context.projectId];
uint256 amount = context.forwardedAmount.value;
// Withdraw principal from Aave directly to user
AAVE_POOL.withdraw(config.principalToken, amount, context.beneficiary);
principalWithdrawn[context.projectId] += amount;
}
function _maybeTransferYield(uint256 projectId) internal {
ProjectConfig storage config = projectConfigs[projectId];
uint256 availableYield = _calculateAvailableYield(projectId);
if (availableYield >= config.yieldThreshold) {
// Withdraw yield from Aave to this contract
uint256 withdrawn = AAVE_POOL.withdraw(
config.principalToken,
availableYield,
address(this)
);
// Approve terminal
IERC20(config.principalToken).forceApprove(address(TERMINAL), withdrawn);
// Add to project balance (team can then use sendPayoutsOf)
TERMINAL.addToBalanceOf(
projectId,
config.principalToken,
withdrawn,
false, // shouldReturnHeldFees
"", // memo
"" // metadata
);
yieldWithdrawn[projectId] += withdrawn;
}
}
function _calculateAvailableYield(uint256 projectId) internal view returns (uint256) {
ProjectConfig storage config = projectConfigs[projectId];
uint256 totalBalance = IERC20(config.aToken).balanceOf(address(this));
uint256 principalRemaining = principalDeposited[projectId] - principalWithdrawn[projectId];
if (totalBalance > principalRemaining + yieldWithdrawn[projectId]) {
return totalBalance - principalRemaining - yieldWithdrawn[projectId];
}
return 0;
}
// Ruleset must enable data hooks
JBRulesetMetadata({
useDataHookForPay: true, // Enable beforePay/afterPay
useDataHookForCashOut: true, // Enable beforeCashOut/afterCashOut
dataHook: address(yieldHook), // Your hook address
// ...
});
| Scenario | Fits Pattern? |
|---|---|
| Yield-backed funding | ✅ |
| Aave/Compound integration | ✅ |
| Principal-protected investing | ✅ |
| Staking rewards integration | ✅ (adapt for staking protocol) |
| Standard fundraising | ❌ Use native terminals |
| NFT-based funding | ❌ Use 721 hook |
context.amount.value to forward funds, not 0addToBalanceOf() to add yield to project balance/jb-project for deployment script)revnet-core-v5)JB721TiersHookProjectDeployer/jb-simplify - Checklist to reduce custom code/jb-project - Project deployment and configuration/jb-ruleset - Ruleset configuration details/jb-v5-impl - Deep implementation mechanics/jb-terminal-wrapper - Full terminal wrapper implementation details