@bananapus/721-hook-v6 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
- # Bananapus NFT Hook
1
+ # Juicebox 721 Hook
2
2
 
3
3
  `nana-721-hook` is:
4
4
 
5
5
  1. A pay hook for Juicebox projects to sell tiered NFTs (ERC-721s) with different prices and artwork.
6
- 2. (Optionally) a redeem hook which allows holders to burn their NFTs to reclaim funds from the project, in proportion to the NFT's price.
6
+ 2. (Optionally) a cash out hook which allows holders to burn their NFTs to reclaim funds from the project, in proportion to the NFT's price.
7
7
 
8
8
  <details>
9
9
  <summary>Table of Contents</summary>
@@ -156,15 +156,15 @@ nana-721-hook/
156
156
  │ └── helpers/
157
157
  │ └── Hook721DeploymentLib.sol - Internal helpers for deployment scripts.
158
158
  ├── src/ - Contract source code. Top level contains implementation contracts.
159
- │ ├── JB721TiersHook.sol - The core tiered NFT pay/redeem hook.
159
+ │ ├── JB721TiersHook.sol - The core tiered NFT pay/cash out hook.
160
160
  │ ├── JB721TiersHookDeployer.sol - Deploys an NFT hook for a project.
161
161
  │ ├── JB721TiersHookProjectDeployer.sol - Deploys a project with a tiered NFT hook.
162
162
  │ ├── JB721TiersHookStore.sol - Stores and manages data for tiered NFT hooks.
163
163
  │ ├── abstract/
164
- │ │ ├── ERC721.sol - Abstract ERC-721 implementation.
165
- │ │ └── JB721Hook.sol - Abstract NFT hook implementation.
164
+ │ │ ├── JB721Hook.sol - Abstract base hook: handles pay/cash out lifecycle, metadata, and terminal validation.
165
+ │ │ └── ERC721.sol - Clone-compatible abstract ERC-721 implementation.
166
166
  │ ├── interfaces/ - Contract interfaces.
167
- │ ├── libraries/ - Libraries.
167
+ │ ├── libraries/ - Libraries (includes JB721TiersHookLib for tier adjustments, split distribution, price normalization, and token URI resolution).
168
168
  │ └── structs/ - Structs.
169
169
  └── test/ - Forge tests and testing utilities.
170
170
  ├── E2E/
@@ -190,9 +190,9 @@ graph TD;
190
190
  D[JB721TiersHookDeployer] -->|Adds NFT hooks to| B
191
191
  A -->|Deploys| C[JB721TiersHook]
192
192
  D -->|Deploys| C
193
- B -->|Calls upon pay/redeem| C
193
+ B -->|Calls upon pay/cash out| C
194
194
  C -->|Stores data in| E[JB721TiersHookStore]
195
- B -->|Uses| F[Pay/redeem terminal]
195
+ B -->|Uses| F[Pay/cash out terminal]
196
196
  C -->|Mints NFTs upon payment through| F
197
197
  C -->|Burns NFTs to reclaim funds through| F
198
198
  ```
@@ -201,7 +201,8 @@ graph TD;
201
201
 
202
202
  | Contract | Description |
203
203
  | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
204
- | [`JB721TiersHook.sol`](https://github.com/Bananapus/nana-721-hook/blob/main/src/JB721TiersHook.sol) | The core tiered NFT pay/redeem hook implementation. |
204
+ | [`JB721Hook.sol`](https://github.com/Bananapus/nana-721-hook/blob/main/src/abstract/JB721Hook.sol) | Abstract base for 721 hooks: handles pay/cash out lifecycle, terminal validation, and metadata resolution. |
205
+ | [`JB721TiersHook.sol`](https://github.com/Bananapus/nana-721-hook/blob/main/src/JB721TiersHook.sol) | The core tiered NFT pay/cash out hook implementation, extending `JB721Hook`. |
205
206
  | [`JB721TiersHookDeployer.sol`](https://github.com/Bananapus/nana-721-hook/blob/main/src/JB721TiersHookDeployer.sol) | Exposes a `deployHookFor(…)` function which allows deploys an NFT hook for a project. |
206
207
  | [`JB721TiersHookProjectDeployer.sol`](https://github.com/Bananapus/nana-721-hook/blob/main/src/JB721TiersHookProjectDeployer.sol) | Exposes a `launchProjectFor(…)` function which deploys a project with a tiered NFT hook already set up. |
207
208
  | [`JB721TiersHookStore.sol`](https://github.com/Bananapus/nana-721-hook/blob/main/src/JB721TiersHookStore.sol) | Stores and manages data for tiered NFT hooks. |
@@ -210,44 +211,47 @@ graph TD;
210
211
 
211
212
  ### Hooks
212
213
 
213
- This contract is a _data hook_, a _pay hook_, and a _redeem hook_. Data hooks receive information about a payment or a redemption, and put together a payload for the pay/redeem hook to execute.
214
+ This contract is a _data hook_, a _pay hook_, and a _cash out hook_. Data hooks receive information about a payment or a cash out, and put together a payload for the pay/cash out hook to execute.
214
215
 
215
- Juicebox projects can specify a data hook in their `JBRulesetMetadata`. When someone attempts to pay or redeem from the project, the project's terminal records the payment in the terminal store, passing information about the payment/redemption to the data hook in the process. The data hook responds with a list of payloads – each payload specifies the address of a pay/redeem hook, as well as some custom data and an amount of funds to send to that pay/redeem hook.
216
+ Juicebox projects can specify a data hook in their `JBRulesetMetadata`. When someone attempts to pay or cash out from the project, the project's terminal records the payment in the terminal store, passing information about the payment/cash out to the data hook in the process. The data hook responds with a list of payloads – each payload specifies the address of a pay/cash out hook, as well as some custom data and an amount of funds to send to that pay/cash out hook.
216
217
 
217
- Each pay/redeem hook can then execute custom behavior based on the custom data (and funds) they receive.
218
+ Each pay/cash out hook can then execute custom behavior based on the custom data (and funds) they receive.
218
219
 
219
220
  ### Mechanism
220
221
 
221
- A project using a 721 tiers hook can specify any number of NFT tiers.
222
+ A project using a 721 tiers hook can specify any number of NFT tiers (up to 65,535 total).
222
223
 
223
- - NFT tiers can be removed by the project owner as long as they are not locked.
224
- - NFT tiers can be added by the project owner as long as they respect the hook's `flags`. The flags specify if newly added tiers can have votes (voting units), if new tiers can have non-zero reserve frequencies, if new tiers can allow on-demand minting by the project's owner, and if the tier can be removed.
224
+ - NFT tiers can be removed by the project owner as long as they are not locked (`cannotBeRemoved`). After removing tiers, call `cleanTiers()` on the store to optimize tier iteration.
225
+ - NFT tiers can be added by the project owner as long as they respect the hook's `flags`. The flags specify if newly added tiers can have votes (voting units), if new tiers can have non-zero reserve frequencies, if new tiers can allow on-demand minting by the project's owner, and if overspending is allowed.
225
226
 
226
- Each tier has the following optional properties:
227
+ Each tier has the following properties:
227
228
 
228
- - A price.
229
- - A supply (the maximum number of NFTs which can be minted from the tier).
229
+ - A price (up to `uint104`).
230
+ - A supply (the maximum number of NFTs which can be minted from the tier, up to 999,999,999).
230
231
  - A token URI (artwork and metadata), which can be overridden by a URI resolver. The URI resolver can return unique values for each NFT in the tier.
231
232
  - A category, so tiers can be organized and accessed for different purposes.
232
- - A reserve frequency (optional). With a reserve frequency of 5, an extra NFT will be minted to a pre-specified beneficiary address for every 5 NFTs purchased and minted from the tier.
233
- - A number of votes each NFT should represent on-chain (optional).
233
+ - A discount percent (optional). Reduces the effective purchase price. The discount is out of 200, so a `discountPercent` of 100 means 50% off, and 200 means free. The discount can be changed later via `setDiscountPercentOf`, and tiers can be configured with `cannotIncreaseDiscountPercent` to only allow discounts to decrease. Cash out weight is always based on the original tier price, not the discounted price.
234
+ - A reserve frequency (optional). With a reserve frequency of 5, an extra NFT will be minted to a pre-specified beneficiary address for every 5 NFTs purchased and minted from the tier. Tiers with owner minting enabled cannot have reserves.
235
+ - Voting units (optional). By default, each NFT's voting power equals its tier price. If `useVotingUnits` is true, a custom `votingUnits` value is used instead.
234
236
  - A flag to specify whether the NFTs in the tier can always be transferred, or if transfers can be paused depending on the project's ruleset.
235
237
  - A flag to specify whether the contract's owner can mint NFTs from the tier on-demand.
236
- - A set of flags which restrict tiers added in the future (the votes/reserved frequency/on-demand minting/can be removed flags noted above).
238
+ - A split percent and a set of splits (optional). Each tier can route a percentage of its mint price to configured split recipients (other projects, addresses, etc.) every time an NFT from the tier is purchased. The remaining funds stay in the project's balance. The `splitPercent` is out of `JBConstants.SPLITS_TOTAL_PERCENT` (1,000,000,000).
239
+ - A set of flags which restrict tiers added in the future (the votes/reserved frequency/on-demand minting/overspending flags noted above).
237
240
 
238
241
  Additional notes:
239
242
 
240
- - A payer can specify any number of tiers to mint as long as the total price does not exceed the amount being paid. If tiers aren't specified, their payment mints the most expensive tier possible, unless they specify that the hook should not mint any NFTs.
241
- - If the payment and a tier's price are specified in different currencies, the `JBPrices` contract is used to normalize the values.
242
- - If some of a payment does not go towards purchasing an NFT, those extra funds will be stored as "NFT credits" which can be used for future purchases. Optionally, the hook can disallow credits and reject payments with leftover funds.
243
- - If enabled by the project owner, holders can burn their NFTs to reclaim funds from the project. These redemptions are proportional to the NFTs price, relative to the combined price of all the NFTs.
244
- - NFT redemptions can be enabled by setting `useDataHookForRedeem` to `true` in the project's `JBRulesetMetadata`. If NFT redemptions are enabled, project token redemptions are disabled.
245
- - The hook's deployer can choose if the NFTs should support on-chain voting (as `ERC721Votes`). This increases the gas fees to interact with the NFTs, and should be disabled if not needed.
243
+ - A payer can specify any number of tiers to mint as long as the total price does not exceed the amount being paid. If tiers aren't specified, the leftover amount is stored as pay credits (if allowed).
244
+ - If the payment and a tier's price are specified in different currencies, the `JBPrices` contract is used to normalize the values. If no `JBPrices` contract is set and the currencies differ, the payment is silently ignored (no mint, no revert).
245
+ - If some of a payment does not go towards purchasing an NFT, those extra funds will be stored as "NFT credits" which can be used for future purchases. Credits are only combined with the payment when `payer == beneficiary`. Optionally, the hook can disallow credits and reject payments with leftover funds (via `preventOverspending`).
246
+ - If enabled by the project owner, holders can burn their NFTs to reclaim funds from the project. These cash outs are proportional to the NFTs price, relative to the combined price of all the NFTs (including pending reserves in the denominator).
247
+ - NFT cash outs can be enabled by setting `useDataHookForCashOut` to `true` in the project's `JBRulesetMetadata`. If NFT cash outs are enabled, project token cash outs are disabled -- attempting to cash out fungible tokens when the data hook is active will revert.
248
+ - Per-tier voting units can be configured: either custom voting units or the tier's price as the default. Voting power is computed per-address across all tiers.
249
+ - The hook declares support for ERC-2981 (royalties) via `supportsInterface`, but does not implement the `royaltyInfo` function. This is intended for future extension.
246
250
 
247
251
  ### Setup
248
252
 
249
253
  To use a 721 tiers hook, a Juicebox project should be created by a `JB721TiersHookProjectDeployer` instead of a `JBController`. The deployer will create a `JB721TiersHook` (through an associated `JB721TiersHookDeployer`) and add it to the project's first ruleset. New rulesets can be queued with `JB721TiersHookProjectDeployer.queueRulesetsOf(…)` if the project's owner gives the project deployer the permission [`JBPermissions.QUEUE_RULESETS`](https://github.com/Bananapus/nana-permission-ids/blob/master/src/JBPermissionIds.sol) (ID `2`) in [`JBPermissions`](https://github.com/Bananapus/nana-core/blob/main/src/JBPermissions.sol).
250
254
 
251
- It's also possible to add a 721 tiers hook to an existing project by calling `JB721TiersHookDeployer.deployHookFor(…)` and adding the hook to the project's ruleset – specifically, the project must set their [`JBRulesetMetadata.dataHook`](https://github.com/Bananapus/nana-core/blob/main/src/structs/JBRulesetMetadata.sol) to the newly deployed hook, and enable `JBRulesetMetadata.useDataHookForPay` and/or `JBRulesetMetadata.useDataHookForRedeem` depending on the functionality they'd like to enable.
255
+ It's also possible to add a 721 tiers hook to an existing project by calling `JB721TiersHookDeployer.deployHookFor(…)` and adding the hook to the project's ruleset – specifically, the project must set their [`JBRulesetMetadata.dataHook`](https://github.com/Bananapus/nana-core/blob/main/src/structs/JBRulesetMetadata.sol) to the newly deployed hook, and enable `JBRulesetMetadata.useDataHookForPay` and/or `JBRulesetMetadata.useDataHookForCashOut` depending on the functionality they'd like to enable.
252
256
 
253
257
  All `JB721TiersHook`s store their data in the `JB721TiersHookStore` contract.
package/SKILLS.md CHANGED
@@ -1,4 +1,4 @@
1
- # nana-721-hook-v5
1
+ # Juicebox 721 Hook
2
2
 
3
3
  ## Purpose
4
4
 
@@ -8,76 +8,166 @@ Tiered ERC-721 NFT hook for Juicebox V6 that mints NFTs when a project is paid a
8
8
 
9
9
  | Contract | Role |
10
10
  |----------|------|
11
- | `JB721TiersHook` | Core hook: processes payments (mints NFTs), processes cash outs (burns NFTs), manages tiers, reserves, credits, metadata, and discount percents. Deployed as minimal clones. |
11
+ | `JB721Hook` (abstract) | Abstract base hook: owns `DIRECTORY`, `METADATA_ID_TARGET`, `PROJECT_ID`. Implements `afterPayRecordedWith` (terminal validation + delegates to virtual `_processPayment`), `afterCashOutRecordedWith` (terminal validation, burn loop, delegates to virtual `_didBurn`), `beforeCashOutRecordedWith` (metadata decoding, delegates to virtual `cashOutWeightOf`/`totalCashOutWeight`), `beforePayRecordedWith` (default: forward weight), `hasMintPermissionFor` (returns false), `supportsInterface`, and `_initialize`. |
12
+ | `JB721TiersHook` | Core hook: extends `JB721Hook`. Manages tiers, reserves, credits, metadata, and discount percents. Deployed as minimal clones. Inherits `JBOwnable`, `ERC2771Context`, `JB721Hook`, `IJB721TiersHook`. Overrides `cashOutWeightOf`, `totalCashOutWeight`, `_didBurn`, `_processPayment`, and `beforePayRecordedWith` (adds tier split calculation). |
12
13
  | `JB721TiersHookStore` | Shared singleton storage for all hook instances. Stores tiers (`JBStored721Tier`), balances, reserves, bitmaps for removed tiers, flags, and token URI resolvers. |
13
14
  | `JB721TiersHookDeployer` | Factory: clones `JB721TiersHook` via `LibClone.clone` / `cloneDeterministic`, initializes, registers in address registry. |
14
- | `JB721TiersHookProjectDeployer` | Convenience deployer: creates a Juicebox project + hook in one transaction. Wires the hook as the data hook with `useDataHookForPay: true`. |
15
- | `JB721Hook` (abstract) | Base: implements `IJBRulesetDataHook` + `IJBPayHook` + `IJBCashOutHook`. Validates caller is a project terminal. |
15
+ | `JB721TiersHookProjectDeployer` | Convenience deployer: creates a Juicebox project + hook in one transaction. Also supports `launchRulesetsFor` and `queueRulesetsOf`. Wires the hook as the data hook with `useDataHookForPay: true`. |
16
+ | `JB721TiersHookLib` (library) | External library called via DELEGATECALL from the hook. Handles tier adjustments (`adjustTiersFor`), split amount calculation (`calculateSplitAmounts`), split fund distribution (`distributeAll`), price normalization (`normalizePaymentValue`), and token URI resolution (`resolveTokenURI`). Extracted to stay within EIP-170 contract size limit. |
17
+ | `IJB721Hook` (interface) | Interface for `JB721Hook`: extends `IJBRulesetDataHook`, `IJBPayHook`, `IJBCashOutHook`. Declares `DIRECTORY()`, `METADATA_ID_TARGET()`, `PROJECT_ID()`. |
16
18
  | `ERC721` (abstract) | Clone-compatible ERC-721 with `_initialize(name, symbol)` instead of constructor args. |
17
19
 
18
20
  ## Key Functions
19
21
 
20
22
  | Function | Contract | What it does |
21
23
  |----------|----------|--------------|
22
- | `initialize(projectId, name, symbol, baseUri, tokenUriResolver, contractUri, tiersConfig, flags)` | `JB721TiersHook` | One-time setup for a cloned hook instance. Stores pricing context (currency, decimals, prices contract packed into uint256), records tiers and flags in the store. |
23
- | `afterPayRecordedWith(context)` | `JB721Hook` | Called by terminal after payment. Validates caller is a project terminal, delegates to `_processPayment`. |
24
- | `_processPayment(context)` | `JB721TiersHook` | Normalizes payment value via pricing context, decodes payer metadata for tier IDs to mint, calls `_mintAll`, manages pay credits for overspending. |
25
- | `afterCashOutRecordedWith(context)` | `JB721Hook` | Called by terminal during cash out. Decodes token IDs from metadata, validates ownership, burns NFTs, calls `_didBurn`. |
26
- | `beforePayRecordedWith(context)` | `JB721Hook` | Data hook: returns original weight and sets this contract as the pay hook (amount=0). |
27
- | `beforeCashOutRecordedWith(context)` | `JB721Hook` | Data hook: calculates `cashOutCount` (weight of NFTs being cashed out) and `totalSupply` (total weight of all NFTs). Rejects if fungible tokens are also being cashed out. |
28
- | `adjustTiers(tiersToAdd, tierIdsToRemove)` | `JB721TiersHook` | Owner-only. Adds/removes tiers via the store. Requires `ADJUST_721_TIERS` permission. |
24
+ | `initialize(projectId, name, symbol, baseUri, tokenUriResolver, contractUri, tiersConfig, flags)` | `JB721TiersHook` | One-time setup for a cloned hook instance. Stores pricing context (currency, decimals, prices contract packed into uint256), records tiers and flags in the store. Validates `decimals <= 18`. |
25
+ | `afterPayRecordedWith(context)` | `JB721Hook` | Called by terminal after payment. Validates caller is a project terminal, delegates to virtual `_processPayment`. |
26
+ | `_processPayment(context)` | `JB721TiersHook` | Normalizes payment value via pricing context, decodes payer metadata for tier IDs to mint, calls `_mintAll`, manages pay credits for overspending. Distributes tier split funds via `JB721TiersHookLib.distributeAll` if split amounts were forwarded. |
27
+ | `afterCashOutRecordedWith(context)` | `JB721Hook` | Called by terminal during cash out. Decodes token IDs from metadata, validates ownership, burns NFTs, delegates to virtual `_didBurn`. Reverts if `msg.value != 0`. |
28
+ | `beforePayRecordedWith(context)` | `JB721TiersHook` | Data hook: returns original weight, calculates per-tier split amounts via `JB721TiersHookLib.calculateSplitAmounts`, and sets this contract as the pay hook with the total split amount forwarded. |
29
+ | `beforeCashOutRecordedWith(context)` | `JB721Hook` | Data hook: calculates `cashOutCount` (via virtual `cashOutWeightOf`) and `totalSupply` (via virtual `totalCashOutWeight`). Rejects if fungible tokens are also being cashed out. |
30
+ | `adjustTiers(tiersToAdd, tierIdsToRemove)` | `JB721TiersHook` | Owner-only. Adds/removes tiers via `JB721TiersHookLib.adjustTiersFor` (DELEGATECALL). Requires `ADJUST_721_TIERS` permission. Registers tier splits in `JBSplits` if configured. |
29
31
  | `mintFor(tierIds, beneficiary)` | `JB721TiersHook` | Owner-only manual mint. Requires `MINT_721` permission. Passes `amount: type(uint256).max` and `isOwnerMint: true` to force the mint. |
30
32
  | `mintPendingReservesFor(tierId, count)` | `JB721TiersHook` | Public. Mints pending reserve NFTs for a tier to the tier's `reserveBeneficiary`. Checks ruleset metadata for `mintPendingReservesPaused`. |
31
- | `setMetadata(baseUri, contractUri, tokenUriResolver, encodedIPFSTUriTierId, encodedIPFSUri)` | `JB721TiersHook` | Owner-only. Updates base URI, contract URI, token URI resolver, or per-tier encoded IPFS URI. Requires `SET_721_METADATA` permission. |
33
+ | `mintPendingReservesFor(configs[])` | `JB721TiersHook` | Batch variant. Calls `mintPendingReservesFor(tierId, count)` for each config. |
34
+ | `setMetadata(baseUri, contractUri, tokenUriResolver, encodedIPFSUriTierId, encodedIPFSUri)` | `JB721TiersHook` | Owner-only. Updates base URI, contract URI, token URI resolver, or per-tier encoded IPFS URI. Requires `SET_721_METADATA` permission. |
32
35
  | `setDiscountPercentOf(tierId, discountPercent)` | `JB721TiersHook` | Owner-only. Sets discount percent for a tier. Requires `SET_721_DISCOUNT_PERCENT` permission. |
36
+ | `setDiscountPercentsOf(configs[])` | `JB721TiersHook` | Batch variant. Sets discount percent for multiple tiers. Requires `SET_721_DISCOUNT_PERCENT` permission. |
37
+ | `tokenURI(tokenId)` | `JB721TiersHook` | Resolves token metadata URI. Delegates to `JB721TiersHookLib.resolveTokenURI`, which checks for a custom `tokenUriResolver` first, then falls back to IPFS decoding via `JBIpfsDecoder`. |
38
+ | `firstOwnerOf(tokenId)` | `JB721TiersHook` | Returns the first owner of an NFT (the address that originally received it). Stored on first transfer out; returns current owner if never transferred. |
39
+ | `pricingContext()` | `JB721TiersHook` | Unpacks and returns the currency, decimals, and prices contract from the packed `_packedPricingContext`. |
40
+ | `balanceOf(owner)` | `JB721TiersHook` | Overrides ERC-721 `balanceOf` to delegate to `STORE.balanceOf`, which sums across all tiers. |
41
+ | `hasMintPermissionFor(...)` | `JB721Hook` | Always returns `false`. Required by `IJBRulesetDataHook`; prevents the hook from granting mint permissions to anyone. |
42
+ | `supportsInterface(interfaceId)` | `JB721TiersHook` | Returns `true` for `IJB721TiersHook`, `IJBRulesetDataHook`, `IJBPayHook`, `IJBCashOutHook`, `IERC2981`, `IERC721`, `IERC721Metadata`, `IERC165`. |
33
43
  | `deployHookFor(projectId, config, salt)` | `JB721TiersHookDeployer` | Clones the hook implementation, initializes it, transfers ownership to caller, registers in address registry. |
34
44
  | `launchProjectFor(owner, deployConfig, launchConfig, controller, salt)` | `JB721TiersHookProjectDeployer` | Creates project via controller, deploys hook, wires hook as data hook with `useDataHookForPay: true`, transfers hook ownership to project. |
35
- | `recordMint(amount, tierIds, isOwnerMint)` | `JB721TiersHookStore` | Records minting: validates supply, checks tier prices against amount (unless owner mint), generates token IDs (tierId * 1_000_000_000 + mintCount), tracks reserves. |
36
- | `recordAddTiers(tiers)` | `JB721TiersHookStore` | Adds new tiers sorted by category. Validates against hook flags (no new reserves/votes/owner-minting if flagged). |
37
- | `recordRemoveTierIds(tierIds)` | `JB721TiersHookStore` | Marks tiers as removed in the bitmap. Validates tier is not locked (`cannotBeRemoved`). |
45
+ | `launchRulesetsFor(projectId, deployConfig, launchRulesetsConfig, controller, salt)` | `JB721TiersHookProjectDeployer` | Deploys a hook for an existing project and launches rulesets. Requires `QUEUE_RULESETS` and `SET_TERMINALS` permissions. Transfers hook ownership to project. |
46
+ | `queueRulesetsOf(projectId, deployConfig, queueRulesetsConfig, controller, salt)` | `JB721TiersHookProjectDeployer` | Deploys a hook and queues rulesets for an existing project. Requires `QUEUE_RULESETS` permission. Transfers hook ownership to project. |
47
+ | `recordMint(amount, tierIds, isOwnerMint)` | `JB721TiersHookStore` | Records minting: validates supply, checks tier prices against amount (unless owner mint), applies discount if set, generates token IDs (`tierId * 1_000_000_000 + mintCount`), ensures remaining supply covers pending reserves. |
48
+ | `recordAddTiers(tiers)` | `JB721TiersHookStore` | Adds new tiers sorted by category. Validates against hook flags (no new reserves/votes/owner-minting if flagged). Enforces max tier count (`type(uint16).max`), max supply per tier (`999_999_999`), discount percent bounds, non-zero supply, category sort order, and reserve+owner-mint mutual exclusion. |
49
+ | `recordRemoveTierIds(tierIds)` | `JB721TiersHookStore` | Marks tiers as removed in the bitmap. Validates tier is not locked (`cannotBeRemoved`). Does NOT update the sorted linked list -- call `cleanTiers()` afterward. |
50
+ | `recordMintReservesFor(tierId, count)` | `JB721TiersHookStore` | Mints reserve NFTs from remaining supply. Validates count does not exceed pending reserves. |
51
+ | `recordSetDiscountPercentOf(tierId, discountPercent)` | `JB721TiersHookStore` | Sets discount percent for a tier. Validates bounds (`<= DISCOUNT_DENOMINATOR`). If `cannotIncreaseDiscountPercent` is set, rejects increases. |
52
+ | `recordBurn(tokenIds)` | `JB721TiersHookStore` | Increments burn counter per tier. Trusts `msg.sender` (the hook) to have already verified ownership and burned the tokens. |
53
+ | `cleanTiers(hook)` | `JB721TiersHookStore` | Public. Removes stale entries from the sorted tier linked list after tiers have been removed via `recordRemoveTierIds`. Optimizes tier iteration. |
54
+ | `tiersOf(hook, categories, includeResolvedUri, startingId, size)` | `JB721TiersHookStore` | Returns an array of active tiers, optionally filtered by categories. Skips removed tiers. |
55
+ | `tierOf(hook, id, includeResolvedUri)` | `JB721TiersHookStore` | Returns a single tier by ID. |
56
+ | `tierOfTokenId(hook, tokenId, includeResolvedUri)` | `JB721TiersHookStore` | Returns the tier for a given token ID. |
57
+ | `totalSupplyOf(hook)` | `JB721TiersHookStore` | Returns total NFTs minted across all tiers (excluding burns). |
58
+ | `totalCashOutWeight(hook)` | `JB721TiersHookStore` | Returns total cash out weight (sum of `price * (minted + pendingReserves)` for all tiers). Uses original price, not discounted price. |
59
+ | `cashOutWeightOf(hook, tokenIds)` | `JB721TiersHookStore` | Returns combined cash out weight for specific token IDs. Uses original tier price, not discounted. |
60
+ | `votingUnitsOf(hook, account)` | `JB721TiersHookStore` | Returns total voting units for an address across all tiers. Uses custom `votingUnits` if `useVotingUnits` is set, otherwise uses tier price. |
61
+ | `tierVotingUnitsOf(hook, account, tierId)` | `JB721TiersHookStore` | Returns voting units for an address within a specific tier. |
62
+ | `calculateSplitAmounts(store, hook, metadataIdTarget, metadata)` | `JB721TiersHookLib` | Called in `beforePayRecordedWith`. Decodes tier IDs from payer metadata, looks up each tier's `splitPercent`, calculates `mulDiv(price, splitPercent, SPLITS_TOTAL_PERCENT)` per tier, returns `totalSplitAmount` (forwarded to hook as `amount`) and encoded `hookMetadata` (tier IDs + amounts). |
63
+ | `distributeAll(directory, projectId, hookAddress, token, encodedSplitData)` | `JB721TiersHookLib` | Called in `afterPayRecordedWith`. Decodes per-tier amounts, looks up each tier's splits from `JBSplits` by group ID (`hookAddress | (tierId << 160)`), distributes to split recipients. Leftover goes to project balance via `addToBalance`. |
64
+ | `adjustTiersFor(store, directory, projectId, hookAddress, caller, tiersToAdd, tierIdsToRemove)` | `JB721TiersHookLib` | Called via DELEGATECALL from `adjustTiers`. Removes tiers, adds tiers, emits events, and registers any configured splits in `JBSplits` via the project's controller. |
65
+ | `normalizePaymentValue(packedPricingContext, projectId, amountValue, amountCurrency, amountDecimals)` | `JB721TiersHookLib` | Converts a payment value to the hook's pricing currency using `JBPrices`. Returns `(0, false)` if currencies differ and no prices contract is set. |
66
+ | `resolveTokenURI(store, hook, baseUri, tokenId)` | `JB721TiersHookLib` | Resolves token URI: checks for custom `tokenUriResolver` first, otherwise decodes IPFS URI via `JBIpfsDecoder`. |
38
67
 
39
68
  ## Integration Points
40
69
 
41
70
  | Dependency | Import | Used For |
42
71
  |------------|--------|----------|
43
- | `@bananapus/core-v6` | `IJBDirectory`, `IJBRulesets`, `IJBPrices`, `IJBController`, `IJBTerminal`, `JBRuleset`, `JBRulesetMetadata`, `JBAfterPayRecordedContext`, `JBBeforeCashOutRecordedContext`, etc. | Terminal validation, ruleset metadata, pricing, payment/cash-out contexts |
72
+ | `@bananapus/core-v6` | `IJBDirectory`, `IJBRulesets`, `IJBPrices`, `IJBController`, `IJBTerminal`, `IJBSplits`, `JBRuleset`, `JBRulesetMetadata`, `JBAfterPayRecordedContext`, `JBBeforeCashOutRecordedContext`, `JBSplit`, `JBSplitGroup`, `JBConstants`, etc. | Terminal validation, ruleset metadata, pricing, payment/cash-out contexts, splits |
44
73
  | `@bananapus/ownable-v6` | `JBOwnable` | Project-based ownership for the hook (ownership can be transferred to a project NFT) |
45
74
  | `@bananapus/permission-ids-v6` | `JBPermissionIds` | Permission IDs: `ADJUST_721_TIERS`, `MINT_721`, `SET_721_METADATA`, `SET_721_DISCOUNT_PERCENT`, `QUEUE_RULESETS`, `SET_TERMINALS` |
46
75
  | `@bananapus/address-registry-v6` | `IJBAddressRegistry` | Registering deployed hook clones |
47
- | `@openzeppelin/contracts` | `ERC2771Context`, `IERC165`, `IERC2981`, `IERC721` | Meta-transactions (trusted forwarder), interface detection, royalty standard |
48
- | `@prb/math` | `mulDiv` | Safe fixed-point multiplication/division for price normalization |
76
+ | `@openzeppelin/contracts` | `ERC2771Context`, `IERC165`, `IERC2981`, `IERC721`, `SafeERC20` | Meta-transactions (trusted forwarder), interface detection, royalty standard declaration, safe ERC-20 transfers for split distribution |
77
+ | `@prb/math` | `mulDiv` | Safe fixed-point multiplication/division for price normalization and discount/split calculation |
49
78
  | `solady` | `LibClone` | Minimal proxy (clone) deployment for hooks |
50
79
 
51
80
  ## Key Types
52
81
 
53
82
  | Struct/Enum | Key Fields | Used In |
54
83
  |-------------|------------|---------|
55
- | `JB721TierConfig` | `uint104 price`, `uint32 initialSupply`, `uint32 votingUnits`, `uint16 reserveFrequency`, `address reserveBeneficiary`, `bytes32 encodedIPFSUri`, `uint24 category`, `uint8 discountPercent`, `bool allowOwnerMint`, `bool transfersPausable`, `bool cannotBeRemoved`, `bool cannotIncreaseDiscountPercent` | `adjustTiers`, `initialize`, `recordAddTiers` |
56
- | `JB721Tier` | Same as config plus `uint32 id`, `uint32 remainingSupply`, `string resolvedUri` | Return type from `tierOf`, `tiersOf`, `tierOfTokenId` |
57
- | `JBStored721Tier` | `uint104 price`, `uint32 remainingSupply`, `uint32 initialSupply`, `uint32 votingUnits`, `uint24 category`, `uint8 discountPercent`, `uint16 reserveFrequency`, `uint8 packedBools` | Internal storage in `JB721TiersHookStore` |
58
- | `JB721InitTiersConfig` | `JB721TierConfig[] tiers`, `uint32 currency`, `uint8 decimals`, `IJBPrices prices` | `initialize` defines tiers and pricing context |
84
+ | `JB721TierConfig` | `uint104 price`, `uint32 initialSupply`, `uint32 votingUnits`, `uint16 reserveFrequency`, `address reserveBeneficiary`, `bytes32 encodedIPFSUri`, `uint24 category`, `uint8 discountPercent`, `bool allowOwnerMint`, `bool useReserveBeneficiaryAsDefault`, `bool transfersPausable`, `bool useVotingUnits`, `bool cannotBeRemoved`, `bool cannotIncreaseDiscountPercent`, `uint32 splitPercent`, `JBSplit[] splits` | `adjustTiers`, `initialize`, `recordAddTiers` |
85
+ | `JB721Tier` | `uint32 id`, `uint104 price`, `uint32 remainingSupply`, `uint32 initialSupply`, `uint104 votingUnits`, `uint16 reserveFrequency`, `address reserveBeneficiary`, `bytes32 encodedIPFSUri`, `uint24 category`, `uint8 discountPercent`, `bool allowOwnerMint`, `bool transfersPausable`, `bool cannotBeRemoved`, `bool cannotIncreaseDiscountPercent`, `uint32 splitPercent`, `string resolvedUri` | Return type from `tierOf`, `tiersOf`, `tierOfTokenId` |
86
+ | `JBStored721Tier` | `uint104 price`, `uint32 remainingSupply`, `uint32 initialSupply`, `uint32 splitPercent`, `uint24 category`, `uint8 discountPercent`, `uint16 reserveFrequency`, `uint8 packedBools` (allowOwnerMint, transfersPausable, useVotingUnits, cannotBeRemoved, cannotIncreaseDiscountPercent) | Internal storage in `JB721TiersHookStore`. Voting units stored separately in `_tierVotingUnitsOf` when `useVotingUnits` is true. |
87
+ | `JB721InitTiersConfig` | `JB721TierConfig[] tiers`, `uint32 currency`, `uint8 decimals`, `IJBPrices prices` | `initialize` -- defines tiers and pricing context |
59
88
  | `JBDeploy721TiersHookConfig` | `string name`, `string symbol`, `string baseUri`, `IJB721TokenUriResolver tokenUriResolver`, `string contractUri`, `JB721InitTiersConfig tiersConfig`, `address reserveBeneficiary`, `JB721TiersHookFlags flags` | `deployHookFor`, `launchProjectFor` |
60
89
  | `JB721TiersHookFlags` | `bool noNewTiersWithReserves`, `bool noNewTiersWithVotes`, `bool noNewTiersWithOwnerMinting`, `bool preventOverspending` | `initialize`, `recordFlags` |
61
- | `JB721TiersRulesetMetadata` | `bool pauseTransfers`, `bool pauseMintPendingReserves` | Packed into `JBRulesetMetadata.metadata` per-ruleset |
62
- | `JBPayDataHookRulesetConfig` | `uint48 mustStartAtOrAfter`, `uint32 duration`, `uint112 weight`, `uint32 weightCutPercent`, `IJBRulesetApprovalHook approvalHook`, `JBPayDataHookRulesetMetadata metadata`, `JBSplitGroup[] splitGroups`, `JBFundAccessLimitGroup[] fundAccessLimitGroups` | `JB721TiersHookProjectDeployer` wraps core ruleset config with `useDataHookForPay: true` hardcoded |
63
- | `JBPayDataHookRulesetMetadata` | Same as `JBRulesetMetadata` minus `allowSetCustomToken` (hardcoded false) and `useDataHookForPay` (hardcoded true) and `dataHook` (set to deployed hook) | `launchProjectFor`, `launchRulesetsFor`, `queueRulesetsOf` |
90
+ | `JB721TiersRulesetMetadata` | `bool pauseTransfers`, `bool pauseMintPendingReserves` | Packed into `JBRulesetMetadata.metadata` per-ruleset (bit 0 = pauseTransfers, bit 1 = pauseMintPendingReserves) |
91
+ | `JBPayDataHookRulesetConfig` | `uint48 mustStartAtOrAfter`, `uint32 duration`, `uint112 weight`, `uint32 weightCutPercent`, `IJBRulesetApprovalHook approvalHook`, `JBPayDataHookRulesetMetadata metadata`, `JBSplitGroup[] splitGroups`, `JBFundAccessLimitGroup[] fundAccessLimitGroups` | `JB721TiersHookProjectDeployer` -- wraps core ruleset config with `useDataHookForPay: true` hardcoded |
92
+ | `JBPayDataHookRulesetMetadata` | Same as `JBRulesetMetadata` minus `allowSetCustomToken` (hardcoded false) and `useDataHookForPay` (hardcoded true) and `dataHook` (set to deployed hook). Includes `ownerMustSendPayouts`. | `launchProjectFor`, `launchRulesetsFor`, `queueRulesetsOf` |
93
+ | `JBLaunchProjectConfig` | `string projectUri`, `JBPayDataHookRulesetConfig[] rulesetConfigurations`, `JBTerminalConfig[] terminalConfigurations`, `string memo` | `launchProjectFor` |
94
+ | `JBLaunchRulesetsConfig` | `uint56 projectId`, `JBPayDataHookRulesetConfig[] rulesetConfigurations`, `JBTerminalConfig[] terminalConfigurations`, `string memo` | `launchRulesetsFor` |
95
+ | `JBQueueRulesetsConfig` | `uint56 projectId`, `JBPayDataHookRulesetConfig[] rulesetConfigurations`, `string memo` | `queueRulesetsOf` |
64
96
  | `JB721TiersMintReservesConfig` | `uint32 tierId`, `uint16 count` | `mintPendingReservesFor` batch variant |
65
97
  | `JB721TiersSetDiscountPercentConfig` | `uint32 tierId`, `uint16 discountPercent` | `setDiscountPercentsOf` batch variant |
66
98
  | `JBBitmapWord` | `uint256 currentWord`, `uint256 currentDepth` | Internal tier removal tracking in store |
67
99
 
100
+ ## Constants
101
+
102
+ | Constant | Value | Location | Meaning |
103
+ |----------|-------|----------|---------|
104
+ | `DISCOUNT_DENOMINATOR` | `200` | `JB721Constants` | Max `discountPercent` value. A `discountPercent` of 200 = 100% discount (free). A `discountPercent` of 100 = 50% off. Formula: `price -= mulDiv(price, discountPercent, 200)`. |
105
+ | `_ONE_BILLION` | `1_000_000_000` | `JB721TiersHookStore` | Used for token ID generation: `tokenId = tierId * 1_000_000_000 + tokenNumber`. Also caps max initial supply per tier at 999,999,999. |
106
+ | Max tier count | `type(uint16).max` (65,535) | `JB721TiersHookStore` | Maximum total number of tiers across all `recordAddTiers` calls for a single hook. |
107
+
108
+ ## Discount Percent
109
+
110
+ Each tier has a `discountPercent` (uint8) that reduces its effective purchase price:
111
+
112
+ - The discount is applied during `recordMint`: `price -= mulDiv(price, discountPercent, DISCOUNT_DENOMINATOR)`.
113
+ - `DISCOUNT_DENOMINATOR` is 200, so `discountPercent = 100` means 50% off, `discountPercent = 200` means free.
114
+ - Discount can be changed via `setDiscountPercentOf` / `setDiscountPercentsOf` (requires `SET_721_DISCOUNT_PERCENT` permission).
115
+ - If `cannotIncreaseDiscountPercent` is set on the tier, the discount can only be decreased or kept the same -- increases are rejected by the store.
116
+ - Cash out weight always uses the **original tier price**, not the discounted price. This prevents discount changes from retroactively altering the cash-out value of already-minted NFTs.
117
+
118
+ ## Voting Units
119
+
120
+ Each tier has configurable voting power:
121
+
122
+ - If `useVotingUnits` is `true` on the tier config, voting power per NFT is the custom `votingUnits` value (stored in `_tierVotingUnitsOf`).
123
+ - If `useVotingUnits` is `false`, voting power per NFT defaults to the tier's `price`.
124
+ - The `noNewTiersWithVotes` flag blocks adding new tiers with any voting power -- this means blocking tiers where `(useVotingUnits && votingUnits != 0)` OR `(!useVotingUnits && price != 0)`.
125
+ - Total voting units for an address are computed by `votingUnitsOf(hook, account)`, which sums `balance * votingPower` across all tiers.
126
+
127
+ ## Reserve Minting
128
+
129
+ - Reserves accumulate as NFTs are purchased: for every `reserveFrequency` non-reserve mints, one reserve NFT becomes available.
130
+ - Pending count: `ceil(numberOfNonReserveMints / reserveFrequency) - numberOfReservesMintedFor`.
131
+ - Reserves are minted to the tier's `reserveBeneficiary` (or the hook's `defaultReserveBeneficiaryOf` as fallback).
132
+ - Reserve minting is permissionless (`mintPendingReservesFor`), but can be paused per-ruleset via `pauseMintPendingReserves` in `JB721TiersRulesetMetadata`.
133
+ - Supply is protected: `recordMint` ensures remaining supply covers pending reserves after each purchase.
134
+ - Tiers with `allowOwnerMint: true` cannot have a `reserveFrequency` -- the store rejects this combination.
135
+
136
+ ## Tier Splits
137
+
138
+ - Each tier can route a percentage of its mint price to configured split recipients. The `splitPercent` field (out of `JBConstants.SPLITS_TOTAL_PERCENT` = 1,000,000,000) determines how much of the price is forwarded.
139
+ - Split recipients are stored in `JBSplits` using group IDs computed as `uint256(uint160(hookAddress)) | (uint256(tierId) << 160)`.
140
+ - Splits are registered via `JB721TiersHookLib._setSplitGroupsFor` when tiers with `splits` are added.
141
+ - In `beforePayRecordedWith`, `calculateSplitAmounts` decodes tier IDs from payer metadata, computes `mulDiv(price, splitPercent, SPLITS_TOTAL_PERCENT)` per tier, and returns the total to be forwarded to the hook.
142
+ - In `afterPayRecordedWith`, `distributeAll` distributes forwarded funds to each tier's split group recipients. Leftover after all splits goes back to the project's balance via `addToBalance`.
143
+ - Split recipients can be projects (via `terminal.pay` or `terminal.addToBalance`) or plain addresses (direct ETH transfer or `SafeERC20.safeTransfer`).
144
+
68
145
  ## Gotchas
69
146
 
70
147
  - `JB721TiersHook` is deployed as a **minimal clone** (not a full deployment). The constructor sets immutables (`RULESETS`, `STORE`, `DIRECTORY`, `METADATA_ID_TARGET`), and `initialize()` sets per-instance state. Calling `initialize()` twice reverts with `JB721TiersHook_AlreadyInitialized`.
148
+ - **`JB721Hook` abstract base**: `JB721TiersHook` extends `JB721Hook`, which handles generic 721 hook lifecycle (terminal validation, burn loop, metadata decoding). `JB721TiersHook` overrides `cashOutWeightOf`, `totalCashOutWeight`, `_didBurn`, `_processPayment`, and `beforePayRecordedWith`. Errors like `JB721Hook_InvalidPay` and `JB721Hook_InvalidCashOut` are defined on the abstract class, not `JB721TiersHook`.
71
149
  - **Pricing context is bit-packed** into a single `uint256`: currency (bits 0-31), decimals (bits 32-39), prices contract address (bits 40-199). Read it via `pricingContext()`.
150
+ - **Pricing decimals must be <= 18**: `initialize` reverts with `JB721TiersHook_InvalidPricingDecimals` otherwise.
72
151
  - **Token IDs encode tier ID**: `tokenId = tierId * 1_000_000_000 + mintNumber`. Use `STORE.tierIdOfToken(tokenId)` to extract the tier ID.
73
152
  - **Pay credits**: If a payer overpays (amount > total tier prices), the excess is stored as `payCreditsOf[beneficiary]` and can be applied to future mints. This only works when `preventOverspending` flag is `false`. Credits are only combined with payment when `payer == beneficiary`.
74
- - **Cash outs reject fungible tokens**: `beforeCashOutRecordedWith` reverts with `JB721Hook_UnexpectedTokenCashedOut` if `context.cashOutCount > 0`. NFT cash outs and fungible token cash outs are mutually exclusive.
153
+ - **Cash outs reject fungible tokens**: `beforeCashOutRecordedWith` reverts with `JB721TiersHook_UnexpectedTokenCashedOut` if `context.cashOutCount > 0`. NFT cash outs and fungible token cash outs are mutually exclusive.
154
+ - **Cash out weight uses original price**: `cashOutWeightOf` and `totalCashOutWeight` use the full tier `price`, not the discounted price. This prevents discount changes from altering the cash-out value of already-minted NFTs.
155
+ - **Pending reserves inflate totalCashOutWeight**: `totalCashOutWeight` includes pending reserves in the denominator (`price * (minted + pendingReserves)`). This dilutes cash-out value before reserves are minted, preventing early cashers from extracting more than their fair share.
75
156
  - **Reserve minting is permissionless** but governed by ruleset metadata. Anyone can call `mintPendingReservesFor` as long as `mintPendingReservesPaused` is not set in the current ruleset's metadata.
157
+ - **Reserve + owner-mint mutual exclusion**: Tiers with `allowOwnerMint: true` cannot have a `reserveFrequency`. The store rejects this combination during `recordAddTiers`.
76
158
  - `setMetadata` uses `address(this)` as the sentinel for "no change" on `tokenUriResolver` (not `address(0)`). Passing `address(0)` will clear the resolver.
77
159
  - `JBPayDataHookRulesetConfig` hardcodes `allowSetCustomToken: false` and `useDataHookForPay: true` when wiring rulesets through the project deployer.
78
160
  - The `_update` override in `JB721TiersHook` checks `tier.transfersPausable` and consults the current ruleset's metadata for `transfersPaused`. Transfers to `address(0)` (burns) are never blocked.
161
+ - **IERC2981 declared but not implemented**: `supportsInterface` returns `true` for `IERC2981`, but no `royaltyInfo` function is implemented. Callers querying `royaltyInfo` will get a revert. This appears intentional -- the interface is declared for future extension or to signal capability to marketplaces that may override behavior.
162
+ - **Tier splits**: Each tier can route a percentage of its mint price to configured split recipients. `splitPercent` is out of `JBConstants.SPLITS_TOTAL_PERCENT` (1,000,000,000). Split group IDs are `uint256(uint160(hookAddress)) | (uint256(tierId) << 160)`.
163
+ - **Removing tiers does not update the sorted list**: `recordRemoveTierIds` only marks tiers in the bitmap. Call `cleanTiers()` afterward to remove them from the iteration sequence.
79
164
  - `JB721TiersHookStore` is a **shared singleton** -- all hook instances on the same chain use the same store, keyed by `address(hook)`.
80
165
  - The `ERC721` abstract uses `_initialize(name, symbol)` instead of a constructor, making it clone-compatible. The standard `_owners` mapping is `internal` (not `private`).
166
+ - **`hasMintPermissionFor` always returns `false`**: The hook never grants mint permission to any address. This is part of the `IJBRulesetDataHook` interface.
167
+ - **Max tier count is 65,535** (`type(uint16).max`). Adding tiers beyond this limit reverts.
168
+ - **Max initial supply per tier is 999,999,999** (`_ONE_BILLION - 1`). Exceeding this would cause token ID overflow into the next tier's ID space.
169
+ - **`noNewTiersWithVotes` blocks all voting power**: It rejects tiers where voting units would be non-zero, whether from custom `votingUnits` or from a non-zero `price` (when `useVotingUnits` is false).
170
+ - **`firstOwnerOf` is lazy**: The first owner is only stored when the token is first transferred away from its original holder. Before any transfer, `firstOwnerOf` returns the current owner.
81
171
 
82
172
  ## Example Integration
83
173
 
@@ -107,7 +197,9 @@ tiers[0] = JB721TierConfig({
107
197
  transfersPausable: false,
108
198
  useVotingUnits: false,
109
199
  cannotBeRemoved: false,
110
- cannotIncreaseDiscountPercent: false
200
+ cannotIncreaseDiscountPercent: false,
201
+ splitPercent: 0,
202
+ splits: new JBSplit[](0)
111
203
  });
112
204
 
113
205
  // Deploy a project with the 721 hook attached.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,31 +1,23 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity 0.8.26;
3
3
 
4
- import {IJBCashOutHook} from "@bananapus/core-v6/src/interfaces/IJBCashOutHook.sol";
5
4
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
6
- import {IJBPayHook} from "@bananapus/core-v6/src/interfaces/IJBPayHook.sol";
7
5
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
8
6
  import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
9
7
  import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
10
8
  import {IJBRulesets} from "@bananapus/core-v6/src/interfaces/IJBRulesets.sol";
11
- import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
12
- import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
13
9
  import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
14
10
  import {JBRulesetMetadataResolver} from "@bananapus/core-v6/src/libraries/JBRulesetMetadataResolver.sol";
15
- import {JBAfterCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterCashOutRecordedContext.sol";
16
11
  import {JBAfterPayRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterPayRecordedContext.sol";
17
- import {JBBeforeCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforeCashOutRecordedContext.sol";
18
12
  import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
19
- import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
20
13
  import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
21
14
  import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
22
15
  import {JBOwnable} from "@bananapus/ownable-v6/src/JBOwnable.sol";
23
16
  import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
24
- import {IERC2981} from "@openzeppelin/contracts/interfaces/IERC2981.sol";
25
17
  import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
26
18
  import {Context} from "@openzeppelin/contracts/utils/Context.sol";
27
19
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
28
- import {ERC721} from "./abstract/ERC721.sol";
20
+ import {JB721Hook} from "./abstract/JB721Hook.sol";
29
21
  import {IJB721TiersHook} from "./interfaces/IJB721TiersHook.sol";
30
22
  import {IJB721TiersHookStore} from "./interfaces/IJB721TiersHookStore.sol";
31
23
  import {IJB721TokenUriResolver} from "./interfaces/IJB721TokenUriResolver.sol";
@@ -43,33 +35,23 @@ import {JB721TiersMintReservesConfig} from "./structs/JB721TiersMintReservesConf
43
35
  /// the project is paid, the hook may mint NFTs to the payer, depending on the hook's setup, the amount paid, and
44
36
  /// information specified by the payer. The project's owner can enable NFT cash outs through this hook, allowing
45
37
  /// holders to burn their NFTs to reclaim funds from the project (in proportion to the NFT's price).
46
- contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
38
+ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook {
47
39
  //*********************************************************************//
48
40
  // --------------------------- custom errors ------------------------- //
49
41
  //*********************************************************************//
50
42
 
51
43
  error JB721TiersHook_AlreadyInitialized(uint256 projectId);
52
44
  error JB721TiersHook_CurrencyMismatch(uint256 paymentCurrency, uint256 tierCurrency);
53
- error JB721TiersHook_InvalidCashOut();
54
- error JB721TiersHook_InvalidPay();
55
45
  error JB721TiersHook_InvalidPricingDecimals(uint256 decimals);
56
46
  error JB721TiersHook_MintReserveNftsPaused();
57
47
  error JB721TiersHook_NoProjectId();
58
48
  error JB721TiersHook_Overspending(uint256 leftoverAmount);
59
49
  error JB721TiersHook_TierTransfersPaused();
60
- error JB721TiersHook_UnauthorizedToken(uint256 tokenId, address holder);
61
- error JB721TiersHook_UnexpectedTokenCashedOut();
62
50
 
63
51
  //*********************************************************************//
64
52
  // --------------- public immutable stored properties ---------------- //
65
53
  //*********************************************************************//
66
54
 
67
- /// @notice The directory of terminals and controllers for projects.
68
- IJBDirectory public immutable override DIRECTORY;
69
-
70
- /// @notice The ID used when parsing metadata.
71
- address public immutable override METADATA_ID_TARGET;
72
-
73
55
  /// @notice The contract storing and managing project rulesets.
74
56
  IJBRulesets public immutable override RULESETS;
75
57
 
@@ -80,9 +62,6 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
80
62
  // ---------------------- public stored properties ------------------- //
81
63
  //*********************************************************************//
82
64
 
83
- /// @notice The ID of the project that this contract is associated with.
84
- uint256 public override PROJECT_ID;
85
-
86
65
  /// @notice The base URI for the NFT `tokenUris`.
87
66
  string public override baseURI;
88
67
 
@@ -127,10 +106,9 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
127
106
  address trustedForwarder
128
107
  )
129
108
  JBOwnable(permissions, directory.PROJECTS(), msg.sender, uint88(0))
109
+ JB721Hook(directory)
130
110
  ERC2771Context(trustedForwarder)
131
111
  {
132
- DIRECTORY = directory;
133
- METADATA_ID_TARGET = address(this);
134
112
  RULESETS = rulesets;
135
113
  STORE = store;
136
114
  }
@@ -139,61 +117,6 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
139
117
  // ------------------------- external views -------------------------- //
140
118
  //*********************************************************************//
141
119
 
142
- /// @notice The data calculated before a cash out is recorded in the terminal store. This data is provided to the
143
- /// terminal's `cashOutTokensOf(...)` transaction.
144
- /// @dev Sets this contract as the cash out hook. Part of `IJBRulesetDataHook`.
145
- /// @dev This function is used for NFT cash outs, and will only be called if the project's ruleset has
146
- /// `useDataHookForCashOut` set to `true`.
147
- /// @param context The cash out context passed to this contract by the `cashOutTokensOf(...)` function.
148
- /// @return cashOutTaxRate The cash out tax rate influencing the reclaim amount.
149
- /// @return cashOutCount The amount of tokens that should be considered cashed out.
150
- /// @return totalSupply The total amount of tokens that are considered to be existing.
151
- /// @return hookSpecifications The amount and data to send to cash out hooks (this contract) instead of returning to
152
- /// the beneficiary.
153
- function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context)
154
- public
155
- view
156
- virtual
157
- override
158
- returns (
159
- uint256 cashOutTaxRate,
160
- uint256 cashOutCount,
161
- uint256 totalSupply,
162
- JBCashOutHookSpecification[] memory hookSpecifications
163
- )
164
- {
165
- // Make sure (fungible) project tokens aren't also being cashed out.
166
- if (context.cashOutCount > 0) revert JB721TiersHook_UnexpectedTokenCashedOut();
167
-
168
- // Fetch the cash out hook metadata using the corresponding metadata ID.
169
- (bool metadataExists, bytes memory metadata) = JBMetadataResolver.getDataFor({
170
- id: JBMetadataResolver.getId({purpose: "cashOut", target: METADATA_ID_TARGET}), metadata: context.metadata
171
- });
172
-
173
- // Use this contract as the only cash out hook.
174
- hookSpecifications = new JBCashOutHookSpecification[](1);
175
- hookSpecifications[0] = JBCashOutHookSpecification({hook: this, amount: 0, metadata: bytes("")});
176
-
177
- uint256[] memory decodedTokenIds;
178
-
179
- // Decode the metadata.
180
- if (metadataExists) decodedTokenIds = abi.decode(metadata, (uint256[]));
181
-
182
- // Use the cash out weight of the provided 721s.
183
- cashOutCount = STORE.cashOutWeightOf({hook: address(this), tokenIds: decodedTokenIds});
184
-
185
- // Use the total cash out weight of the 721s.
186
- totalSupply = STORE.totalCashOutWeight(address(this));
187
-
188
- // Use the cash out tax rate from the context.
189
- cashOutTaxRate = context.cashOutTaxRate;
190
- }
191
-
192
- /// @notice Required by the IJBRulesetDataHook interfaces. Return false to not leak any permissions.
193
- function hasMintPermissionFor(uint256, JBRuleset memory, address) external pure returns (bool) {
194
- return false;
195
- }
196
-
197
120
  /// @notice The first owner of an NFT.
198
121
  /// @dev This is generally the address which paid for the NFT.
199
122
  /// @param tokenId The token ID of the NFT to get the first owner of.
@@ -236,18 +159,16 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
236
159
  return STORE.balanceOf({hook: address(this), owner: owner});
237
160
  }
238
161
 
239
- /// @notice The data calculated before a payment is recorded in the terminal store. This data is provided to the
240
- /// terminal's `pay(...)` transaction.
241
- /// @dev Sets this contract as the pay hook. Part of `IJBRulesetDataHook`.
242
- /// @param context The payment context passed to this contract by the `pay(...)` function.
243
- /// @return weight The new `weight` to use, overriding the ruleset's `weight`.
244
- /// @return hookSpecifications The amount and data to send to pay hooks (this contract) instead of adding to the
245
- /// terminal's balance.
162
+ /// @notice The data calculated before a payment is recorded in the terminal store.
163
+ /// @dev Overrides the base to calculate the split amount to forward based on tier split percentages.
164
+ /// @param context The payment context.
165
+ /// @return weight The weight to use for token minting (unchanged from ruleset weight).
166
+ /// @return hookSpecifications The hook specifications, with the split amount to forward.
246
167
  function beforePayRecordedWith(JBBeforePayRecordedContext calldata context)
247
168
  public
248
169
  view
249
170
  virtual
250
- override
171
+ override(JB721Hook, IJBRulesetDataHook)
251
172
  returns (uint256 weight, JBPayHookSpecification[] memory hookSpecifications)
252
173
  {
253
174
  weight = context.weight;
@@ -260,13 +181,20 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
260
181
  hookSpecifications[0] = JBPayHookSpecification({hook: this, amount: totalSplitAmount, metadata: splitMetadata});
261
182
  }
262
183
 
184
+ /// @notice The combined cash out weight of the NFTs with the specified token IDs.
185
+ /// @dev An NFT's cash out weight is its price.
186
+ /// @dev To get their relative cash out weight, divide the result by the `totalCashOutWeight(...)`.
187
+ /// @param tokenIds The token IDs of the NFTs to get the cumulative cash out weight of.
188
+ /// @return weight The cash out weight of the tokenIds.
189
+ function cashOutWeightOf(uint256[] memory tokenIds) public view virtual override returns (uint256) {
190
+ return STORE.cashOutWeightOf({hook: address(this), tokenIds: tokenIds});
191
+ }
192
+
263
193
  /// @notice Indicates if this contract adheres to the specified interface.
264
194
  /// @dev See {IERC165-supportsInterface}.
265
195
  /// @param interfaceId The ID of the interface to check for adherence to.
266
- function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721, IERC165) returns (bool) {
267
- return interfaceId == type(IJB721TiersHook).interfaceId || interfaceId == type(IJBRulesetDataHook).interfaceId
268
- || interfaceId == type(IJBPayHook).interfaceId || interfaceId == type(IJBCashOutHook).interfaceId
269
- || interfaceId == type(IERC2981).interfaceId || super.supportsInterface(interfaceId);
196
+ function supportsInterface(bytes4 interfaceId) public view override(IERC165, JB721Hook) returns (bool) {
197
+ return interfaceId == type(IJB721TiersHook).interfaceId || JB721Hook.supportsInterface(interfaceId);
270
198
  }
271
199
 
272
200
  /// @notice Initializes a cloned copy of the original hook contract.
@@ -298,9 +226,8 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
298
226
  // Make sure a projectId is provided.
299
227
  if (projectId == 0) revert JB721TiersHook_NoProjectId();
300
228
 
301
- // Initialize ERC721 and set the project ID.
302
- ERC721._initialize({name_: name, symbol_: symbol});
303
- PROJECT_ID = projectId;
229
+ // Initialize the superclass.
230
+ JB721Hook._initialize({projectId: projectId, name: name, symbol: symbol});
304
231
 
305
232
  // Validate pricing decimals are within a reasonable range.
306
233
  if (tiersConfig.decimals > 18) revert JB721TiersHook_InvalidPricingDecimals(tiersConfig.decimals);
@@ -352,76 +279,17 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
352
279
  return JB721TiersHookLib.resolveTokenURI(STORE, address(this), baseURI, tokenId);
353
280
  }
354
281
 
282
+ /// @notice The combined cash out weight of all outstanding NFTs.
283
+ /// @dev An NFT's cash out weight is its price.
284
+ /// @return weight The total cash out weight.
285
+ function totalCashOutWeight() public view virtual override returns (uint256) {
286
+ return STORE.totalCashOutWeight(address(this));
287
+ }
288
+
355
289
  //*********************************************************************//
356
290
  // ---------------------- external transactions ---------------------- //
357
291
  //*********************************************************************//
358
292
 
359
- /// @notice Mints one or more NFTs to the `context.beneficiary` upon payment if conditions are met. Part of
360
- /// `IJBPayHook`.
361
- /// @dev Reverts if the calling contract is not one of the project's terminals.
362
- /// @param context The payment context passed in by the terminal.
363
- // slither-disable-next-line locked-ether
364
- function afterPayRecordedWith(JBAfterPayRecordedContext calldata context) external payable virtual override {
365
- uint256 projectId = PROJECT_ID;
366
-
367
- // Make sure the caller is a terminal of the project, and that the call is being made on behalf of an
368
- // interaction with the correct project.
369
- if (!DIRECTORY.isTerminalOf(projectId, IJBTerminal(msg.sender)) || context.projectId != projectId) {
370
- revert JB721TiersHook_InvalidPay();
371
- }
372
-
373
- // Process the payment.
374
- _processPayment(context);
375
- }
376
-
377
- /// @notice Burns the specified NFTs upon token holder cash out, reclaiming funds from the project's balance for
378
- /// `context.beneficiary`. Part of `IJBCashOutHook`.
379
- /// @dev Reverts if the calling contract is not one of the project's terminals.
380
- /// @param context The cash out context passed in by the terminal.
381
- // slither-disable-next-line locked-ether
382
- function afterCashOutRecordedWith(JBAfterCashOutRecordedContext calldata context)
383
- external
384
- payable
385
- virtual
386
- override
387
- {
388
- // Keep a reference to the project ID.
389
- uint256 projectId = PROJECT_ID;
390
-
391
- // Make sure the caller is a terminal of the project, and that the call is being made on behalf of an
392
- // interaction with the correct project.
393
- if (
394
- msg.value != 0 || !DIRECTORY.isTerminalOf({projectId: projectId, terminal: IJBTerminal(msg.sender)})
395
- || context.projectId != projectId
396
- ) revert JB721TiersHook_InvalidCashOut();
397
-
398
- // Fetch the cash out hook metadata using the corresponding metadata ID.
399
- (bool metadataExists, bytes memory metadata) = JBMetadataResolver.getDataFor({
400
- id: JBMetadataResolver.getId({purpose: "cashOut", target: METADATA_ID_TARGET}),
401
- metadata: context.cashOutMetadata
402
- });
403
-
404
- uint256[] memory decodedTokenIds;
405
-
406
- // Decode the metadata.
407
- if (metadataExists) decodedTokenIds = abi.decode(metadata, (uint256[]));
408
-
409
- // Iterate through the NFTs, burning them if the owner is correct.
410
- for (uint256 i; i < decodedTokenIds.length; i++) {
411
- // Set the current NFT's token ID.
412
- uint256 tokenId = decodedTokenIds[i];
413
-
414
- // Make sure the token's owner is correct.
415
- if (_ownerOf(tokenId) != context.holder) revert JB721TiersHook_UnauthorizedToken(tokenId, context.holder);
416
-
417
- // Burn the token.
418
- _burn(tokenId);
419
- }
420
-
421
- // Add to burned counter.
422
- STORE.recordBurn(decodedTokenIds);
423
- }
424
-
425
293
  /// @notice Add or delete tiers.
426
294
  /// @dev Only the contract's owner or an operator with the `ADJUST_TIERS` permission from the owner can adjust the
427
295
  /// tiers.
@@ -638,6 +506,13 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
638
506
  // ------------------------ internal functions ----------------------- //
639
507
  //*********************************************************************//
640
508
 
509
+ /// @notice A function which gets called after NFTs have been cashed out and recorded by the terminal.
510
+ /// @param tokenIds The token IDs of the NFTs that were burned.
511
+ function _didBurn(uint256[] memory tokenIds) internal virtual override {
512
+ // Add to burned counter.
513
+ STORE.recordBurn(tokenIds);
514
+ }
515
+
641
516
  /// @notice Mints one NFT from each of the specified tiers for the beneficiary.
642
517
  /// @dev The same tier can be specified more than once.
643
518
  /// @param amount The amount to base the mints on. The total price of the NFTs being minted cannot be larger than
@@ -687,7 +562,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
687
562
  /// the payer's existing credits are NOT applied to the mint. Only the beneficiary's credits are combined with
688
563
  /// the incoming payment value. Leftover funds after minting are stored as credits for the beneficiary.
689
564
  /// @param context Payment context provided by the terminal after it has recorded the payment in the terminal store.
690
- function _processPayment(JBAfterPayRecordedContext calldata context) internal virtual {
565
+ function _processPayment(JBAfterPayRecordedContext calldata context) internal virtual override {
691
566
  // Normalize the payment value based on the pricing context.
692
567
  uint256 value;
693
568
  {
@@ -711,9 +586,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
711
586
  // If the payer is the beneficiary, combine their NFT credits with the amount paid.
712
587
  uint256 unusedPayCredits;
713
588
  if (context.payer == context.beneficiary) {
714
- unchecked {
715
- leftoverAmount += payCredits;
716
- }
589
+ leftoverAmount += payCredits;
717
590
  } else {
718
591
  // Otherwise, the payer's NFT credits won't be used, and we keep track of the unused credits.
719
592
  unusedPayCredits = payCredits;
@@ -755,28 +628,26 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
755
628
  if (leftoverAmount != 0 && !allowOverspending) revert JB721TiersHook_Overspending(leftoverAmount);
756
629
 
757
630
  // Update NFT credits if they changed.
758
- unchecked {
759
- uint256 newPayCredits = leftoverAmount + unusedPayCredits;
760
-
761
- if (newPayCredits != payCredits) {
762
- if (newPayCredits > payCredits) {
763
- emit AddPayCredits({
764
- amount: newPayCredits - payCredits,
765
- newTotalCredits: newPayCredits,
766
- account: context.beneficiary,
767
- caller: _msgSender()
768
- });
769
- } else {
770
- emit UsePayCredits({
771
- amount: payCredits - newPayCredits,
772
- newTotalCredits: newPayCredits,
773
- account: context.beneficiary,
774
- caller: _msgSender()
775
- });
776
- }
777
-
778
- payCreditsOf[context.beneficiary] = newPayCredits;
631
+ uint256 newPayCredits = leftoverAmount + unusedPayCredits;
632
+
633
+ if (newPayCredits != payCredits) {
634
+ if (newPayCredits > payCredits) {
635
+ emit AddPayCredits({
636
+ amount: newPayCredits - payCredits,
637
+ newTotalCredits: newPayCredits,
638
+ account: context.beneficiary,
639
+ caller: _msgSender()
640
+ });
641
+ } else {
642
+ emit UsePayCredits({
643
+ amount: payCredits - newPayCredits,
644
+ newTotalCredits: newPayCredits,
645
+ account: context.beneficiary,
646
+ caller: _msgSender()
647
+ });
779
648
  }
649
+
650
+ payCreditsOf[context.beneficiary] = newPayCredits;
780
651
  }
781
652
 
782
653
  // Distribute any forwarded funds to tier split groups.
@@ -0,0 +1,264 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import {IJBCashOutHook} from "@bananapus/core-v6/src/interfaces/IJBCashOutHook.sol";
5
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
6
+ import {IJBPayHook} from "@bananapus/core-v6/src/interfaces/IJBPayHook.sol";
7
+ import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
8
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
9
+ import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
10
+ import {JBAfterCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterCashOutRecordedContext.sol";
11
+ import {JBAfterPayRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterPayRecordedContext.sol";
12
+ import {JBBeforeCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforeCashOutRecordedContext.sol";
13
+ import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
14
+ import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
15
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
16
+ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
17
+ import {IERC2981} from "@openzeppelin/contracts/interfaces/IERC2981.sol";
18
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
19
+
20
+ import {ERC721} from "./ERC721.sol";
21
+ import {IJB721Hook} from "../interfaces/IJB721Hook.sol";
22
+
23
+ /// @title JB721Hook
24
+ /// @notice When a project which uses this hook is paid, this hook may mint NFTs to the payer, depending on this hook's
25
+ /// setup, the amount paid, and information specified by the payer. The project's owner can enable NFT cash outs
26
+ /// through this hook, allowing the NFT holders to burn their NFTs to reclaim funds from the project (in proportion to
27
+ /// the NFT's price).
28
+ abstract contract JB721Hook is ERC721, IJB721Hook {
29
+ //*********************************************************************//
30
+ // --------------------------- custom errors ------------------------- //
31
+ //*********************************************************************//
32
+
33
+ error JB721Hook_InvalidCashOut();
34
+ error JB721Hook_InvalidPay();
35
+ error JB721Hook_UnauthorizedToken(uint256 tokenId, address holder);
36
+ error JB721Hook_UnexpectedTokenCashedOut();
37
+
38
+ //*********************************************************************//
39
+ // --------------- public immutable stored properties ---------------- //
40
+ //*********************************************************************//
41
+
42
+ /// @notice The directory of terminals and controllers for projects.
43
+ IJBDirectory public immutable override DIRECTORY;
44
+
45
+ /// @notice The ID used when parsing metadata.
46
+ address public immutable override METADATA_ID_TARGET;
47
+
48
+ //*********************************************************************//
49
+ // -------------------- public stored properties --------------------- //
50
+ //*********************************************************************//
51
+
52
+ /// @notice The ID of the project that this contract is associated with.
53
+ uint256 public override PROJECT_ID;
54
+
55
+ //*********************************************************************//
56
+ // -------------------------- constructor ---------------------------- //
57
+ //*********************************************************************//
58
+
59
+ /// @param directory A directory of terminals and controllers for projects.
60
+ constructor(IJBDirectory directory) {
61
+ DIRECTORY = directory;
62
+ // Store the address of the original hook deploy. Clones will each use the address of the instance they're based
63
+ // on.
64
+ METADATA_ID_TARGET = address(this);
65
+ }
66
+
67
+ //*********************************************************************//
68
+ // ------------------------- external views -------------------------- //
69
+ //*********************************************************************//
70
+
71
+ /// @notice The data calculated before a payment is recorded in the terminal store. This data is provided to the
72
+ /// terminal's `pay(...)` transaction.
73
+ /// @dev Sets this contract as the pay hook. Part of `IJBRulesetDataHook`.
74
+ /// @param context The payment context passed to this contract by the `pay(...)` function.
75
+ /// @return weight The new `weight` to use, overriding the ruleset's `weight`.
76
+ /// @return hookSpecifications The amount and data to send to pay hooks (this contract) instead of adding to the
77
+ /// terminal's balance.
78
+ function beforePayRecordedWith(JBBeforePayRecordedContext calldata context)
79
+ public
80
+ view
81
+ virtual
82
+ override
83
+ returns (uint256 weight, JBPayHookSpecification[] memory hookSpecifications)
84
+ {
85
+ // Forward the received weight and use this contract as the only pay hook.
86
+ weight = context.weight;
87
+ hookSpecifications = new JBPayHookSpecification[](1);
88
+ hookSpecifications[0] = JBPayHookSpecification({hook: this, amount: 0, metadata: bytes("")});
89
+ }
90
+
91
+ /// @notice The data calculated before a cash out is recorded in the terminal store. This data is provided to the
92
+ /// terminal's `cashOutTokensOf(...)` transaction.
93
+ /// @dev Sets this contract as the cash out hook. Part of `IJBRulesetDataHook`.
94
+ /// @dev This function is used for NFT cash outs, and will only be called if the project's ruleset has
95
+ /// `useDataHookForCashOut` set to `true`.
96
+ /// @param context The cash out context passed to this contract by the `cashOutTokensOf(...)` function.
97
+ /// @return cashOutTaxRate The cash out tax rate influencing the reclaim amount.
98
+ /// @return cashOutCount The amount of tokens that should be considered cashed out.
99
+ /// @return totalSupply The total amount of tokens that are considered to be existing.
100
+ /// @return hookSpecifications The amount and data to send to cash out hooks (this contract) instead of returning to
101
+ /// the beneficiary.
102
+ function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context)
103
+ public
104
+ view
105
+ virtual
106
+ override
107
+ returns (
108
+ uint256 cashOutTaxRate,
109
+ uint256 cashOutCount,
110
+ uint256 totalSupply,
111
+ JBCashOutHookSpecification[] memory hookSpecifications
112
+ )
113
+ {
114
+ // Make sure (fungible) project tokens aren't also being cashed out.
115
+ if (context.cashOutCount > 0) revert JB721Hook_UnexpectedTokenCashedOut();
116
+
117
+ // Fetch the cash out hook metadata using the corresponding metadata ID.
118
+ (bool metadataExists, bytes memory metadata) = JBMetadataResolver.getDataFor({
119
+ id: JBMetadataResolver.getId({purpose: "cashOut", target: METADATA_ID_TARGET}), metadata: context.metadata
120
+ });
121
+
122
+ // Use this contract as the only cash out hook.
123
+ hookSpecifications = new JBCashOutHookSpecification[](1);
124
+ hookSpecifications[0] = JBCashOutHookSpecification({hook: this, amount: 0, metadata: bytes("")});
125
+
126
+ uint256[] memory decodedTokenIds;
127
+
128
+ // Decode the metadata.
129
+ if (metadataExists) decodedTokenIds = abi.decode(metadata, (uint256[]));
130
+
131
+ // Use the cash out weight of the provided 721s.
132
+ cashOutCount = cashOutWeightOf(decodedTokenIds);
133
+
134
+ // Use the total cash out weight of the 721s.
135
+ totalSupply = totalCashOutWeight();
136
+
137
+ // Use the cash out tax rate from the context.
138
+ cashOutTaxRate = context.cashOutTaxRate;
139
+ }
140
+
141
+ /// @notice Required by the IJBRulesetDataHook interfaces. Return false to not leak any permissions.
142
+ function hasMintPermissionFor(uint256, JBRuleset memory, address) external pure returns (bool) {
143
+ return false;
144
+ }
145
+
146
+ //*********************************************************************//
147
+ // -------------------------- public views --------------------------- //
148
+ //*********************************************************************//
149
+
150
+ /// @notice Returns the cumulative cash out weight of the specified token IDs relative to the
151
+ /// `totalCashOutWeight`.
152
+ /// @param tokenIds The NFT token IDs to calculate the cumulative cash out weight of.
153
+ /// @return The cumulative cash out weight of the specified token IDs.
154
+ function cashOutWeightOf(uint256[] memory tokenIds) public view virtual returns (uint256) {
155
+ tokenIds; // Prevents unused var compiler and natspec complaints.
156
+ return 0;
157
+ }
158
+
159
+ /// @notice Indicates if this contract adheres to the specified interface.
160
+ /// @dev See {IERC165-supportsInterface}.
161
+ /// @param interfaceId The ID of the interface to check for adherence to.
162
+ function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721, IERC165) returns (bool) {
163
+ return interfaceId == type(IJB721Hook).interfaceId || interfaceId == type(IJBRulesetDataHook).interfaceId
164
+ || interfaceId == type(IJBPayHook).interfaceId || interfaceId == type(IJBCashOutHook).interfaceId
165
+ || interfaceId == type(IERC2981).interfaceId || super.supportsInterface(interfaceId);
166
+ }
167
+
168
+ /// @notice Calculates the cumulative cash out weight of all NFT token IDs.
169
+ /// @return The total cumulative cash out weight of all NFT token IDs.
170
+ function totalCashOutWeight() public view virtual returns (uint256) {
171
+ return 0;
172
+ }
173
+
174
+ //*********************************************************************//
175
+ // ---------------------- external transactions ---------------------- //
176
+ //*********************************************************************//
177
+
178
+ /// @notice Mints one or more NFTs to the `context.beneficiary` upon payment if conditions are met. Part of
179
+ /// `IJBPayHook`.
180
+ /// @dev Reverts if the calling contract is not one of the project's terminals.
181
+ /// @param context The payment context passed in by the terminal.
182
+ // slither-disable-next-line locked-ether
183
+ function afterPayRecordedWith(JBAfterPayRecordedContext calldata context) external payable virtual override {
184
+ uint256 projectId = PROJECT_ID;
185
+
186
+ // Make sure the caller is a terminal of the project, and that the call is being made on behalf of an
187
+ // interaction with the correct project.
188
+ if (!DIRECTORY.isTerminalOf(projectId, IJBTerminal(msg.sender)) || context.projectId != projectId) {
189
+ revert JB721Hook_InvalidPay();
190
+ }
191
+
192
+ // Process the payment.
193
+ _processPayment(context);
194
+ }
195
+
196
+ /// @notice Burns the specified NFTs upon token holder cash out, reclaiming funds from the project's balance for
197
+ /// `context.beneficiary`. Part of `IJBCashOutHook`.
198
+ /// @dev Reverts if the calling contract is not one of the project's terminals.
199
+ /// @param context The cash out context passed in by the terminal.
200
+ // slither-disable-next-line locked-ether
201
+ function afterCashOutRecordedWith(JBAfterCashOutRecordedContext calldata context)
202
+ external
203
+ payable
204
+ virtual
205
+ override
206
+ {
207
+ // Keep a reference to the project ID.
208
+ uint256 projectId = PROJECT_ID;
209
+
210
+ // Make sure the caller is a terminal of the project, and that the call is being made on behalf of an
211
+ // interaction with the correct project.
212
+ if (
213
+ msg.value != 0 || !DIRECTORY.isTerminalOf({projectId: projectId, terminal: IJBTerminal(msg.sender)})
214
+ || context.projectId != projectId
215
+ ) revert JB721Hook_InvalidCashOut();
216
+
217
+ // Fetch the cash out hook metadata using the corresponding metadata ID.
218
+ (bool metadataExists, bytes memory metadata) = JBMetadataResolver.getDataFor({
219
+ id: JBMetadataResolver.getId({purpose: "cashOut", target: METADATA_ID_TARGET}),
220
+ metadata: context.cashOutMetadata
221
+ });
222
+
223
+ uint256[] memory decodedTokenIds;
224
+
225
+ // Decode the metadata.
226
+ if (metadataExists) decodedTokenIds = abi.decode(metadata, (uint256[]));
227
+
228
+ // Iterate through the NFTs, burning them if the owner is correct.
229
+ for (uint256 i; i < decodedTokenIds.length; i++) {
230
+ // Set the current NFT's token ID.
231
+ uint256 tokenId = decodedTokenIds[i];
232
+
233
+ // Make sure the token's owner is correct.
234
+ if (_ownerOf(tokenId) != context.holder) revert JB721Hook_UnauthorizedToken(tokenId, context.holder);
235
+
236
+ // Burn the token.
237
+ _burn(tokenId);
238
+ }
239
+
240
+ // Call the hook.
241
+ _didBurn(decodedTokenIds);
242
+ }
243
+
244
+ //*********************************************************************//
245
+ // ---------------------- internal transactions ---------------------- //
246
+ //*********************************************************************//
247
+
248
+ /// @notice Initializes the contract by associating it with a project and adding ERC721 details.
249
+ /// @param projectId The ID of the project that this contract is associated with.
250
+ /// @param name The name of the NFT collection.
251
+ /// @param symbol The symbol representing the NFT collection.
252
+ function _initialize(uint256 projectId, string memory name, string memory symbol) internal {
253
+ ERC721._initialize({name_: name, symbol_: symbol});
254
+ PROJECT_ID = projectId;
255
+ }
256
+
257
+ /// @notice Executes after NFTs have been burned via cash out.
258
+ /// @param tokenIds The token IDs of the NFTs that were burned.
259
+ function _didBurn(uint256[] memory tokenIds) internal virtual;
260
+
261
+ /// @notice Process a received payment.
262
+ /// @param context The payment context passed in by the terminal.
263
+ function _processPayment(JBAfterPayRecordedContext calldata context) internal virtual;
264
+ }
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ import {IJBCashOutHook} from "@bananapus/core-v6/src/interfaces/IJBCashOutHook.sol";
5
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
6
+ import {IJBPayHook} from "@bananapus/core-v6/src/interfaces/IJBPayHook.sol";
7
+ import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
8
+
9
+ interface IJB721Hook is IJBRulesetDataHook, IJBPayHook, IJBCashOutHook {
10
+ /// @notice The directory of terminals and controllers for projects.
11
+ /// @return The directory contract.
12
+ function DIRECTORY() external view returns (IJBDirectory);
13
+
14
+ /// @notice The ID used when parsing metadata.
15
+ /// @return The address of the metadata ID target.
16
+ function METADATA_ID_TARGET() external view returns (address);
17
+
18
+ /// @notice The ID of the project that this contract is associated with.
19
+ /// @return The project ID.
20
+ function PROJECT_ID() external view returns (uint256);
21
+ }
@@ -1,13 +1,10 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity ^0.8.0;
3
3
 
4
- import {IJBCashOutHook} from "@bananapus/core-v6/src/interfaces/IJBCashOutHook.sol";
5
- import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
6
- import {IJBPayHook} from "@bananapus/core-v6/src/interfaces/IJBPayHook.sol";
7
4
  import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
8
- import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
9
5
  import {IJBRulesets} from "@bananapus/core-v6/src/interfaces/IJBRulesets.sol";
10
6
 
7
+ import {IJB721Hook} from "./IJB721Hook.sol";
11
8
  import {IJB721TiersHookStore} from "./IJB721TiersHookStore.sol";
12
9
  import {IJB721TokenUriResolver} from "./IJB721TokenUriResolver.sol";
13
10
  import {JB721InitTiersConfig} from "../structs/JB721InitTiersConfig.sol";
@@ -16,7 +13,7 @@ import {JB721TiersHookFlags} from "../structs/JB721TiersHookFlags.sol";
16
13
  import {JB721TiersMintReservesConfig} from "../structs/JB721TiersMintReservesConfig.sol";
17
14
  import {JB721TiersSetDiscountPercentConfig} from "../structs/JB721TiersSetDiscountPercentConfig.sol";
18
15
 
19
- interface IJB721TiersHook is IJBRulesetDataHook, IJBPayHook, IJBCashOutHook {
16
+ interface IJB721TiersHook is IJB721Hook {
20
17
  event AddPayCredits(
21
18
  uint256 indexed amount, uint256 indexed newTotalCredits, address indexed account, address caller
22
19
  );
@@ -39,18 +36,6 @@ interface IJB721TiersHook is IJBRulesetDataHook, IJBPayHook, IJBCashOutHook {
39
36
  uint256 indexed amount, uint256 indexed newTotalCredits, address indexed account, address caller
40
37
  );
41
38
 
42
- /// @notice The directory of terminals and controllers for projects.
43
- /// @return The directory contract.
44
- function DIRECTORY() external view returns (IJBDirectory);
45
-
46
- /// @notice The ID used when parsing metadata.
47
- /// @return The address of the metadata ID target.
48
- function METADATA_ID_TARGET() external view returns (address);
49
-
50
- /// @notice The ID of the project that this contract is associated with.
51
- /// @return The project ID.
52
- function PROJECT_ID() external view returns (uint256);
53
-
54
39
  /// @notice The contract storing and managing project rulesets.
55
40
  /// @return The rulesets contract.
56
41
  function RULESETS() external view returns (IJBRulesets);
@@ -965,7 +965,7 @@ contract Test_afterPayRecorded_Unit is UnitTestSetup {
965
965
  vm.prank(terminal);
966
966
 
967
967
  // Expect a revert for the caller not being a terminal of the project.
968
- vm.expectRevert(abi.encodeWithSelector(JB721TiersHook.JB721TiersHook_InvalidPay.selector));
968
+ vm.expectRevert(abi.encodeWithSelector(JB721Hook.JB721Hook_InvalidPay.selector));
969
969
 
970
970
  hook.afterPayRecordedWith(
971
971
  JBAfterPayRecordedContext({
@@ -1530,4 +1530,88 @@ contract Test_afterPayRecorded_Unit is UnitTestSetup {
1530
1530
  // Check: has the holder's balance returned to 0?
1531
1531
  assertEq(hook.balanceOf(holder), 0);
1532
1532
  }
1533
+
1534
+ function test_afterPayRecorded_revertOnCreditOverflow_samePayerBeneficiary() public {
1535
+ // Mock the directory call.
1536
+ mockAndExpect(
1537
+ address(mockJBDirectory),
1538
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
1539
+ abi.encode(true)
1540
+ );
1541
+
1542
+ // Set the beneficiary's pay credits to max uint256.
1543
+ stdstore.target(address(hook)).sig("payCreditsOf(address)").with_key(beneficiary)
1544
+ .checked_write(type(uint256).max);
1545
+
1546
+ // Pay 1 wei where payer == beneficiary. No metadata → no NFT mints.
1547
+ // `leftoverAmount += payCredits` overflows: 1 + type(uint256).max.
1548
+ vm.expectRevert(stdError.arithmeticError);
1549
+ vm.prank(mockTerminalAddress);
1550
+ hook.afterPayRecordedWith(
1551
+ JBAfterPayRecordedContext({
1552
+ payer: beneficiary,
1553
+ projectId: projectId,
1554
+ rulesetId: 0,
1555
+ amount: JBTokenAmount({
1556
+ token: JBConstants.NATIVE_TOKEN,
1557
+ value: 1,
1558
+ decimals: 18,
1559
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
1560
+ }),
1561
+ forwardedAmount: JBTokenAmount({
1562
+ token: JBConstants.NATIVE_TOKEN,
1563
+ value: 0,
1564
+ decimals: 18,
1565
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
1566
+ }),
1567
+ weight: 10 ** 18,
1568
+ newlyIssuedTokenCount: 0,
1569
+ beneficiary: beneficiary,
1570
+ hookMetadata: new bytes(0),
1571
+ payerMetadata: new bytes(0)
1572
+ })
1573
+ );
1574
+ }
1575
+
1576
+ function test_afterPayRecorded_revertOnCreditOverflow_differentPayerBeneficiary() public {
1577
+ // Mock the directory call.
1578
+ mockAndExpect(
1579
+ address(mockJBDirectory),
1580
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
1581
+ abi.encode(true)
1582
+ );
1583
+
1584
+ // Set the beneficiary's pay credits to max uint256.
1585
+ stdstore.target(address(hook)).sig("payCreditsOf(address)").with_key(beneficiary)
1586
+ .checked_write(type(uint256).max);
1587
+
1588
+ // Pay 1 wei where payer != beneficiary. No metadata → no NFT mints, overspending allowed.
1589
+ // leftoverAmount=1, unusedPayCredits=type(uint256).max → overflow in `leftoverAmount + unusedPayCredits`.
1590
+ vm.expectRevert(stdError.arithmeticError);
1591
+ vm.prank(mockTerminalAddress);
1592
+ hook.afterPayRecordedWith(
1593
+ JBAfterPayRecordedContext({
1594
+ payer: address(0xdead),
1595
+ projectId: projectId,
1596
+ rulesetId: 0,
1597
+ amount: JBTokenAmount({
1598
+ token: JBConstants.NATIVE_TOKEN,
1599
+ value: 1,
1600
+ decimals: 18,
1601
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
1602
+ }),
1603
+ forwardedAmount: JBTokenAmount({
1604
+ token: JBConstants.NATIVE_TOKEN,
1605
+ value: 0,
1606
+ decimals: 18,
1607
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
1608
+ }),
1609
+ weight: 10 ** 18,
1610
+ newlyIssuedTokenCount: 0,
1611
+ beneficiary: beneficiary,
1612
+ hookMetadata: new bytes(0),
1613
+ payerMetadata: new bytes(0)
1614
+ })
1615
+ );
1616
+ }
1533
1617
  }
@@ -206,7 +206,7 @@ contract Test_cashOut_Unit is UnitTestSetup {
206
206
  vm.assume(tokenCount > 0);
207
207
 
208
208
  // Expect a revert on account of the token count being non-zero while the total supply is zero.
209
- vm.expectRevert(abi.encodeWithSelector(JB721TiersHook.JB721TiersHook_UnexpectedTokenCashedOut.selector));
209
+ vm.expectRevert(abi.encodeWithSelector(JB721Hook.JB721Hook_UnexpectedTokenCashedOut.selector));
210
210
 
211
211
  hook.beforeCashOutRecordedWith(
212
212
  JBBeforeCashOutRecordedContext({
@@ -347,7 +347,7 @@ contract Test_cashOut_Unit is UnitTestSetup {
347
347
  );
348
348
 
349
349
  // Expect to revert on account of the project ID being incorrect.
350
- vm.expectRevert(abi.encodeWithSelector(JB721TiersHook.JB721TiersHook_InvalidCashOut.selector));
350
+ vm.expectRevert(abi.encodeWithSelector(JB721Hook.JB721Hook_InvalidCashOut.selector));
351
351
 
352
352
  vm.prank(mockTerminalAddress);
353
353
  hook.afterCashOutRecordedWith(
@@ -382,7 +382,7 @@ contract Test_cashOut_Unit is UnitTestSetup {
382
382
  );
383
383
 
384
384
  // Expect to revert on account of the caller not being a terminal of the project.
385
- vm.expectRevert(abi.encodeWithSelector(JB721TiersHook.JB721TiersHook_InvalidCashOut.selector));
385
+ vm.expectRevert(abi.encodeWithSelector(JB721Hook.JB721Hook_InvalidCashOut.selector));
386
386
 
387
387
  vm.prank(mockTerminalAddress);
388
388
  hook.afterCashOutRecordedWith(
@@ -434,9 +434,7 @@ contract Test_cashOut_Unit is UnitTestSetup {
434
434
  abi.encode(true)
435
435
  );
436
436
 
437
- vm.expectRevert(
438
- abi.encodeWithSelector(JB721TiersHook.JB721TiersHook_UnauthorizedToken.selector, tokenId, wrongHolder)
439
- );
437
+ vm.expectRevert(abi.encodeWithSelector(JB721Hook.JB721Hook_UnauthorizedToken.selector, tokenId, wrongHolder));
440
438
 
441
439
  vm.prank(mockTerminalAddress);
442
440
  hook.afterCashOutRecordedWith(