@bananapus/721-hook-v6 0.0.18 → 0.0.19
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/ARCHITECTURE.md +4 -0
- package/README.md +19 -9
- package/RISKS.md +4 -0
- package/USER_JOURNEYS.md +4 -0
- package/package.json +2 -2
- package/src/JB721TiersHook.sol +87 -85
- package/src/abstract/JB721Hook.sol +2 -2
- package/src/libraries/JB721TiersHookLib.sol +4 -8
- package/test/TestAuditGaps.sol +147 -0
- package/test/fork/ERC20CashOutFork.t.sol +612 -0
- package/test/fork/IssueTokensForSplitsFork.t.sol +504 -0
package/ARCHITECTURE.md
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
NFT tier system for Juicebox V6. Allows projects to attach tiered NFT minting to payments and use NFTs as cash-out hooks. Supports on-chain and off-chain metadata, category-based sorting, and configurable pricing with discounts.
|
|
6
6
|
|
|
7
|
+
The design permits a high theoretical tier ceiling, but several important reads and cash-out calculations still scale
|
|
8
|
+
with `maxTierId`. In practice, this should be treated as a curated-catalog hook with an explicit operating envelope,
|
|
9
|
+
not as a guarantee that very large catalogs are comfortable to run on-chain.
|
|
10
|
+
|
|
7
11
|
## Contract Map
|
|
8
12
|
|
|
9
13
|
```
|
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ For projects using `forge` to manage dependencies (not recommended):
|
|
|
55
55
|
forge install Bananapus/nana-721-hook
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
If you're using `forge` to manage dependencies,
|
|
58
|
+
If you're using `forge` to manage dependencies, you'll also need to install `nana-721-hook`'s dependencies. With `libs = ["node_modules", "lib"]` in `foundry.toml`, Foundry auto-resolves import paths — no `remappings.txt` needed.
|
|
59
59
|
|
|
60
60
|
### Develop
|
|
61
61
|
|
|
@@ -136,13 +136,7 @@ This example deploys `nana-721-hook` to the Sepolia testnet using the specified
|
|
|
136
136
|
|
|
137
137
|
To view test coverage, run `npm run coverage` to generate an LCOV test report. You can use an extension like [Coverage Gutters](https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters) to view coverage in your editor.
|
|
138
138
|
|
|
139
|
-
If you're using Nomic Foundation's [Solidity](https://marketplace.visualstudio.com/items?itemName=NomicFoundation.hardhat-solidity) extension in VSCode, you may run into LSP errors because the extension cannot find dependencies outside of `lib`. You can often fix this by running
|
|
140
|
-
|
|
141
|
-
```bash
|
|
142
|
-
forge remappings >> remappings.txt
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
This makes the extension aware of default remappings.
|
|
139
|
+
If you're using Nomic Foundation's [Solidity](https://marketplace.visualstudio.com/items?itemName=NomicFoundation.hardhat-solidity) extension in VSCode, you may run into LSP errors because the extension cannot find dependencies outside of `lib`. You can often fix this by running `forge remappings > remappings.txt` to generate a remappings file for the extension. This file is gitignored and only needed for editor support.
|
|
146
140
|
|
|
147
141
|
## Repository Layout
|
|
148
142
|
|
|
@@ -219,7 +213,8 @@ Each pay/cash out hook can then execute custom behavior based on the custom data
|
|
|
219
213
|
|
|
220
214
|
### Mechanism
|
|
221
215
|
|
|
222
|
-
A project using a 721 tiers hook can specify
|
|
216
|
+
A project using a 721 tiers hook can specify a large number of NFT tiers in theory (up to 65,535 total), but the
|
|
217
|
+
practical operating envelope is much smaller because several important paths still scale with `maxTierId`.
|
|
223
218
|
|
|
224
219
|
- 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
220
|
- NFT tiers can be added by the project owner as long as they respect the hook's `flags`. Tiers must be sorted by category in ascending order — the store reverts with `JB721TiersHookStore_InvalidCategorySortOrder` if not. 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.
|
|
@@ -246,6 +241,21 @@ Additional notes:
|
|
|
246
241
|
- 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
242
|
- 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
243
|
- 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.
|
|
244
|
+
|
|
245
|
+
### Supported Operating Envelope
|
|
246
|
+
|
|
247
|
+
The current implementation is best suited to curated catalogs, not effectively unbounded consumer storefronts.
|
|
248
|
+
|
|
249
|
+
- Treat roughly `<= 100` active tiers as the comfortable operating envelope for projects that expect frequent
|
|
250
|
+
`balanceOf`, governance reads, or NFT cash outs.
|
|
251
|
+
- Treat `100-200` tiers as an advanced configuration that should be used only with deliberate gas budgeting and
|
|
252
|
+
frontend/operator awareness.
|
|
253
|
+
- Above that range, the on-chain reads and cash-out accounting are still functionally correct, but several important
|
|
254
|
+
paths become increasingly expensive because they iterate the tier set.
|
|
255
|
+
|
|
256
|
+
The test suite proves survivability at `100` and `200` tiers, and also proves that `balanceOf` and
|
|
257
|
+
`totalCashOutWeight` become materially more expensive at `100` tiers than at `10` tiers. That evidence should be read
|
|
258
|
+
as a scope boundary, not as encouragement to target the `uint16.max` theoretical tier ceiling in production.
|
|
249
259
|
- The hook declares support for ERC-2981 (royalties) via `supportsInterface`, but does not implement the `royaltyInfo` function. This is intended for future extension.
|
|
250
260
|
|
|
251
261
|
### Setup
|
package/RISKS.md
CHANGED
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
|
|
34
34
|
- **`totalCashOutWeight` iterates ALL tier IDs** (1 to `maxTierIdOf`), including removed tiers with minted NFTs. Called during every `beforeCashOutRecordedWith`. At ~2-3k gas per tier, 500+ tiers approaches block gas limits. Could block all NFT cash-outs if an attacker with `ADJUST_721_TIERS` permission adds thousands of tiers.
|
|
35
35
|
- **`balanceOf`, `votingUnitsOf`, `totalSupplyOf` iterate all tiers.** Same pattern: loop from `maxTierIdOf` down to 1. These are view functions but called by governance contracts.
|
|
36
|
+
- **Theoretical max is not the supported operating envelope.** The store permits up to 65,535 tiers, but the practical
|
|
37
|
+
comfort zone is far lower. The test suite demonstrates survivability at 100 to 200 tiers and also demonstrates that
|
|
38
|
+
`balanceOf` and `totalCashOutWeight` become materially more expensive at 100 tiers than at 10 tiers. Treat large
|
|
39
|
+
catalogs as an explicit gas-budgeting exercise, not as a default deployment shape.
|
|
36
40
|
- **`tiersOf` traverses removed tiers.** Removed tiers are skipped via bitmap but still traversed in the linked list. `cleanTiers()` must be called separately to compact. `cleanTiers()` is permissionless and idempotent.
|
|
37
41
|
- **Minting from many tiers in one payment.** `recordMint` loops per tier ID: storage read (stored tier + bitmap check) per iteration. 50 tiers in one payment ~5-7M gas (tested, fits in 30M block). 100+ tiers in a single mint is feasible but consumes most of the block.
|
|
38
42
|
- **`recordAddTiers` sort-insertion cost.** Adding a low-category tier to a hook with many existing higher-category tiers iterates the entire sorted list to find the insertion point. O(n) per added tier.
|
package/USER_JOURNEYS.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Every user-facing operation in the tiered 721 hook system, with exact entry points, parameters, state changes, events, and edge cases.
|
|
4
4
|
|
|
5
|
+
These journeys describe functional behavior, not a promise that the theoretical `uint16.max` tier ceiling is a
|
|
6
|
+
production-ready catalog size. Several important reads and cash-out calculations scale with `maxTierId`, so large-tier
|
|
7
|
+
deployments should be evaluated against the repo's documented operating envelope before launch.
|
|
8
|
+
|
|
5
9
|
---
|
|
6
10
|
|
|
7
11
|
## 1. Pay and Receive NFTs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/721-hook-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@bananapus/address-registry-v6": "^0.0.10",
|
|
21
|
-
"@bananapus/core-v6": "^0.0.
|
|
21
|
+
"@bananapus/core-v6": "^0.0.23",
|
|
22
22
|
"@bananapus/ownable-v6": "^0.0.10",
|
|
23
23
|
"@bananapus/permission-ids-v6": "^0.0.10",
|
|
24
24
|
"@openzeppelin/contracts": "^5.6.1",
|
package/src/JB721TiersHook.sol
CHANGED
|
@@ -210,7 +210,8 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
210
210
|
issueTokensForSplits: STORE.flagsOf(address(this)).issueTokensForSplits
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
-
hookSpecifications[0] =
|
|
213
|
+
hookSpecifications[0] =
|
|
214
|
+
JBPayHookSpecification({hook: this, noop: false, amount: totalSplitAmount, metadata: splitMetadata});
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
/// @notice The combined cash out weight of the NFTs with the specified token IDs.
|
|
@@ -620,104 +621,105 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
620
621
|
}
|
|
621
622
|
}
|
|
622
623
|
|
|
623
|
-
/// @notice
|
|
624
|
-
/// @
|
|
625
|
-
///
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
});
|
|
641
|
-
if (!valid) return;
|
|
624
|
+
/// @notice Mint NFTs from the specified tiers and update the beneficiary's pay credits.
|
|
625
|
+
/// @param value The normalized payment value.
|
|
626
|
+
/// @param context Payment context provided by the terminal.
|
|
627
|
+
function _mintAndUpdateCredits(uint256 value, JBAfterPayRecordedContext calldata context) internal {
|
|
628
|
+
// Keep a reference to the number of NFT credits the beneficiary already has.
|
|
629
|
+
uint256 payCredits = payCreditsOf[context.beneficiary];
|
|
630
|
+
|
|
631
|
+
// Set the leftover amount as the initial value.
|
|
632
|
+
uint256 leftoverAmount = value;
|
|
633
|
+
|
|
634
|
+
// If the payer is the beneficiary, combine their NFT credits with the amount paid.
|
|
635
|
+
uint256 unusedPayCredits;
|
|
636
|
+
if (context.payer == context.beneficiary) {
|
|
637
|
+
leftoverAmount += payCredits;
|
|
638
|
+
} else {
|
|
639
|
+
// Otherwise, the payer's NFT credits won't be used, and we keep track of the unused credits.
|
|
640
|
+
unusedPayCredits = payCredits;
|
|
642
641
|
}
|
|
643
642
|
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
uint256 payCredits = payCreditsOf[context.beneficiary];
|
|
643
|
+
// Keep a reference to the boolean indicating whether paying more than the price of the NFTs being minted
|
|
644
|
+
// is allowed. Defaults to the collection's flag.
|
|
645
|
+
bool allowOverspending = !STORE.flagsOf(address(this)).preventOverspending;
|
|
648
646
|
|
|
649
|
-
|
|
650
|
-
|
|
647
|
+
// Resolve the metadata.
|
|
648
|
+
(bool found, bytes memory metadata) = JBMetadataResolver.getDataFor({
|
|
649
|
+
id: JBMetadataResolver.getId({purpose: "pay", target: METADATA_ID_TARGET}), metadata: context.payerMetadata
|
|
650
|
+
});
|
|
651
651
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
leftoverAmount += payCredits;
|
|
656
|
-
} else {
|
|
657
|
-
// Otherwise, the payer's NFT credits won't be used, and we keep track of the unused credits.
|
|
658
|
-
unusedPayCredits = payCredits;
|
|
659
|
-
}
|
|
652
|
+
if (found) {
|
|
653
|
+
// Keep a reference to the IDs of the tier be to minted.
|
|
654
|
+
uint16[] memory tierIdsToMint;
|
|
660
655
|
|
|
661
|
-
// Keep a reference to the
|
|
662
|
-
|
|
663
|
-
bool allowOverspending = !STORE.flagsOf(address(this)).preventOverspending;
|
|
656
|
+
// Keep a reference to the payer's flag indicating whether overspending is allowed.
|
|
657
|
+
bool payerAllowsOverspending;
|
|
664
658
|
|
|
665
|
-
//
|
|
666
|
-
(
|
|
667
|
-
id: JBMetadataResolver.getId({purpose: "pay", target: METADATA_ID_TARGET}),
|
|
668
|
-
metadata: context.payerMetadata
|
|
669
|
-
});
|
|
659
|
+
// Decode the metadata.
|
|
660
|
+
(payerAllowsOverspending, tierIdsToMint) = abi.decode(metadata, (bool, uint16[]));
|
|
670
661
|
|
|
671
|
-
if
|
|
672
|
-
|
|
673
|
-
|
|
662
|
+
// Make sure overspending is allowed if requested.
|
|
663
|
+
if (allowOverspending && !payerAllowsOverspending) {
|
|
664
|
+
allowOverspending = false;
|
|
665
|
+
}
|
|
674
666
|
|
|
675
|
-
|
|
676
|
-
|
|
667
|
+
// Mint NFTs from the tiers as specified.
|
|
668
|
+
if (tierIdsToMint.length != 0) {
|
|
669
|
+
// slither-disable-next-line reentrancy-events,reentrancy-no-eth
|
|
670
|
+
leftoverAmount =
|
|
671
|
+
_mintAll({amount: leftoverAmount, mintTierIds: tierIdsToMint, beneficiary: context.beneficiary});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
677
674
|
|
|
678
|
-
|
|
679
|
-
|
|
675
|
+
// If overspending isn't allowed, revert.
|
|
676
|
+
if (leftoverAmount != 0 && !allowOverspending) revert JB721TiersHook_Overspending(leftoverAmount);
|
|
680
677
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
allowOverspending = false;
|
|
684
|
-
}
|
|
678
|
+
// Update NFT credits if they changed.
|
|
679
|
+
uint256 newPayCredits = leftoverAmount + unusedPayCredits;
|
|
685
680
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
681
|
+
if (newPayCredits != payCredits) {
|
|
682
|
+
if (newPayCredits > payCredits) {
|
|
683
|
+
emit AddPayCredits({
|
|
684
|
+
amount: newPayCredits - payCredits,
|
|
685
|
+
newTotalCredits: newPayCredits,
|
|
686
|
+
account: context.beneficiary,
|
|
687
|
+
caller: _msgSender()
|
|
688
|
+
});
|
|
689
|
+
} else {
|
|
690
|
+
emit UsePayCredits({
|
|
691
|
+
amount: payCredits - newPayCredits,
|
|
692
|
+
newTotalCredits: newPayCredits,
|
|
693
|
+
account: context.beneficiary,
|
|
694
|
+
caller: _msgSender()
|
|
695
|
+
});
|
|
693
696
|
}
|
|
694
697
|
|
|
695
|
-
|
|
696
|
-
if (leftoverAmount != 0 && !allowOverspending) revert JB721TiersHook_Overspending(leftoverAmount);
|
|
697
|
-
|
|
698
|
-
// Update NFT credits if they changed.
|
|
699
|
-
uint256 newPayCredits = leftoverAmount + unusedPayCredits;
|
|
700
|
-
|
|
701
|
-
if (newPayCredits != payCredits) {
|
|
702
|
-
if (newPayCredits > payCredits) {
|
|
703
|
-
emit AddPayCredits({
|
|
704
|
-
amount: newPayCredits - payCredits,
|
|
705
|
-
newTotalCredits: newPayCredits,
|
|
706
|
-
account: context.beneficiary,
|
|
707
|
-
caller: _msgSender()
|
|
708
|
-
});
|
|
709
|
-
} else {
|
|
710
|
-
emit UsePayCredits({
|
|
711
|
-
amount: payCredits - newPayCredits,
|
|
712
|
-
newTotalCredits: newPayCredits,
|
|
713
|
-
account: context.beneficiary,
|
|
714
|
-
caller: _msgSender()
|
|
715
|
-
});
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
payCreditsOf[context.beneficiary] = newPayCredits;
|
|
719
|
-
}
|
|
698
|
+
payCreditsOf[context.beneficiary] = newPayCredits;
|
|
720
699
|
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/// @notice Process a payment, minting NFTs and updating credits as necessary.
|
|
703
|
+
/// @dev Pay credits are tracked per beneficiary, not per payer. When the payer differs from the beneficiary,
|
|
704
|
+
/// the payer's existing credits are NOT applied to the mint. Only the beneficiary's credits are combined with
|
|
705
|
+
/// the incoming payment value. Leftover funds after minting are stored as credits for the beneficiary.
|
|
706
|
+
/// @param context Payment context provided by the terminal after it has recorded the payment in the terminal store.
|
|
707
|
+
function _processPayment(JBAfterPayRecordedContext calldata context) internal virtual override {
|
|
708
|
+
// Normalize the payment value based on the pricing context.
|
|
709
|
+
bool valid;
|
|
710
|
+
uint256 value;
|
|
711
|
+
(value, valid) = JB721TiersHookLib.normalizePaymentValue({
|
|
712
|
+
packedPricingContext: _packedPricingContext,
|
|
713
|
+
prices: PRICES,
|
|
714
|
+
projectId: PROJECT_ID,
|
|
715
|
+
amountValue: context.amount.value,
|
|
716
|
+
amountCurrency: context.amount.currency,
|
|
717
|
+
amountDecimals: context.amount.decimals
|
|
718
|
+
});
|
|
719
|
+
if (!valid) return;
|
|
720
|
+
|
|
721
|
+
// Mint NFTs from the specified tiers and update the beneficiary's pay credits.
|
|
722
|
+
_mintAndUpdateCredits({value: value, context: context});
|
|
721
723
|
|
|
722
724
|
// Distribute any forwarded funds to tier split groups.
|
|
723
725
|
if (context.hookMetadata.length != 0 && context.forwardedAmount.value != 0) {
|
|
@@ -102,7 +102,7 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
|
|
|
102
102
|
|
|
103
103
|
// Use this contract as the only cash out hook.
|
|
104
104
|
hookSpecifications = new JBCashOutHookSpecification[](1);
|
|
105
|
-
hookSpecifications[0] = JBCashOutHookSpecification({hook: this, amount: 0, metadata: bytes("")});
|
|
105
|
+
hookSpecifications[0] = JBCashOutHookSpecification({hook: this, noop: false, amount: 0, metadata: bytes("")});
|
|
106
106
|
|
|
107
107
|
uint256[] memory decodedTokenIds;
|
|
108
108
|
|
|
@@ -136,7 +136,7 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
|
|
|
136
136
|
// Forward the received weight and use this contract as the only pay hook.
|
|
137
137
|
weight = context.weight;
|
|
138
138
|
hookSpecifications = new JBPayHookSpecification[](1);
|
|
139
|
-
hookSpecifications[0] = JBPayHookSpecification({hook: this, amount: 0, metadata: bytes("")});
|
|
139
|
+
hookSpecifications[0] = JBPayHookSpecification({hook: this, noop: false, amount: 0, metadata: bytes("")});
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/// @notice Required by the IJBRulesetDataHook interfaces. Return false to not leak any permissions.
|
|
@@ -175,14 +175,10 @@ library JB721TiersHookLib {
|
|
|
175
175
|
view
|
|
176
176
|
returns (uint256 totalSplitAmount, bytes memory hookMetadata)
|
|
177
177
|
{
|
|
178
|
-
bytes memory data
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
id: JBMetadataResolver.getId({purpose: "pay", target: metadataIdTarget}), metadata: metadata
|
|
183
|
-
});
|
|
184
|
-
if (!found) return (0, bytes(""));
|
|
185
|
-
}
|
|
178
|
+
(bool found, bytes memory data) = JBMetadataResolver.getDataFor({
|
|
179
|
+
id: JBMetadataResolver.getId({purpose: "pay", target: metadataIdTarget}), metadata: metadata
|
|
180
|
+
});
|
|
181
|
+
if (!found) return (0, bytes(""));
|
|
186
182
|
|
|
187
183
|
(, uint16[] memory tierIdsToMint) = abi.decode(data, (bool, uint16[]));
|
|
188
184
|
if (tierIdsToMint.length == 0) return (0, bytes(""));
|
package/test/TestAuditGaps.sol
CHANGED
|
@@ -424,6 +424,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
|
|
|
424
424
|
|
|
425
425
|
/// @dev The block gas limit on mainnet is 30M. We use a generous limit for safety.
|
|
426
426
|
uint256 constant BLOCK_GAS_LIMIT = 30_000_000;
|
|
427
|
+
uint256 constant OPERATING_ENVELOPE_SOFT_LIMIT = 200;
|
|
427
428
|
|
|
428
429
|
// ---------------------------------------------------------------
|
|
429
430
|
// Test 1: Add 100 tiers in a single adjustTiers call
|
|
@@ -446,6 +447,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
|
|
|
446
447
|
reserveBeneficiary: reserveBeneficiary,
|
|
447
448
|
encodedIPFSUri: tokenUris[i % 10],
|
|
448
449
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
450
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
449
451
|
category: uint24(i + 1), // Ascending categories
|
|
450
452
|
discountPercent: 0,
|
|
451
453
|
allowOwnerMint: false,
|
|
@@ -498,6 +500,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
|
|
|
498
500
|
reserveBeneficiary: reserveBeneficiary,
|
|
499
501
|
encodedIPFSUri: tokenUris[i % 10],
|
|
500
502
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
503
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
501
504
|
category: uint24(i + 1),
|
|
502
505
|
discountPercent: 0,
|
|
503
506
|
allowOwnerMint: false,
|
|
@@ -552,6 +555,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
|
|
|
552
555
|
reserveBeneficiary: reserveBeneficiary,
|
|
553
556
|
encodedIPFSUri: tokenUris[i % 10],
|
|
554
557
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
558
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
555
559
|
category: uint24(i + 1),
|
|
556
560
|
discountPercent: 0,
|
|
557
561
|
allowOwnerMint: false,
|
|
@@ -579,6 +583,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
|
|
|
579
583
|
uint16[] memory tierIdsToMint = new uint16[](10);
|
|
580
584
|
uint256 totalCost;
|
|
581
585
|
for (uint256 i; i < 10; i++) {
|
|
586
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
582
587
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
583
588
|
tierIdsToMint[i] = uint16(i + 1);
|
|
584
589
|
totalCost += (i + 1) * 1e15;
|
|
@@ -651,6 +656,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
|
|
|
651
656
|
reserveBeneficiary: reserveBeneficiary,
|
|
652
657
|
encodedIPFSUri: tokenUris[i % 10],
|
|
653
658
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
659
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
654
660
|
category: uint24(i + 1),
|
|
655
661
|
discountPercent: 0,
|
|
656
662
|
allowOwnerMint: false,
|
|
@@ -699,6 +705,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
|
|
|
699
705
|
reserveBeneficiary: reserveBeneficiary,
|
|
700
706
|
encodedIPFSUri: tokenUris[i % 10],
|
|
701
707
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
708
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
702
709
|
category: uint24(i + 1),
|
|
703
710
|
discountPercent: 0,
|
|
704
711
|
allowOwnerMint: false,
|
|
@@ -759,6 +766,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
|
|
|
759
766
|
reserveBeneficiary: reserveBeneficiary,
|
|
760
767
|
encodedIPFSUri: tokenUris[i % 10],
|
|
761
768
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
769
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
762
770
|
category: uint24(i + 1),
|
|
763
771
|
discountPercent: 0,
|
|
764
772
|
allowOwnerMint: false,
|
|
@@ -830,6 +838,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
|
|
|
830
838
|
reserveBeneficiary: reserveBeneficiary,
|
|
831
839
|
encodedIPFSUri: tokenUris[i % 10],
|
|
832
840
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
841
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
833
842
|
category: uint24(i + 1),
|
|
834
843
|
discountPercent: 0,
|
|
835
844
|
allowOwnerMint: false,
|
|
@@ -856,6 +865,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
|
|
|
856
865
|
uint16[] memory tierIdsToMint = new uint16[](50);
|
|
857
866
|
uint256 totalCost;
|
|
858
867
|
for (uint256 i; i < 50; i++) {
|
|
868
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
859
869
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
860
870
|
tierIdsToMint[i] = uint16(i + 1);
|
|
861
871
|
totalCost += (i + 1) * 1e15;
|
|
@@ -900,4 +910,141 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
|
|
|
900
910
|
|
|
901
911
|
emit log_named_uint("Gas used to mint from 50 tiers in single payment", gasUsed);
|
|
902
912
|
}
|
|
913
|
+
|
|
914
|
+
/// @notice The expensive read paths scale with tier count, not just with the beneficiary's holdings.
|
|
915
|
+
/// This test exists to prove that a 100-tier catalog is materially more expensive than a 10-tier catalog even
|
|
916
|
+
/// when the queried user owns zero NFTs.
|
|
917
|
+
function test_operatingEnvelope_balanceOf_100tiersIsMateriallyMoreExpensiveThan10tiers() public {
|
|
918
|
+
uint256 gasFor10 = _measureBalanceOfGas({tierCount: 10});
|
|
919
|
+
uint256 gasFor100 = _measureBalanceOfGas({tierCount: 100});
|
|
920
|
+
|
|
921
|
+
assertGt(gasFor100, gasFor10 * 4, "100-tier balanceOf should be materially more expensive than 10 tiers");
|
|
922
|
+
emit log_named_uint("Gas used for balanceOf (10 tiers)", gasFor10);
|
|
923
|
+
emit log_named_uint("Gas used for balanceOf (100 tiers)", gasFor100);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/// @notice Cash-out accounting also scales with the catalog size because totalCashOutWeight walks the tier set.
|
|
927
|
+
/// We use a ratio check instead of an absolute snapshot so the test stays stable across compiler changes while
|
|
928
|
+
/// still proving the production-scale cost increase.
|
|
929
|
+
function test_operatingEnvelope_totalCashOutWeight_100tiersIsMateriallyMoreExpensiveThan10tiers() public {
|
|
930
|
+
uint256 gasFor10 = _measureTotalCashOutWeightGas({tierCount: 10, mintedCount: 10});
|
|
931
|
+
uint256 gasFor100 = _measureTotalCashOutWeightGas({tierCount: 100, mintedCount: 10});
|
|
932
|
+
|
|
933
|
+
assertGt(
|
|
934
|
+
gasFor100, gasFor10 * 4, "100-tier totalCashOutWeight should be materially more expensive than 10 tiers"
|
|
935
|
+
);
|
|
936
|
+
emit log_named_uint("Gas used for totalCashOutWeight (10 tiers)", gasFor10);
|
|
937
|
+
emit log_named_uint("Gas used for totalCashOutWeight (100 tiers)", gasFor100);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function _measureBalanceOfGas(uint256 tierCount) internal returns (uint256 gasUsed) {
|
|
941
|
+
defaultTierConfig.initialSupply = 10;
|
|
942
|
+
defaultTierConfig.reserveFrequency = 0;
|
|
943
|
+
|
|
944
|
+
ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
|
|
945
|
+
IJB721TiersHookStore hookStore = targetHook.STORE();
|
|
946
|
+
|
|
947
|
+
vm.prank(address(targetHook));
|
|
948
|
+
hookStore.recordAddTiers(_sequentialTierConfigs(tierCount, 1e15, 10));
|
|
949
|
+
|
|
950
|
+
uint256 gasBefore = gasleft();
|
|
951
|
+
hookStore.balanceOf(address(targetHook), beneficiary);
|
|
952
|
+
gasUsed = gasBefore - gasleft();
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function _measureTotalCashOutWeightGas(uint256 tierCount, uint256 mintedCount) internal returns (uint256 gasUsed) {
|
|
956
|
+
defaultTierConfig.initialSupply = 10;
|
|
957
|
+
defaultTierConfig.reserveFrequency = 0;
|
|
958
|
+
|
|
959
|
+
ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
|
|
960
|
+
IJB721TiersHookStore hookStore = targetHook.STORE();
|
|
961
|
+
|
|
962
|
+
vm.prank(address(targetHook));
|
|
963
|
+
hookStore.recordAddTiers(_sequentialTierConfigs(tierCount, 1e15, 10));
|
|
964
|
+
|
|
965
|
+
mockAndExpect(
|
|
966
|
+
address(mockJBDirectory),
|
|
967
|
+
abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
|
|
968
|
+
abi.encode(true)
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
uint16[] memory tierIdsToMint = new uint16[](mintedCount);
|
|
972
|
+
uint256 totalCost;
|
|
973
|
+
for (uint256 i; i < mintedCount; i++) {
|
|
974
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
975
|
+
tierIdsToMint[i] = uint16(i + 1);
|
|
976
|
+
totalCost += (i + 1) * 1e15;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
bytes[] memory data = new bytes[](1);
|
|
980
|
+
data[0] = abi.encode(false, tierIdsToMint);
|
|
981
|
+
bytes4[] memory ids = new bytes4[](1);
|
|
982
|
+
ids[0] = metadataHelper.getId("pay", address(targetHook));
|
|
983
|
+
bytes memory payerMetadata = metadataHelper.createMetadata(ids, data);
|
|
984
|
+
|
|
985
|
+
JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
|
|
986
|
+
payer: beneficiary,
|
|
987
|
+
projectId: projectId,
|
|
988
|
+
rulesetId: 0,
|
|
989
|
+
amount: JBTokenAmount({
|
|
990
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
991
|
+
value: totalCost,
|
|
992
|
+
decimals: 18,
|
|
993
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
994
|
+
}),
|
|
995
|
+
forwardedAmount: JBTokenAmount({
|
|
996
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
997
|
+
value: 0,
|
|
998
|
+
decimals: 18,
|
|
999
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
1000
|
+
}),
|
|
1001
|
+
weight: 10e18,
|
|
1002
|
+
newlyIssuedTokenCount: 0,
|
|
1003
|
+
beneficiary: beneficiary,
|
|
1004
|
+
hookMetadata: bytes(""),
|
|
1005
|
+
payerMetadata: payerMetadata
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
vm.prank(mockTerminalAddress);
|
|
1009
|
+
targetHook.afterPayRecordedWith(payContext);
|
|
1010
|
+
|
|
1011
|
+
uint256 gasBefore = gasleft();
|
|
1012
|
+
hookStore.totalCashOutWeight(address(targetHook));
|
|
1013
|
+
gasUsed = gasBefore - gasleft();
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function _sequentialTierConfigs(
|
|
1017
|
+
uint256 tierCount,
|
|
1018
|
+
uint104 priceStep,
|
|
1019
|
+
uint32 initialSupply
|
|
1020
|
+
)
|
|
1021
|
+
internal
|
|
1022
|
+
view
|
|
1023
|
+
returns (JB721TierConfig[] memory newTiers)
|
|
1024
|
+
{
|
|
1025
|
+
require(tierCount <= OPERATING_ENVELOPE_SOFT_LIMIT, "test helper only sized for envelope coverage");
|
|
1026
|
+
|
|
1027
|
+
newTiers = new JB721TierConfig[](tierCount);
|
|
1028
|
+
for (uint256 i; i < tierCount; i++) {
|
|
1029
|
+
newTiers[i] = JB721TierConfig({
|
|
1030
|
+
price: uint104((i + 1) * priceStep),
|
|
1031
|
+
initialSupply: initialSupply,
|
|
1032
|
+
votingUnits: 0,
|
|
1033
|
+
reserveFrequency: 0,
|
|
1034
|
+
reserveBeneficiary: reserveBeneficiary,
|
|
1035
|
+
encodedIPFSUri: tokenUris[i % 10],
|
|
1036
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
1037
|
+
category: uint24(i + 1),
|
|
1038
|
+
discountPercent: 0,
|
|
1039
|
+
allowOwnerMint: false,
|
|
1040
|
+
useReserveBeneficiaryAsDefault: false,
|
|
1041
|
+
transfersPausable: false,
|
|
1042
|
+
cannotBeRemoved: false,
|
|
1043
|
+
cannotIncreaseDiscountPercent: false,
|
|
1044
|
+
useVotingUnits: false,
|
|
1045
|
+
splitPercent: 0,
|
|
1046
|
+
splits: new JBSplit[](0)
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
903
1050
|
}
|