计划执行与检查节点
executing-plans
按既定实施计划完成工作,保持过程检查和反馈。
jb-v5-impl【效率与协作】Deep implementation knowledge for Juicebox V5 protocol. Covers internal mechanics, edge cases, tradeoffs, gas considerations, and critical integration patterns. Use when reasoning about how things work internally.
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
This skill provides deep implementation knowledge for integrators, covering internal mechanics, edge cases, tradeoffs, and critical considerations.
User calls pay()
→ _pay() internal
→ STORE.recordPaymentFrom()
→ Validates ruleset exists and !pausePay
→ Invokes data hook if useDataHookForPay
→ Calculates tokens: mulDiv(amount, weight, weightRatio)
→ Returns hook specifications
→ TOKENS.mintFor() via controller
→ Executes pay hook specifications
→ Emits Pay event
→ Validates minReturnedTokens
Weight Calculation: Token minting uses fixed-point math with 18 decimals:
tokenCount = mulDiv(amount.value, weight, 10**18)
The weight is the ruleset's configured weight (tokens per unit of currency).
Data Hook Override: When useDataHookForPay is true, the data hook can:
Hook Execution Order: Pay hooks execute sequentially after token minting. Each hook receives its specified amount from the payment.
The cash out calculation implements a modified bonding curve:
Base = (surplus × cashOutCount) / totalSupply
taxFactor = (MAX_RATE - taxRate) + (taxRate × cashOutCount / totalSupply)
reclaimAmount = base × taxFactor / MAX_RATE
Where MAX_RATE = 10000 (representing 100%).
| Tax Rate | Effect |
|---|---|
| 0% | Linear redemption: get proportional share of surplus |
| 50% | Partial penalty: larger redemptions penalized more |
| 100% | No redemption possible: returns 0 |
Critical Insight: The tax rate creates a curve where:
Surplus is calculated as:
surplus = balance - remainingPayoutLimit
The useTotalSurplusForCashOuts metadata flag determines whether to use:
Tradeoff: Total surplus provides more accurate valuation but costs more gas due to multi-terminal queries.
Queue ruleset
→ approvalHook.approvalStatusOf() called
→ If ApprovalExpected: queued but not yet active
→ If Approved: becomes current when start time reached
→ If Failed/Empty: reverts to base ruleset
JBDeadline Implementation: Requires DURATION() seconds between queue time and ruleset start. If queued too late, approval fails.
Weight is cut exponentially each cycle:
newWeight = weight × (MAX_CUT - weightCutPercent) / MAX_CUT
Applied iteratively for each cycle that has passed.
Optimization: For large cycle counts (>1000), the contract uses cached intermediate values to avoid O(n) computation:
if (multiple > 1000) {
// Use cached values at 50,000 interval checkpoints
}
When a ruleset is queued:
basedOnId field tracks inheritance chainCritical Consideration: If an approval hook rejects a ruleset, the system walks backward through the inheritance chain to find the latest approved ancestor.
cycleNumber = 1 + (currentTime - start) / duration
For rulesets with duration = 0 (infinite), cycle number stays at 1.
Reserved tokens don't mint immediately. Instead:
pendingReservedTokenBalanceOf[projectId] += (tokenCount × reservedRate) / MAX_RATE
Tokens accumulate until sendReservedTokensToSplitsOf() is called.
sendReservedTokensToSplitsOf()
→ Reads pendingReservedTokenBalanceOf
→ Resets pending balance to 0
→ Mints all reserved tokens to controller
→ Distributes via _sendReservedTokensToSplitGroupOf()
→ For each split:
→ If projectId set: pay that project
→ If hook set: call hook
→ Else: transfer to beneficiary
→ Leftover sent to project owner
Batched Distribution:
Per-Payment Distribution (alternative pattern):
Splits use packed storage for gas efficiency:
Slot 1: percent (32) | projectId (64) | beneficiary (160)
Slot 2: preferAddToBalance (1) | lockedUntil (48) | hook (160)
When updating splits:
Edge Case: If a locked split's beneficiary becomes a contract that can't receive funds, those funds are stuck until lock expires.
for each split:
amount = totalAmount × split.percent / SPLITS_TOTAL_PERCENT
if (split.hook != address(0)):
// Optimistically transfer to hook
hook.processSplitWith(context)
else if (split.projectId != 0):
if (split.preferAddToBalance):
terminal.addToBalanceOf(projectId, ...)
else:
terminal.pay(projectId, ...)
else:
// Direct transfer to beneficiary
Critical: Split hooks receive funds optimistically before processSplitWith() is called. Malicious hooks could steal funds.
mintTokens = amount × weight / 10^18
swapTokens = TWAP_quote - slippageTolerance
if (swapTokens > mintTokens):
route through swap
else:
standard mint
base = (amountIn × 10 × DENOMINATOR) / poolLiquidity
if base > 150 bps: tolerance = 12%
if base > 100 bps: tolerance = 33%
if base > 75 bps: tolerance = 5%
... (progressive reduction)
try uniswapPool.swap(...) returns (int256 amount0, int256 amount1) {
// Process successful swap
} catch {
// Return 0, triggering fallback to standard mint
}
Critical: Failed swaps don't revert the entire payment. The hook gracefully falls back to standard minting.
After swap execution:
if (leftoverAmount > 0) {
controller.mintTokensOf(projectId, leftoverAmount, beneficiary, ...)
}
Ensures no user funds are lost to rounding or partial swaps.
afterPayRecordedWith()
→ Decode metadata for tier IDs
→ If no tiers specified: auto-select based on price
→ STORE.recordMint() validates:
→ Total tier prices ≤ payment amount
→ Tiers have remaining supply
→ Tiers are active
→ Mint NFTs to beneficiary
→ Handle leftover as credits or revert
payCreditsOf[payer] += leftover
// On next payment:
effectiveAmount = payment + payCreditsOf[payer]
payCreditsOf[payer] = 0
Tradeoff: Credits provide flexibility but:
Each NFT's cash out value equals its tier price:
weight = tier.price × redemptionRate
The totalCashOutWeight() aggregates all outstanding NFT values for proportion calculations.
Critical: If NFT prices vary significantly, small-price NFT holders receive proportionally less than their initial payment.
FEE = 25 // 2.5% (out of 1000)
feeAmount = amount × FEE / (1000 + FEE) // ~2.44% of gross
Note: Fee is calculated as a portion of the gross amount, not added on top.
Fees apply to:
Fees exempt:
When holdFees is true in ruleset metadata:
heldFeesOf[projectId][token].push(fee)
// Fees held for 28 days
// Can be refunded by adding equivalent to balance
After 28 days, held fees can be processed to the fee beneficiary (Project #1).
The directory manages terminal and controller assignments for projects.
mapping(uint256 projectId => IJBController) public controllerOf;
mapping(uint256 projectId => IJBTerminal[]) internal _terminalsOf;
mapping(uint256 projectId => mapping(address token => IJBTerminal)) internal _primaryTerminalOf;
mapping(address => bool) public isAllowedToSetFirstController;
setTerminalsOf() replaces the entire terminal array:
SET_TERMINALS permission OR caller is project controllersetTerminalsAllowed flag (bypassed if controller is caller)primaryTerminalOf() resolution:
setControllerOf() handles migration:
SET_CONTROLLER permission OR first-time setup via allowlistsetControllerAllowed flagIJBMigratable.migrate() on old controller if interface supportedCritical: First controller can only be set by addresses on the isAllowedToSetFirstController allowlist.
ERC-721 contract where each token represents a Juicebox project.
function createFor(address owner) external returns (uint256 projectId) {
projectId = ++count;
_mint(owner, projectId);
}
The count variable acts as both total project counter and next project ID.
function tokenURI(uint256 projectId) public view override returns (string memory) {
if (address(tokenUriResolver) == address(0)) return "";
return tokenUriResolver.tokenUriOf(address(this), projectId);
}
Graceful Degradation: Returns empty string if no resolver set, preventing reverts.
Overrides _msgSender(), _msgData(), and _contextSuffixLength() for meta-transaction support via trusted forwarder.
Bitmap-based permission system enabling granular access control.
mapping(
address operator => mapping(
address account => mapping(
uint256 projectId => uint256 packedPermissions
)
)
) public permissionsOf;
Each bit in the uint256 represents one of 256 possible permissions. Project ID 0 is the wildcard, granting permissions across all projects.
hasPermission() implements hierarchical evaluation:
1. If includeRoot && operator has ROOT permission:
→ Check specific project OR wildcard project
→ Return true if ROOT found
2. Check specific permissionId bit on specific project
3. If includeWildcardProjectId:
→ Check specific permissionId bit on project 0
hasPermissions() (batch check):
// Only account holder or authorized operators can modify
if (_msgSender() != account) {
// Operators cannot grant ROOT permission
if (permissionsData.permissionIds contains ROOT) revert;
// ROOT operators cannot modify wildcard project
if (projectId == 0) revert;
// Must have ROOT on specific project to modify
if (!hasPermission(ROOT, projectId)) revert;
}
Edge Cases:
PermissionIdOutOfBoundsDual-balance system supporting both unclaimed credits and ERC-20 tokens.
mapping(address holder => mapping(uint256 projectId => uint256)) public creditBalanceOf;
mapping(uint256 projectId => uint256) public totalCreditSupplyOf;
mapping(uint256 projectId => IJBToken) public tokenOf;
mapping(IJBToken token => uint256) public projectIdOf;
mintFor() chooses based on ERC-20 existence:
if (tokenOf[projectId] != address(0)) {
token.mint(holder, count); // Direct ERC-20 mint
} else {
creditBalanceOf[holder][projectId] += count; // Store as credits
totalCreditSupplyOf[projectId] += count;
}
burnFrom() burns credits first, then tokens:
uint256 creditBalance = creditBalanceOf[holder][projectId];
uint256 tokensToBurn = creditBalance < count ? count - creditBalance : 0;
uint256 creditsToBurn = count - tokensToBurn;
// Burn credits
creditBalanceOf[holder][projectId] -= creditsToBurn;
totalCreditSupplyOf[projectId] -= creditsToBurn;
// Burn tokens
if (tokensToBurn > 0) token.burn(holder, tokensToBurn);
deployERC20For() uses minimal proxy clones:
Clones.clone() or Clones.cloneDeterministic()tokenOf[projectId] and projectIdOf[token]setTokenFor() validates external tokens:
canBeAddedTo(projectId)The JBTokens system supports custom ERC20 tokens, enabling advanced tokenomics while preserving Juicebox's payment and redemption mechanics.
When setTokenFor() is called with a custom token:
function setTokenFor(uint256 projectId, IJBToken token) external {
// 1. Validate token is compatible
if (token.decimals() != 18) revert JBTokens_TokensMustHave18Decimals();
if (!token.canBeAddedTo(projectId)) revert JBTokens_TokenCannotBeAddedTo();
if (projectIdOf[token] != 0) revert JBTokens_TokenAlreadyAssigned();
// 2. Store bidirectional mapping
tokenOf[projectId] = token;
projectIdOf[token] = projectId;
// 3. Existing credits remain claimable
// totalCreditSupplyOf[projectId] stays unchanged
}
Key Insight: Setting a custom token doesn't migrate existing credits. Credit holders must call claimTokensFor() to convert credits to the ERC20.
When payments are received:
pay() → mintTokensOf() → JBTokens.mintFor()
↓
customToken.mint(holder, amount)
When cash outs occur:
cashOutTokensOf() → burnTokensOf() → JBTokens.burnFrom()
↓
// Burns credits first, then tokens
customToken.burn(holder, tokensToBurn)
Critical: The controller calls mint() and burn() directly on your token. Your token MUST grant these permissions to the controller address.
| Requirement | Reason |
|---|---|
| 18 decimals | All Juicebox math (weights, rates) assumes 18 decimals |
| canBeAddedTo() | Validates token agrees to serve this project |
| mint(address, uint256) | Controller must mint on payments |
| burn(address, uint256) | Controller must burn on cash outs |
| Controller access | Token must authorize JBController for mint/burn |
1. Transfer Tax Token
function _update(address from, address to, uint256 amount) internal override {
// Skip tax for controller operations (mints/burns)
if (from == address(0) || to == address(0) || msg.sender == controller) {
super._update(from, to, amount);
return;
}
// Apply tax on transfers
uint256 tax = (amount * TAX_RATE) / 10000;
super._update(from, taxRecipient, tax);
super._update(from, to, amount - tax);
}
Tradeoff: Tax revenue goes to taxRecipient, not the Juicebox treasury. Consider routing tax to the project via addToBalanceOf().
2. Rebasing Token
// Track shares instead of balances
mapping(address => uint256) private _shares;
uint256 public totalShares;
uint256 public rebaseIndex = 1e18; // Starts at 1:1
function balanceOf(address account) public view override returns (uint256) {
return (_shares[account] * rebaseIndex) / 1e18;
}
function rebase(uint256 newIndex) external onlyOwner {
rebaseIndex = newIndex;
// All balances scale proportionally
}
Tradeoff: Cash out calculations use totalSupply(). Rebasing changes supply without minting, which affects redemption value.
3. Governance Token (ERC20Votes)
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
contract GovernanceProjectToken is ERC20Votes {
address public controller;
function mint(address to, uint256 amount) external {
require(msg.sender == controller, "Only controller");
_mint(to, amount);
}
// Inherits delegation, checkpointing, getPastVotes()
}
Benefit: Token holders can vote on external governance proposals while maintaining Juicebox treasury mechanics.
4. Editable Name/Symbol Token
contract EditableProjectToken is ERC20 {
string private _tokenName;
string private _tokenSymbol;
function name() public view override returns (string memory) { return _tokenName; }
function symbol() public view override returns (string memory) { return _tokenSymbol; }
function setName(string calldata newName) external onlyProjectOwner {
_tokenName = newName;
}
function setSymbol(string calldata newSymbol) external onlyProjectOwner {
_tokenSymbol = newSymbol;
}
}
Benefit: Rebrand without redeploying token or migrating liquidity. Project owner controls metadata.
5. Vesting Token with Per-Address Schedules
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
uint40 duration; // Total vesting duration
}
mapping(address => VestingSchedule) public vestingOf;
function vestedAmountOf(address account) public view returns (uint256) {
VestingSchedule memory schedule = vestingOf[account];
if (schedule.totalAmount == 0) return balanceOf(account);
if (block.timestamp < schedule.cliff) return 0;
if (block.timestamp >= schedule.start + schedule.duration) {
return schedule.totalAmount;
}
uint256 elapsed = block.timestamp - schedule.start;
return (schedule.totalAmount * elapsed) / schedule.duration;
}
function _update(address from, address to, uint256 amount) internal override {
// Skip vesting checks for mints, burns, controller ops
if (from == address(0) || to == address(0) || msg.sender == controller) {
super._update(from, to, amount);
return;
}
VestingSchedule storage schedule = vestingOf[from];
if (schedule.totalAmount == 0) {
super._update(from, to, amount);
return;
}
require(block.timestamp >= schedule.cliff, "Cliff not reached");
uint256 transferable = vestedAmountOf(from) - schedule.released;
require(amount <= transferable, "Insufficient vested balance");
schedule.released += amount;
super._update(from, to, amount);
}
function setVestingSchedule(
address beneficiary,
uint256 totalAmount,
uint40 start,
uint40 cliffDuration,
uint40 vestingDuration
) external onlyProjectOwner {
vestingOf[beneficiary] = VestingSchedule({
totalAmount: totalAmount,
released: 0,
start: start,
cliff: start + cliffDuration,
duration: vestingDuration
});
}
}
Key Difference from Treasury Vesting:
Use Case: Team allocations, investor lock-ups, contributor rewards where tokens should vest per-person with individual cliffs and durations. Can be combined with treasury vesting for layered protection.
Tradeoff: Does not prevent cash outs (controller operations bypass vesting). If you need to prevent recipients from cashing out, combine with a ruleset that has pauseCashOut: true during the vesting period, or set a high cashOutTaxRate.
Token assigned twice: A token can only serve one project. setTokenFor() reverts if token already assigned.
Credit/token split during cash out: Burns credits first, then tokens:
// If user has 100 credits and 50 tokens, burning 120:
// - Burns all 100 credits
// - Burns 20 tokens
Transfer restrictions: If your token blocks certain transfers, ensure controller operations (mint/burn) are always allowed.
Pausable tokens: Pausing transfers will break cash outs if tokens can't be burned.
Fee-on-transfer tokens: Not directly supported. The minted amount must equal the amount the controller requested.
Approval requirements: JBTokens calls burn() directly. If your token requires approval for burns, this will fail. Use burnFrom pattern that allows controller without approval.
Before using a custom token:
canBeAddedTo(projectId) returns true for your projecttotalSupply() reflects actual redeemable tokensPacked storage for payout limits and surplus allowances.
// Bits 0-223: amount (up to ~2^224 wei)
// Bits 224-255: currency (32-bit identifier)
uint256 packed = uint256(amount) | (uint256(currency) << 224);
setFundAccessLimitsFor() requires strictly increasing currency order:
for (uint256 i = 1; i < payoutLimits.length; i++) {
if (payoutLimits[i].currency <= payoutLimits[i-1].currency) {
revert JBFundAccessLimits_InvalidPayoutLimitCurrencyOrdering();
}
}
This prevents duplicates and enables O(n) iteration during lookups.
Zero-amount limits are filtered during storage, not stored:
if (limit.amount > 0) {
_packedPayoutLimitsDataOf[projectId][rulesetId][terminal][token].push(packed);
}
Understanding the lifecycle of payout limits vs surplus allowances is critical for project design. This is a common source of confusion.
Payout limits reset when a new cycle begins:
Cycle 1: Payout limit = 10 ETH
→ Team sends 10 ETH payouts
→ Remaining limit = 0
Cycle 2: Payout limit = 10 ETH (RESET!)
→ Team can send another 10 ETH
→ This continues every cycle
Key Insight: The payout limit is defined per-ruleset, and when a cycling ruleset starts a new cycle, the limit refreshes. This enables recurring distributions without queuing multiple rulesets.
Surplus allowance does NOT reset each cycle:
Cycle 1: Surplus allowance = 20 ETH
→ Team uses 15 ETH from surplus
→ Remaining allowance = 5 ETH
Cycle 2: Surplus allowance = 5 ETH (NOT reset!)
→ Still only 5 ETH available
→ Allowance only resets if NEW RULESET is queued
Key Insight: Surplus allowance is a one-time budget per ruleset configuration. It's designed for discretionary treasury access, not recurring distributions.
The terminal store tracks used amounts separately from configured limits:
// JBTerminalStore
mapping(address terminal =>
mapping(uint256 projectId =>
mapping(address token =>
mapping(uint256 rulesetCycleNumber => uint256)
)
)
) public usedPayoutLimitOf;
mapping(address terminal =>
mapping(uint256 projectId =>
mapping(address token =>
mapping(uint256 rulesetId => uint256)
)
)
) public usedSurplusAllowanceOf;
Notice the key difference:
rulesetCycleNumber → resets each cyclerulesetId → persists across cyclesSurplus (redeemable amount) is calculated as:
surplus = balance - remainingPayoutLimit
Where remainingPayoutLimit is the configured limit minus used amount for the current cycle.
Implication: Payout limits protect funds from cash outs. If you set a 10 ETH payout limit, that 10 ETH cannot be cashed out by token holders until it's distributed or the cycle ends.
Combine both limit types for sophisticated treasury management:
| Mechanism | Behavior | Use Case |
|---|---|---|
| Payout Limit | Resets each cycle | Recurring vesting/salaries |
| Surplus Allowance | One-time per ruleset | Emergency fund / discretionary |
Example: 12-Month Vesting with Treasury Reserve
// Single cycling ruleset (NOT 12 separate rulesets!)
JBRulesetConfig({
duration: 30 days, // Monthly cycles
// ...
fundAccessLimitGroups: [
JBFundAccessLimitGroup({
terminal: address(TERMINAL),
token: JBConstants.NATIVE_TOKEN,
payoutLimits: [
JBCurrencyAmount({
amount: 6.67 ether, // Monthly vesting (80 ETH / 12)
currency: nativeCurrency
})
],
surplusAllowances: [
JBCurrencyAmount({
amount: 20 ether, // One-time treasury access
currency: nativeCurrency
})
]
})
]
});
Result over 12 months:
Queueing 12 rulesets for 12-month vesting
Expecting surplus allowance to reset
Not understanding surplus protection
Hierarchical price feed system with inverse calculation support.
pricePerUnitOf() follows this order:
1. Same currency? Return 10^decimals (1:1 ratio)
2. Direct feed exists? Use feed.currentUnitPrice()
3. Inverse feed exists? Calculate reciprocal
4. Project-specific not found? Try default (projectId=0)
5. Nothing found? Revert with PriceFeedNotFound
// If only priceFeedFor[project][B][A] exists, calculate A→B:
price = mulDiv(10**decimals, 10**decimals, inverseFeed.currentUnitPrice(decimals));
Price feeds are append-only:
Access Control:
Wraps Uniswap V3 swaps into a Juicebox terminal interface.
pay()/addToBalanceOf()
→ _acceptFundsFor() // Transfer or Permit2
→ _handleTokenTransfersAndSwap()
→ _beforeTransferFor() // Wrap ETH if needed
→ _swap() // Execute Uniswap swap
→ Forward to primary terminal for output token
function _getQuote(IUniswapV3Pool pool, uint256 amountIn) internal view returns (uint256) {
uint32 secondsAgo = OracleLibrary.getOldestObservationSecondsAgo(address(pool));
// Cap at configured window (2 min to 2 days)
if (secondsAgo > twapWindow) secondsAgo = twapWindow;
(int24 arithmeticMeanTick,) = OracleLibrary.consult(pool, secondsAgo);
return OracleLibrary.getQuoteAtTick(arithmeticMeanTick, amountIn, tokenIn, tokenOut);
}
addDefaultPool() setup:
Project-specific configs override global defaults:
pool = poolOf[projectId][tokenIn][tokenOut];
if (pool == address(0)) {
pool = poolOf[DEFAULT_PROJECT_ID][tokenIn][tokenOut];
}
Tiered NFT system with integrated payment processing and cash out support. Uses JBOwnable for flexible ownership.
The 721 hook implements three hook interfaces:
beforePayRecordedWith(), beforeCashOutRecordedWith()afterPayRecordedWith()afterCashOutRecordedWith()Plus extends JBOwnable for project-based or EOA ownership with permission delegation.
struct JB721Tier {
uint104 price; // Price in terminal token
uint32 initialSupply; // Starting supply
uint32 remainingSupply; // Current available
uint16 votingUnits; // Governance weight
uint16 reserveFrequency; // Reserved mint ratio (1 in N)
uint24 category; // Grouping identifier
bool transfersPausable; // Can transfers be paused
bool cannotBeRemoved; // Permanent tier flag
}
_processPayment)afterPayRecordedWith()
→ _processPayment()
→ Normalize payment to tier pricing currency via JBPrices
→ Add existing payCreditsOf[beneficiary] if payer == beneficiary
→ Decode metadata for tier IDs to mint
→ If no tiers specified: auto-select best fit tiers
→ Call STORE.recordMint() for each tier
→ Validates tier active and has supply
→ Handles reserved mints (1 per reserveFrequency)
→ Mint NFTs via _mint()
→ Handle leftover:
→ If allowOverspending: add to payCreditsOf
→ Else: revert if leftover exists
When payer doesn't specify tiers, the hook selects automatically:
function _selectAutoTiers(uint256 amount) internal view returns (uint256[] tiers) {
// Iterate tiers by price descending
// Select highest-priced tier that fits remaining amount
// Repeat until amount exhausted or no tier fits
}
Tradeoff: Auto-selection may not match user intent; explicit tier selection preferred for UX.
mapping(address payer => uint256) public payCreditsOf;
// On payment:
if (payer == beneficiary) {
effectiveAmount = payment.amount + payCreditsOf[payer];
payCreditsOf[payer] = 0; // Use all credits
}
// After minting:
if (leftover > 0 && allowOverspending) {
payCreditsOf[beneficiary] += leftover;
emit AddPayCredits(beneficiary, leftover, ...);
}
Edge Cases:
Each NFT's value equals its tier price:
function cashOutWeightOf(uint256[] tokenIds) public view returns (uint256 weight) {
for (uint256 i; i < tokenIds.length; i++) {
JB721Tier tier = STORE.tierOfTokenId(tokenIds[i]);
weight += tier.price;
}
}
Total outstanding value:
function totalCashOutWeight() public view returns (uint256) {
return STORE.totalCashOutWeight(address(this));
}
Cash Out Proportion:
userShare = (userNFTWeight / totalCashOutWeight) × surplus × (1 - taxRate)
Tiers can reserve NFTs for the project:
// If reserveFrequency = 10, every 10th mint goes to reserved beneficiary
if (mintCount % tier.reserveFrequency == 0) {
_mint(reservedBeneficiary, tokenId);
} else {
_mint(payer, tokenId);
}
Reserved NFTs are minted inline during payment, not accumulated.
The hook extends JBOwnable for flexible access control:
// Project-based ownership (common pattern):
constructor(..., uint256 projectId, ...) {
_transferOwnership(projectId); // Owner = project NFT holder
}
// Permission delegation:
// Owner can grant ADJUST_721_TIERS to operators via JBPermissions
// Operators can then call adjustTiers() without being owner
Permission IDs used:
ADJUST_721_TIERS (17): Add/remove tiersSET_721_METADATA (18): Update tier metadataMINT_721 (19): Manual mintingSET_721_DISCOUNT_PERCENT (27): Adjust pricingfunction adjustTiers(JB721TierConfig[] tiersToAdd, uint256[] tierIdsToRemove) external {
// Requires ADJUST_721_TIERS permission or owner
// Remove tiers (if not cannotBeRemoved)
for (uint256 id : tierIdsToRemove) {
require(!tier.cannotBeRemoved, "TIER_LOCKED");
STORE.recordRemoveTierOf(id);
}
// Add new tiers
for (JB721TierConfig config : tiersToAdd) {
STORE.recordAddTier(config);
}
}
Payer metadata structure for specifying tiers:
bytes4 constant METADATA_ID = bytes4(keccak256("JB721TiersHook"));
// Encoded as: [METADATA_ID][allowOverspending (bool)][tierIds (uint16[])]
bytes memory metadata = abi.encode(
true, // allowOverspending
[uint16(1), uint16(3)] // Mint tier 1 and tier 3
);
When building prediction games or outcome-based systems, you need to extend the 721-hook to change treasury mechanics. This section covers the key implementation patterns from Defifa.
| Need | Resolver | Extended Hook |
|---|---|---|
| Custom artwork/metadata | ✅ | ✅ |
| Dynamic cash out weights | ❌ | ✅ |
| First-owner tracking | ❌ | ✅ |
| Phase-based restrictions | ❌ | ✅ |
| Governor integration | ❌ | ✅ |
Rule of thumb: If you need to change how money flows, extend the hook. If you only need to change how tokens look, use a resolver.
Standard 721-hook uses fixed weights based on tier price:
// Standard: weight = tier.price
function cashOutWeightOf(uint256[] tokenIds) returns (uint256 weight) {
for (uint256 i; i < tokenIds.length; i++) {
weight += STORE.tierOfTokenId(tokenIds[i]).price;
}
}
For dynamic weights (e.g., prediction games), override with configurable weights:
// Extended: weight = configurable per tier
uint256 constant TOTAL_CASH_OUT_WEIGHT = 1e18; // 100% distributed among tiers
mapping(uint256 tierId => uint256) public tierCashOutWeight;
function cashOutWeightOf(uint256[] tokenIds) returns (uint256 weight) {
for (uint256 i; i < tokenIds.length; i++) {
uint256 tierId = STORE.tierIdOfToken(tokenIds[i]);
weight += tierCashOutWeight[tierId];
}
}
// Called by governor after outcome is known
function setTierCashOutWeightsTo(DefifaTierCashOutWeight[] calldata weights) external {
// Verify caller is authorized (governor)
// Verify total weights sum to TOTAL_CASH_OUT_WEIGHT
for (uint256 i; i < weights.length; i++) {
tierCashOutWeight[weights[i].id] = weights[i].cashOutWeight;
}
}
For games where rewards should go to original minters (not secondary buyers):
// Track original minter
mapping(uint256 tokenId => address) public firstOwnerOf;
// In _processPayment() after minting:
function _processPayment(JBAfterPayRecordedContext calldata context) internal override {
// ... mint logic ...
// Record first owner
for (uint256 i; i < mintedTokenIds.length; i++) {
firstOwnerOf[mintedTokenIds[i]] = context.beneficiary;
}
}
// In cash out, rewards go to first owner:
function afterCashOutRecordedWith(JBAfterCashOutRecordedContext calldata context) external {
// Verify current owner initiated cash out
// But send rewards to firstOwnerOf[tokenId]
address rewardRecipient = firstOwnerOf[tokenId];
// Transfer rewards to original minter
_transferRewards(rewardRecipient, amount);
}
Trade-off: First-owner tracking adds storage costs but ensures fair game mechanics where secondary market purchases don't steal rewards from original participants.
Different phases have different cash out rules:
enum GamePhase { COUNTDOWN, MINT, REFUND, SCORING, COMPLETE, NO_CONTEST }
function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context)
external view override
returns (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply, JBCashOutHookSpecification[] memory)
{
GamePhase phase = currentPhase();
if (phase == GamePhase.REFUND) {
// During refund: return mint cost (full refund)
return _refundCashOut(context);
}
if (phase == GamePhase.COMPLETE) {
// After scoring: return weighted share of pot
return _scoredCashOut(context);
}
// Other phases: no cash out allowed
revert CashOutNotAllowed();
}
The governor contract calls into the delegate to set weights:
// In Governor contract:
function ratifyScorecard(DefifaScorecard calldata scorecard) external {
// Verify quorum reached
require(attestationCount[scorecard.id] >= quorum(), "Quorum not reached");
// Set weights on delegate
IDefifaDelegate(delegate).setTierCashOutWeightsTo(scorecard.weights);
// Emit event
emit ScorecardRatified(scorecard.id);
}
// In Delegate contract:
function setTierCashOutWeightsTo(DefifaTierCashOutWeight[] calldata weights) external {
// Only governor can set weights
require(msg.sender == governor, "Only governor");
// Only during SCORING phase
require(currentPhase() == GamePhase.SCORING, "Wrong phase");
// Verify weights sum to 100%
uint256 total;
for (uint256 i; i < weights.length; i++) {
tierCashOutWeight[weights[i].id] = weights[i].cashOutWeight;
total += weights[i].cashOutWeight;
}
require(total == TOTAL_CASH_OUT_WEIGHT, "Invalid total");
// Transition to COMPLETE phase
_setPhase(GamePhase.COMPLETE);
}
NFT holders vote with power proportional to their holdings:
// Voting power = (tokens owned in tier / total minted in tier) * MAX_POWER_PER_TIER
function getAttestationPowerOf(address account, uint256[] calldata tierIds)
public view returns (uint256 power)
{
for (uint256 i; i < tierIds.length; i++) {
uint256 tierId = tierIds[i];
uint256 owned = balanceOfTier(account, tierId);
uint256 totalMinted = STORE.tier(tierId).initialSupply - STORE.tier(tierId).remainingSupply;
if (totalMinted > 0) {
power += (owned * MAX_ATTESTATION_POWER_TIER) / totalMinted;
}
}
}
See defifa-collection-deployer-v5 for complete implementation including:
DefifaDelegate.sol - Extended 721-hook with all patterns aboveDefifaGovernor.sol - On-chain voting with tier-weighted powerDefifaDeployer.sol - Factory for deploying gamesREVDeployer owns Juicebox projects (revnets), enabling autonomous governance.
deployFor()
→ Calculate next projectId via PROJECTS.count() + 1
→ CONTROLLER.launchProjectFor()
→ Creates project, transfers NFT to REVDeployer
→ _deployRevnetFor()
→ Deploy ERC-20 token
→ Configure buyback hook with pools
→ Set up split operator permissions
→ Deploy suckers for cross-chain
REVDeployer grants permissions to designated operators:
function _setSplitOperatorOf(uint256 revnetId, address operator) internal {
uint256[] memory permissions = _splitOperatorPermissionIndexesOf(revnetId);
_setPermissionsFor(
operator,
revnetId,
permissions
);
}
Default Split Operator Permissions (6 total):
Plus any custom permissions in _extraOperatorPermissions[revnetId].
Stages define temporal revenue phases:
struct REVStageConfig {
uint40 startsAtOrAfter; // Stage start time
uint16 splitPercent; // Operator split %
uint16 initialIssuance; // Starting weight
uint40 issuanceCutFrequency; // How often weight cuts
uint16 issuanceCutPercent; // Weight cut amount
uint16 cashOutTaxRate; // Cash out penalty
}
Validation Rules:
Pre-mint tokens to beneficiaries after stage starts:
function autoIssueFor(uint256 revnetId, uint256 stageId, address beneficiary) external {
uint256 amount = amountToAutoIssue[revnetId][stageId][beneficiary];
amountToAutoIssue[revnetId][stageId][beneficiary] = 0;
CONTROLLER.mintTokensOf(revnetId, amount, beneficiary, "", true);
}
Borrowers provide revnet tokens as collateral:
borrowable = cashOutValue(collateral) - existingDebt
Where cashOutValue considers surplus, tax rate, and total supply.
uint256 constant MIN_PREPAID_FEE_PERCENT = 25; // 2.5% minimum
uint256 constant MAX_PREPAID_FEE_PERCENT = 500; // 50% maximum
uint256 constant REV_PREPAID_FEE_PERCENT = 10; // 1% to REV
Higher prepaid fees = longer interest-free periods:
prepaidDuration = (prepaidFeePercent / MAX_PREPAID_FEE_PERCENT) × LOAN_LIQUIDATION_DURATION
Linear interpolation from 0% to 100% over remaining loan duration:
timeSincePrepaid = block.timestamp - (loan.createdAt + prepaidDuration);
remainingDuration = LOAN_LIQUIDATION_DURATION - prepaidDuration;
feePercent = (timeSincePrepaid × 100%) / remainingDuration;
After 10 years (3,650 days):
liquidateExpiredLoansFrom()Shared utility for Juicebox-aware ownership. Used by JB721TiersHook, custom hooks, and any contract needing project-based access control.
Project-Based:
function owner() public view returns (address) {
if (jbOwner.projectId != 0) {
return PROJECTS.ownerOf(jbOwner.projectId);
}
return jbOwner.owner;
}
EOA-Based: Direct address stored in jbOwner.owner.
Project-based ownership enables delegation:
// Owner can grant permissions via JBPermissions
PERMISSIONS.setPermissionsFor(
operator,
jbOwner.projectId,
[jbOwner.permissionId]
);
// Operator can now call onlyOwner functions
mintFrom(posts[])
→ For each post:
→ Validate against allowance (min/max price, supply)
→ Check caller in allowlist
→ Create or reuse tier via hook.adjustTiers()
→ Accumulate total price
→ Pay primary project (value - fee)
→ Pay fee project (5% via FEE_DIVISOR = 20)
IPFS URIs map to tier IDs:
if (tierIdOfEncodedIPFSUri[encodedUri] != 0) {
// Mint from existing tier
} else {
// Create new tier, store mapping
tierIdOfEncodedIPFSUri[encodedUri] = newTierId;
}
Packed configuration per project:
Bits 0-103: minPrice
Bits 104-135: minTotalSupply
Bits 136-167: maxTotalSupply
+ Address[] allowlist
Bidirectional cross-chain bridge using merkle trees.
Outbox (local → remote):
prepare() callstoRemote()Inbox (remote → local):
fromRemote()Phase 1: prepare()
function prepare(uint256 projectTokenCount, address beneficiary, ...) external {
// 1. Transfer tokens to sucker
TOKENS.transferCreditsFrom(msg.sender, address(this), projectId, projectTokenCount);
// 2. Cash out for backing assets
terminal.cashOutTokensOf(projectId, projectTokenCount, ...);
// 3. Insert into outbox tree
_insertIntoTree(
keccak256(abi.encode(projectTokenCount, terminalTokenAmount, beneficiary)),
OUTBOX
);
}
Phase 2: toRemote()
function toRemote(JBRemoteToken[] remoteTokens) external {
for each token:
// Validate mapping and minimum amount
// Send root + balance to remote peer
_sendRootOverAMB(peer, root, balance, minGas);
// Clear outbox
outboxOf[token].balance = 0;
outboxOf[token].nonce++;
}
function claim(JBClaim[] claims) external {
for each claim:
// 1. Validate merkle proof against inbox root
_validate(leaf, proof, INBOX);
// 2. Mark leaf as executed (prevent replay)
_executedOf[token][leafIndex] = true;
// 3. Mint project tokens
CONTROLLER.mintTokensOf(projectId, tokenCount, beneficiary, ...);
// 4. Optionally add backing to balance
if (addToBalanceMode == ON_CLAIM) {
terminal.addToBalanceOf(projectId, terminalAmount, ...);
}
}
When bridge fails, users can reclaim from outbox:
function emergencyExit(JBClaim[] claims, address token) external {
// Must be enabled for this token
require(emergencyHatchEnabledFor[token]);
// Validate against OUTBOX tree (not inbox)
_validateForEmergencyExit(leaf, proof);
// Return terminal tokens directly
terminal.pay(projectId, terminalAmount, beneficiary, ...);
}
Critical: Once emergency hatch is enabled, it cannot be disabled.
Three-stage sunset:
Minimum delay enforced between stages for message propagation.
function mapToken(JBTokenMapping mapping) external {
// Cannot remap if outbox has entries
if (outboxOf[token].tree.count > 0) revert;
// Native tokens can only map to native or zero
if (token == JBConstants.NATIVE_TOKEN) {
require(remoteToken == NATIVE_TOKEN || remoteToken == address(0));
}
}
Use REVDeployer/Contract Owner When:
Use EOA Owner When: