@bananapus/721-hook-v6 0.0.42 → 0.0.45

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.
Files changed (86) hide show
  1. package/foundry.lock +1 -7
  2. package/foundry.toml +1 -1
  3. package/package.json +20 -9
  4. package/script/Deploy.s.sol +2 -2
  5. package/src/JB721Checkpoints.sol +61 -19
  6. package/src/JB721CheckpointsDeployer.sol +10 -5
  7. package/src/JB721TiersHook.sol +66 -53
  8. package/src/JB721TiersHookDeployer.sol +8 -5
  9. package/src/JB721TiersHookProjectDeployer.sol +87 -46
  10. package/src/JB721TiersHookStore.sol +137 -107
  11. package/src/abstract/JB721Hook.sol +8 -6
  12. package/src/interfaces/IJB721Checkpoints.sol +21 -14
  13. package/src/interfaces/IJB721CheckpointsDeployer.sol +7 -3
  14. package/src/interfaces/IJB721TiersHook.sol +3 -3
  15. package/src/interfaces/IJB721TiersHookProjectDeployer.sol +4 -2
  16. package/src/interfaces/IJB721TiersHookStore.sol +11 -11
  17. package/src/libraries/JB721TiersHookLib.sol +1 -1
  18. package/src/structs/JB721TiersHookFlags.sol +1 -1
  19. package/src/structs/JBPayDataHookRulesetMetadata.sol +1 -1
  20. package/test/utils/AccessJBLib.sol +49 -0
  21. package/test/utils/ForTest_JB721TiersHook.sol +246 -0
  22. package/test/utils/TestBaseWorkflow.sol +213 -0
  23. package/test/utils/UnitTestSetup.sol +805 -0
  24. package/.gas-snapshot +0 -152
  25. package/ADMINISTRATION.md +0 -87
  26. package/ARCHITECTURE.md +0 -98
  27. package/AUDIT_INSTRUCTIONS.md +0 -77
  28. package/RISKS.md +0 -118
  29. package/SKILLS.md +0 -43
  30. package/STYLE_GUIDE.md +0 -610
  31. package/USER_JOURNEYS.md +0 -121
  32. package/assets/findings/nana-721-hook-v6-pashov-ai-audit-report-20260330-091257.md +0 -83
  33. package/slither-ci.config.json +0 -10
  34. package/test/721HookAttacks.t.sol +0 -408
  35. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +0 -985
  36. package/test/Fork.t.sol +0 -2346
  37. package/test/TestAuditGaps.sol +0 -1075
  38. package/test/TestCheckpoints.t.sol +0 -341
  39. package/test/TestSafeTransferReentrancy.t.sol +0 -305
  40. package/test/TestVotingUnitsLifecycle.t.sol +0 -313
  41. package/test/audit/AuditRegressions.t.sol +0 -83
  42. package/test/audit/CodexNemesisReserveSellout.t.sol +0 -66
  43. package/test/audit/CrossCurrencySplitNoPrices.t.sol +0 -123
  44. package/test/audit/FreshAudit.t.sol +0 -197
  45. package/test/audit/FutureTierPoC.t.sol +0 -39
  46. package/test/audit/FutureTierRemoval.t.sol +0 -47
  47. package/test/audit/Pass12L18.t.sol +0 -80
  48. package/test/audit/PayCreditsBypassTierSplits.t.sol +0 -200
  49. package/test/audit/ProjectDeployerAuth.t.sol +0 -266
  50. package/test/audit/RepoFindings.t.sol +0 -195
  51. package/test/audit/ReserveActivation.t.sol +0 -87
  52. package/test/audit/ReserveSlotProtection.t.sol +0 -273
  53. package/test/audit/RetroactiveReserveBeneficiaryDilution.t.sol +0 -149
  54. package/test/audit/SameCurrencyDecimalMismatch.t.sol +0 -249
  55. package/test/audit/SplitCreditsMismatch.t.sol +0 -219
  56. package/test/audit/SplitFailureRedistribution.t.sol +0 -143
  57. package/test/audit/USDTVoidReturnCompat.t.sol +0 -301
  58. package/test/fork/ERC20CashOutFork.t.sol +0 -633
  59. package/test/fork/ERC20TierSplitFork.t.sol +0 -596
  60. package/test/fork/IssueTokensForSplitsFork.t.sol +0 -516
  61. package/test/invariants/TierLifecycleInvariant.t.sol +0 -188
  62. package/test/invariants/TieredHookStoreInvariant.t.sol +0 -86
  63. package/test/invariants/handlers/TierLifecycleHandler.sol +0 -300
  64. package/test/invariants/handlers/TierStoreHandler.sol +0 -165
  65. package/test/regression/BrokenTerminalDoesNotDos.t.sol +0 -277
  66. package/test/regression/CacheTierLookup.t.sol +0 -190
  67. package/test/regression/ProjectDeployerRulesets.t.sol +0 -358
  68. package/test/regression/ReserveBeneficiaryOverwrite.t.sol +0 -155
  69. package/test/regression/SplitDistributionBugs.t.sol +0 -751
  70. package/test/regression/SplitNoBeneficiary.t.sol +0 -140
  71. package/test/unit/AuditFixes_Unit.t.sol +0 -624
  72. package/test/unit/JB721CheckpointsDeployer_AccessControl.t.sol +0 -116
  73. package/test/unit/JB721TiersRulesetMetadataResolver.t.sol +0 -144
  74. package/test/unit/JBBitmap.t.sol +0 -170
  75. package/test/unit/JBIpfsDecoder.t.sol +0 -136
  76. package/test/unit/TierSupplyReserveCheck.t.sol +0 -221
  77. package/test/unit/adjustTier_Unit.t.sol +0 -1942
  78. package/test/unit/deployer_Unit.t.sol +0 -114
  79. package/test/unit/getters_constructor_Unit.t.sol +0 -593
  80. package/test/unit/mintFor_mintReservesFor_Unit.t.sol +0 -452
  81. package/test/unit/pay_CrossCurrency_Unit.t.sol +0 -530
  82. package/test/unit/pay_Unit.t.sol +0 -1661
  83. package/test/unit/redeem_Unit.t.sol +0 -473
  84. package/test/unit/relayBeneficiary_Unit.t.sol +0 -182
  85. package/test/unit/splitHookDistribution_Unit.t.sol +0 -604
  86. package/test/unit/tierSplitRouting_Unit.t.sol +0 -757
package/USER_JOURNEYS.md DELETED
@@ -1,121 +0,0 @@
1
- # User Journeys
2
-
3
- ## Repo Purpose
4
-
5
- This repo adds tiered NFT logic to Juicebox payment and cash-out flows. It owns tier pricing, reserves, and NFT lifecycle state. It does not own project-specific artwork or game logic.
6
-
7
- ## Primary Actors
8
-
9
- - projects that want priced NFT tiers in their Juicebox flow
10
- - operators managing tier configuration and hook permissions
11
- - holders minting, transferring, and cashing out tiered NFTs
12
- - auditors reviewing tier accounting, reserve behavior, and deployer wiring
13
-
14
- ## Key Surfaces
15
-
16
- - `JB721TiersHook`: runtime 721 hook behavior
17
- - `JB721TiersHookStore`: tier accounting and supply state
18
- - `JB721TiersHookDeployer` and `JB721TiersHookProjectDeployer`: wiring surfaces
19
- - token URI resolver contracts in downstream repos: presentation layer
20
-
21
- ## Journey 1: Launch A Project With A Tiered NFT Hook
22
-
23
- **Actor:** project operator or deployer.
24
-
25
- **Intent:** attach tiered NFT issuance to a project from the start.
26
-
27
- **Preconditions**
28
- - the project knows its tier structure and hook expectations
29
- - the deployer path matches whether the project already exists
30
-
31
- **Main Flow**
32
- 1. Deploy a hook clone or launch a project with the hook already attached.
33
- 2. Configure tier data, hook metadata, and resolver expectations.
34
- 3. Transfer hook ownership into the intended project control surface.
35
-
36
- **Failure Modes**
37
- - wrong hook wiring at launch
38
- - wrong resolver assumptions
39
- - teams treat deployer convenience as proof that runtime economics are correct
40
-
41
- **Postconditions**
42
- - the project has a tiered NFT hook wired into its Juicebox flow
43
-
44
- ## Journey 2: Pay And Mint Tiered NFTs
45
-
46
- **Actor:** payer or integration acting for a payer.
47
-
48
- **Intent:** mint NFTs from configured tiers while preserving the project's terminal flow.
49
-
50
- **Preconditions**
51
- - the project has active tiers
52
- - payment metadata correctly names the intended tiers
53
-
54
- **Main Flow**
55
- 1. A payment reaches the hook through the terminal.
56
- 2. The hook decodes tier selection and records mint state in the store.
57
- 3. NFTs mint, reserve implications update, and any split routing is applied.
58
-
59
- **Failure Modes**
60
- - malformed metadata
61
- - currency mismatch or missing pricing support
62
- - splits or discounts behave differently than the integration expected
63
-
64
- **Postconditions**
65
- - the payer or beneficiary receives the intended NFT tiers and tier state updates
66
-
67
- ## Journey 3: Mint Or Release Reserve NFTs
68
-
69
- **Actor:** reserve beneficiary, operator, or any caller using the reserve path.
70
-
71
- **Intent:** realize pending reserves under the configured reserve rules.
72
-
73
- **Preconditions**
74
- - the relevant tiers have reserve logic enabled
75
- - the ruleset does not pause pending reserve minting
76
-
77
- **Main Flow**
78
- 1. Eligible reserve amounts accumulate as mint activity happens.
79
- 2. A caller triggers reserve minting for pending tiers.
80
- 3. The store moves reserve state forward and NFTs mint to the configured reserve beneficiary.
81
-
82
- **Failure Modes**
83
- - teams misunderstand that reserve minting timing is not owner-exclusive
84
- - reserve assumptions drift from actual tier settings
85
-
86
- **Postconditions**
87
- - pending reserves mint according to tier configuration
88
-
89
- ## Journey 4: Cash Out Tiered NFTs
90
-
91
- **Actor:** NFT holder.
92
-
93
- **Intent:** redeem tiered NFT exposure through the terminal cash-out path.
94
-
95
- **Preconditions**
96
- - the holder owns valid NFTs
97
- - the hook is active for the cash-out path
98
-
99
- **Main Flow**
100
- 1. The holder requests a cash out with NFT-specific metadata.
101
- 2. The hook burns the selected NFTs and records the burn in the store.
102
- 3. The terminal completes reclaim logic using the hook-aware cash-out surface.
103
-
104
- **Failure Modes**
105
- - integrations mix fungible-token and NFT cash-out assumptions
106
- - pending reserves or discounts are misunderstood in value calculations
107
- - token IDs are invalid or already burned
108
-
109
- **Postconditions**
110
- - NFTs burn and reclaim value follows the intended tier model
111
-
112
- ## Trust Boundaries
113
-
114
- - this repo trusts core terminals, directory checks, and pricing surfaces from `nana-core-v6`
115
- - metadata resolvers are outside this repo but still affect user-visible trust
116
- - the store is the main source of truth for tier lifecycle state
117
-
118
- ## Hand-Offs
119
-
120
- - Use [nana-core-v6](../nana-core-v6/USER_JOURNEYS.md) for the underlying terminal and accounting behavior.
121
- - Use the downstream resolver repo when the question is about project-specific metadata or rendering.
@@ -1,83 +0,0 @@
1
- # 🔐 Security Review — nana-721-hook-v6
2
-
3
- ---
4
-
5
- ## Scope
6
-
7
- | | |
8
- | -------------------------------- | ------------------------------------------------------ |
9
- | **Mode** | ALL / default |
10
- | **Files reviewed** | `script/Deploy.s.sol` · `script/helpers/Hook721DeploymentLib.sol` · `src/JB721TiersHook.sol`<br>`src/JB721TiersHookDeployer.sol` · `src/JB721TiersHookProjectDeployer.sol` · `src/JB721TiersHookStore.sol`<br>`src/abstract/ERC721.sol` · `src/abstract/JB721Hook.sol` · `src/libraries/JB721Constants.sol`<br>`src/libraries/JB721TiersHookLib.sol` · `src/libraries/JB721TiersRulesetMetadataResolver.sol` · `src/libraries/JBBitmap.sol`<br>`src/libraries/JBIpfsDecoder.sol` · `src/structs/JB721InitTiersConfig.sol` · `src/structs/JB721Tier.sol`<br>`src/structs/JB721TierConfig.sol` · `src/structs/JB721TiersHookFlags.sol` · `src/structs/JB721TiersMintReservesConfig.sol`<br>`src/structs/JB721TiersRulesetMetadata.sol` · `src/structs/JB721TiersSetDiscountPercentConfig.sol` · `src/structs/JBBitmapWord.sol`<br>`src/structs/JBDeploy721TiersHookConfig.sol` · `src/structs/JBLaunchProjectConfig.sol` · `src/structs/JBLaunchRulesetsConfig.sol`<br>`src/structs/JBPayDataHookRulesetConfig.sol` · `src/structs/JBPayDataHookRulesetMetadata.sol` · `src/structs/JBQueueRulesetsConfig.sol`<br>`src/structs/JBStored721Tier.sol` |
11
- | **Confidence threshold (1-100)** | 75 |
12
-
13
- ---
14
-
15
- ## Findings
16
-
17
- [90] **1. Pay Credits Let Buyers Bypass Tier Split Payments**
18
-
19
- `JB721TiersHook._mintAndUpdateCredits` · Confidence: 90
20
-
21
- **Description**
22
- `payCreditsOf` is merged into NFT purchasing power, but tier split payouts are still capped to `context.forwardedAmount.value`, so a buyer can mint a split-bearing tier mostly with credits while paying only a dust-sized fresh split.
23
-
24
- **Fix**
25
-
26
- ```diff
27
- - leftoverAmount += payCredits;
28
- - if (context.hookMetadata.length != 0 && context.forwardedAmount.value != 0) {
29
- - JB721TiersHookLib.distributeAll(... amount: context.forwardedAmount.value, ...);
30
- - }
31
- + uint256 creditBackedAmount = context.payer == context.beneficiary ? payCredits : 0;
32
- + uint256 splitFundingAmount = context.forwardedAmount.value + creditBackedAmountUsedForMint;
33
- + if (context.hookMetadata.length != 0 && splitFundingAmount != 0) {
34
- + JB721TiersHookLib.distributeAll(... amount: splitFundingAmount, ...);
35
- + }
36
- ```
37
- ---
38
-
39
- [90] **2. Failed Early Splits Are Redistributed to Later Recipients**
40
-
41
- `JB721TiersHookLib._distributeSingleSplit` · Confidence: 90
42
-
43
- **Description**
44
- When an early split payout fails, its amount is added back into `leftoverAmount` before later splits are calculated, so later recipients can receive the failed recipient’s share instead of that value falling back to project balance.
45
-
46
- **Fix**
47
-
48
- ```diff
49
- - if (!_sendPayoutToSplit(...)) {
50
- - leftoverAmount += payoutAmount;
51
- - }
52
- + if (!_sendPayoutToSplit(...)) {
53
- + failedAmount += payoutAmount;
54
- + }
55
- ...
56
- - if (leftoverAmount != 0) {
57
- - terminal.addToBalanceOf(... leftoverAmount ...);
58
- + uint256 amountToProject = leftoverAmount + failedAmount;
59
- + if (amountToProject != 0) {
60
- + terminal.addToBalanceOf(... amountToProject ...);
61
- ```
62
-
63
- ---
64
-
65
- Findings List
66
-
67
- | # | Confidence | Title |
68
- |---|---|---|
69
- | 1 | [90] | Pay Credits Let Buyers Bypass Tier Split Payments |
70
- | 2 | [90] | Failed Early Splits Are Redistributed to Later Recipients |
71
-
72
- ---
73
-
74
- ## Leads
75
-
76
- _Vulnerability trails with concrete code smells where the full exploit path could not be completed in one analysis pass. These are not false positives — they are high-signal leads for manual review. Not scored._
77
-
78
- - **Public Address Registry Can Grief Hook Deployments** — `JB721TiersHookDeployer.deployHookFor` — Code smells: deployment success depends on `JBAddressRegistry.registerAddress`, and the registry is permissionless plus duplicate-registration-reverting — A third party can likely pre-register a predicted hook address and make `deployHookFor` revert at the registry step, but the practical griefing envelope depends on deployment mode and the caller’s ability to retry with a different salt.
79
- - **Shared `_nonce` Can Desync Registry Provenance After Mixed CREATE/CREATE2 Deployments** — `JB721TiersHookDeployer.deployHookFor` — Code smells: `_nonce` is incremented for both deterministic and non-deterministic deployments, while only the CREATE path consumes the deployer nonce the registry models — Mixed deployment modes may cause later saltless registrations to point at the wrong CREATE address, but I did not complete an end-to-end exploit showing downstream trust assumptions being violated.
80
-
81
- ---
82
-
83
- > ⚠️ This review was performed by an AI assistant. AI analysis can never verify the complete absence of vulnerabilities and no guarantee of security is given. Team security reviews, bug bounty programs, and on-chain monitoring are strongly recommended. For a consultation regarding your projects' security, visit [https://www.pashov.com](https://www.pashov.com)
@@ -1,10 +0,0 @@
1
- {
2
- "detectors_to_exclude": "timestamp,uninitialized-local,naming-convention,solc-version,shadowing-local",
3
- "exclude_informational": true,
4
- "exclude_low": false,
5
- "exclude_medium": false,
6
- "exclude_high": false,
7
- "disable_color": false,
8
- "filter_paths": "(mocks/|test/|node_modules/|lib/)",
9
- "legacy_ast": false
10
- }
@@ -1,408 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.28;
3
-
4
- // forge-lint: disable-next-line(unaliased-plain-import)
5
- import "./utils/UnitTestSetup.sol";
6
- import {JB721TierConfigFlags} from "../src/structs/JB721TierConfigFlags.sol";
7
-
8
- /// @title 721HookAttacks
9
- /// @notice Adversarial security tests for JB721TiersHook and JB721TiersHookStore.
10
- contract NFTHookAttacks is UnitTestSetup {
11
- using stdStorage for StdStorage;
12
-
13
- // =========================================================================
14
- // Helpers
15
- // =========================================================================
16
-
17
- /// @dev Mock the directory to accept `mockTerminalAddress` as a terminal for `projectId`.
18
- function _mockTerminalAuth() internal {
19
- mockAndExpect(
20
- mockJBDirectory,
21
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
22
- abi.encode(true)
23
- );
24
- }
25
-
26
- /// @dev Create a pay context that requests minting specific tier IDs.
27
- function _buildPayContext(
28
- address targetHook,
29
- uint256 value,
30
- uint16[] memory tierIds
31
- )
32
- internal
33
- view
34
- returns (JBAfterPayRecordedContext memory)
35
- {
36
- bytes[] memory data = new bytes[](1);
37
- data[0] = abi.encode(false, tierIds);
38
- bytes4[] memory ids = new bytes4[](1);
39
- ids[0] = metadataHelper.getId("pay", targetHook);
40
- bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
41
-
42
- return JBAfterPayRecordedContext({
43
- payer: beneficiary,
44
- projectId: projectId,
45
- rulesetId: 0,
46
- amount: JBTokenAmount({
47
- token: JBConstants.NATIVE_TOKEN,
48
- value: value,
49
- decimals: 18,
50
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
51
- }),
52
- forwardedAmount: JBTokenAmount({
53
- token: JBConstants.NATIVE_TOKEN,
54
- value: 0,
55
- decimals: 18,
56
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
57
- }),
58
- weight: 10 ** 18,
59
- newlyIssuedTokenCount: 0,
60
- beneficiary: beneficiary,
61
- hookMetadata: bytes(""),
62
- payerMetadata: hookMetadata
63
- });
64
- }
65
-
66
- // =========================================================================
67
- // Test 1: Zero-price tier — can an attacker mint for free?
68
- // =========================================================================
69
- /// @notice Add a tier with price=0 via adjustTiers. Verify the hook handles it correctly.
70
- function test_zeroPriceTier_mintBehavior() public {
71
- // Create hook with 1 default tier (price=10).
72
- ForTest_JB721TiersHook targetHook = _initializeForTestHook(1);
73
-
74
- // Add a zero-price tier via adjustTiers (tier ID 2).
75
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](1);
76
- newTiers[0] = JB721TierConfig({
77
- price: 0,
78
- initialSupply: 100,
79
- votingUnits: 0,
80
- reserveFrequency: 0,
81
- reserveBeneficiary: reserveBeneficiary,
82
- encodedIPFSUri: tokenUris[0],
83
- category: 2,
84
- discountPercent: 0,
85
- flags: JB721TierConfigFlags({
86
- allowOwnerMint: false,
87
- useReserveBeneficiaryAsDefault: false,
88
- transfersPausable: false,
89
- useVotingUnits: false,
90
- cantBeRemoved: false,
91
- cantIncreaseDiscountPercent: false,
92
- cantBuyWithCredits: false
93
- }),
94
- splitPercent: 0,
95
- splits: new JBSplit[](0)
96
- });
97
-
98
- vm.prank(owner);
99
- targetHook.adjustTiers(newTiers, new uint256[](0));
100
-
101
- _mockTerminalAuth();
102
-
103
- // Try to mint tier 2 (price=0) with 0 value.
104
- uint16[] memory tierIds = new uint16[](1);
105
- tierIds[0] = 2;
106
-
107
- JBAfterPayRecordedContext memory ctx = _buildPayContext(address(targetHook), 0, tierIds);
108
-
109
- vm.prank(mockTerminalAddress);
110
- targetHook.afterPayRecordedWith(ctx);
111
-
112
- // Verify the NFT was minted to the beneficiary.
113
- assertEq(targetHook.balanceOf(beneficiary), 1, "Should mint 1 NFT at price 0");
114
- }
115
-
116
- // =========================================================================
117
- // Test 2: Discount percent at maximum — effective price becomes 0
118
- // =========================================================================
119
- /// @notice Set discount to 100%, verify the effective price for the tier.
120
- function test_maxDiscountPercent_effectivePrice() public {
121
- defaultTierConfig.discountPercent = 0;
122
- defaultTierConfig.flags.cantIncreaseDiscountPercent = false;
123
-
124
- JB721TiersHook targetHook = _initHookDefaultTiers(1);
125
-
126
- // Owner sets discount to 100%.
127
- vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
128
-
129
- vm.prank(owner);
130
- targetHook.setDiscountPercentOf(1, 100);
131
-
132
- // Read the tier and verify the discount was applied.
133
- JB721Tier memory tier = store.tierOf(address(targetHook), 1, false);
134
- assertEq(tier.discountPercent, 100, "Discount should be 100%");
135
- }
136
-
137
- // =========================================================================
138
- // Test 3: cantIncreaseDiscountPercent flag enforcement
139
- // =========================================================================
140
- /// @notice Try to increase discount when the flag forbids it.
141
- function test_cantIncreaseDiscountPercent_enforcement() public {
142
- defaultTierConfig.discountPercent = 10;
143
- defaultTierConfig.flags.cantIncreaseDiscountPercent = true;
144
-
145
- JB721TiersHook targetHook = _initHookDefaultTiers(1);
146
-
147
- vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
148
-
149
- // Try to increase discount from 10 to 50 — should revert.
150
- vm.prank(owner);
151
- vm.expectRevert();
152
- targetHook.setDiscountPercentOf(1, 50);
153
-
154
- // Decreasing should still work.
155
- vm.prank(owner);
156
- targetHook.setDiscountPercentOf(1, 5);
157
-
158
- JB721Tier memory tier = store.tierOf(address(targetHook), 1, false);
159
- assertEq(tier.discountPercent, 5, "Discount decrease should work");
160
- }
161
-
162
- // =========================================================================
163
- // Test 4: Reserve minting drain — high reserve frequency
164
- // =========================================================================
165
- /// @notice With reserveFrequency=1 (reserve on every mint), mint 5 paid NFTs
166
- /// then call mintPendingReservesFor to drain reserves.
167
- function test_reserveDrain_highFrequency() public {
168
- defaultTierConfig.initialSupply = 100;
169
- defaultTierConfig.reserveFrequency = 1; // Reserve 1 per 1 paid mint.
170
-
171
- ForTest_JB721TiersHook targetHook = _initializeForTestHook(1);
172
- IJB721TiersHookStore hookStore = targetHook.STORE();
173
-
174
- _mockTerminalAuth();
175
-
176
- // Mint 5 paid NFTs from tier 1 (price=10 each, so value=50).
177
- uint16[] memory tierIds = new uint16[](5);
178
- for (uint256 i; i < 5; i++) {
179
- tierIds[i] = 1;
180
- }
181
-
182
- JBAfterPayRecordedContext memory ctx = _buildPayContext(address(targetHook), 50, tierIds);
183
-
184
- vm.prank(mockTerminalAddress);
185
- targetHook.afterPayRecordedWith(ctx);
186
-
187
- assertEq(targetHook.balanceOf(beneficiary), 5, "5 paid NFTs minted");
188
-
189
- // Pending reserves should be 5 (1 per paid mint with frequency=1).
190
- // With frequency=1: reserveCount = nftsMinted / frequency = 5, plus 1 if remainder > 0.
191
- // 5/1 = 5, remainder 0, so pending = 5+1 = 6? Actually the formula is:
192
- // numberOfPendingReservesFor = (numberOfMints + frequency - 1) / frequency - processedReserves
193
- // Let's just check what the store reports.
194
- uint256 pending = hookStore.numberOfPendingReservesFor(address(targetHook), 1);
195
- assertTrue(pending > 0, "Should have pending reserves");
196
-
197
- // Mint all pending reserves.
198
- vm.prank(owner);
199
- targetHook.mintPendingReservesFor(1, pending);
200
-
201
- // After minting, pending should be 0.
202
- uint256 pendingAfter = hookStore.numberOfPendingReservesFor(address(targetHook), 1);
203
- assertEq(pendingAfter, 0, "No pending reserves after minting");
204
-
205
- // Try to mint more reserves — should revert (nothing pending).
206
- vm.prank(owner);
207
- vm.expectRevert();
208
- targetHook.mintPendingReservesFor(1, 1);
209
- }
210
-
211
- // =========================================================================
212
- // Test 5: Cash-out weight after tier removal
213
- // =========================================================================
214
- /// @notice Mint NFTs from a tier, then remove the tier. Verify that
215
- /// totalCashOutWeight still accounts for the minted tokens.
216
- function test_cashOutWeight_afterTierRemoval() public {
217
- defaultTierConfig.initialSupply = 100;
218
- defaultTierConfig.votingUnits = 10;
219
- defaultTierConfig.flags.useVotingUnits = true;
220
-
221
- ForTest_JB721TiersHook targetHook = _initializeForTestHook(1);
222
- IJB721TiersHookStore hookStore = targetHook.STORE();
223
-
224
- _mockTerminalAuth();
225
-
226
- // Mint 3 NFTs from tier 1 (price=10 each, value=30).
227
- uint16[] memory tierIds = new uint16[](3);
228
- tierIds[0] = 1;
229
- tierIds[1] = 1;
230
- tierIds[2] = 1;
231
-
232
- JBAfterPayRecordedContext memory ctx = _buildPayContext(address(targetHook), 30, tierIds);
233
-
234
- vm.prank(mockTerminalAddress);
235
- targetHook.afterPayRecordedWith(ctx);
236
-
237
- assertEq(targetHook.balanceOf(beneficiary), 3, "3 NFTs minted");
238
-
239
- // Get total cash-out weight before removal (from the store).
240
- uint256 weightBefore = hookStore.totalCashOutWeight(address(targetHook));
241
-
242
- // Remove tier 1.
243
- vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
244
-
245
- uint256[] memory tierIdsToRemove = new uint256[](1);
246
- tierIdsToRemove[0] = 1;
247
-
248
- vm.prank(owner);
249
- targetHook.adjustTiers(new JB721TierConfig[](0), tierIdsToRemove);
250
-
251
- // Verify the tier is removed.
252
- assertTrue(hookStore.isTierRemoved(address(targetHook), 1), "Tier should be removed");
253
-
254
- // Total cash-out weight should still include the minted tokens.
255
- uint256 weightAfter = hookStore.totalCashOutWeight(address(targetHook));
256
- assertEq(weightAfter, weightBefore, "Cash-out weight should be preserved after removal");
257
- }
258
-
259
- // =========================================================================
260
- // Test 6: Invalid tier ID in pay metadata — reverts when overspending prevented
261
- // =========================================================================
262
- /// @notice Pass a tier ID that doesn't exist. With preventOverspending=true, must revert.
263
- function test_invalidTierIdInMetadata_reverts() public {
264
- // Use preventOverspending=true so invalid tiers cause a revert.
265
- JB721TiersHook targetHook = _initHookDefaultTiers(1, true);
266
-
267
- _mockTerminalAuth();
268
-
269
- // Try to mint tier 999 which doesn't exist.
270
- uint16[] memory tierIds = new uint16[](1);
271
- tierIds[0] = 999;
272
-
273
- JBAfterPayRecordedContext memory ctx = _buildPayContext(address(targetHook), 1 ether, tierIds);
274
-
275
- vm.prank(mockTerminalAddress);
276
- vm.expectRevert();
277
- targetHook.afterPayRecordedWith(ctx);
278
- }
279
-
280
- // =========================================================================
281
- // Test 7: Duplicate tier IDs in pay metadata — mints multiple NFTs
282
- // =========================================================================
283
- /// @notice Pass the same tier ID multiple times. Should mint multiple NFTs
284
- /// from that tier.
285
- function test_duplicateTierIdsInMetadata_mintsMultiple() public {
286
- defaultTierConfig.initialSupply = 100;
287
-
288
- ForTest_JB721TiersHook targetHook = _initializeForTestHook(1);
289
- IJB721TiersHookStore hookStore = targetHook.STORE();
290
-
291
- _mockTerminalAuth();
292
-
293
- // Mint 3 of the same tier (price=10 each, value=30).
294
- uint16[] memory tierIds = new uint16[](3);
295
- tierIds[0] = 1;
296
- tierIds[1] = 1;
297
- tierIds[2] = 1;
298
-
299
- JBAfterPayRecordedContext memory ctx = _buildPayContext(address(targetHook), 30, tierIds);
300
-
301
- vm.prank(mockTerminalAddress);
302
- targetHook.afterPayRecordedWith(ctx);
303
-
304
- assertEq(targetHook.balanceOf(beneficiary), 3, "3 NFTs minted from same tier");
305
-
306
- // Verify remaining supply decreased.
307
- JB721Tier memory tier = hookStore.tierOf(address(targetHook), 1, false);
308
- assertEq(tier.remainingSupply, 97, "Supply should decrease by 3");
309
- }
310
-
311
- // =========================================================================
312
- // Test 8: Supply exhaustion — no additional NFTs minted after supply drained
313
- // =========================================================================
314
- /// @notice Mint the entire supply of a tier, then verify no more can be minted.
315
- function test_supplyExhaustion_noOvermint() public {
316
- defaultTierConfig.initialSupply = 3; // Only 3 available.
317
- defaultTierConfig.reserveFrequency = 0; // No reserves to keep it simple.
318
-
319
- ForTest_JB721TiersHook targetHook = _initializeForTestHook(1);
320
- IJB721TiersHookStore hookStore = targetHook.STORE();
321
-
322
- _mockTerminalAuth();
323
-
324
- // Mint all 3 (price=10 each, value=30).
325
- uint16[] memory tierIds = new uint16[](3);
326
- tierIds[0] = 1;
327
- tierIds[1] = 1;
328
- tierIds[2] = 1;
329
-
330
- JBAfterPayRecordedContext memory ctx = _buildPayContext(address(targetHook), 30, tierIds);
331
-
332
- vm.prank(mockTerminalAddress);
333
- targetHook.afterPayRecordedWith(ctx);
334
-
335
- assertEq(targetHook.balanceOf(beneficiary), 3, "All 3 minted");
336
-
337
- // Verify supply is exhausted.
338
- JB721Tier memory tier = hookStore.tierOf(address(targetHook), 1, false);
339
- assertEq(tier.remainingSupply, 0, "No remaining supply");
340
-
341
- // Try to mint one more — store enforces supply limit and reverts.
342
- uint16[] memory oneMore = new uint16[](1);
343
- oneMore[0] = 1;
344
-
345
- JBAfterPayRecordedContext memory ctx2 = _buildPayContext(address(targetHook), 10, oneMore);
346
-
347
- vm.prank(mockTerminalAddress);
348
- vm.expectRevert();
349
- targetHook.afterPayRecordedWith(ctx2);
350
- }
351
-
352
- // =========================================================================
353
- // Test 9: adjustTiers without permission — must revert
354
- // =========================================================================
355
- /// @notice Non-owner without ADJUST_721_TIERS permission tries to add/remove tiers.
356
- function test_adjustTiers_noPermission_reverts() public {
357
- JB721TiersHook targetHook = _initHookDefaultTiers(1);
358
-
359
- // Mock permissions to return false.
360
- vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(false));
361
-
362
- address attacker = makeAddr("attacker");
363
-
364
- // Try to add a new tier.
365
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](1);
366
- newTiers[0] = JB721TierConfig({
367
- price: 1,
368
- initialSupply: type(uint32).max,
369
- votingUnits: 0,
370
- reserveFrequency: 0,
371
- reserveBeneficiary: attacker,
372
- encodedIPFSUri: tokenUris[0],
373
- category: 1,
374
- discountPercent: 0,
375
- flags: JB721TierConfigFlags({
376
- allowOwnerMint: true,
377
- useReserveBeneficiaryAsDefault: false,
378
- transfersPausable: false,
379
- useVotingUnits: false,
380
- cantBeRemoved: false,
381
- cantIncreaseDiscountPercent: false,
382
- cantBuyWithCredits: false
383
- }),
384
- splitPercent: 0,
385
- splits: new JBSplit[](0)
386
- });
387
-
388
- vm.prank(attacker);
389
- vm.expectRevert();
390
- targetHook.adjustTiers(newTiers, new uint256[](0));
391
- }
392
-
393
- // =========================================================================
394
- // Test 10: Tier with max supply — verify no overflow
395
- // =========================================================================
396
- /// @notice Add a tier with initialSupply = 999_999_999 (store maximum).
397
- /// Verify it's created correctly and doesn't overflow.
398
- function test_maxSupplyTier_noOverflow() public {
399
- defaultTierConfig.initialSupply = 999_999_999; // Store maximum
400
-
401
- JB721TiersHook targetHook = _initHookDefaultTiers(1);
402
-
403
- // Read the tier to verify the supply was stored correctly.
404
- JB721Tier memory tier = store.tierOf(address(targetHook), 1, false);
405
- assertEq(tier.initialSupply, 999_999_999, "Initial supply should be 999_999_999");
406
- assertEq(tier.remainingSupply, 999_999_999, "Remaining supply should be 999_999_999");
407
- }
408
- }