@bananapus/721-hook-v6 0.0.41 → 0.0.43

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 (77) 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 +60 -18
  6. package/src/JB721CheckpointsDeployer.sol +10 -5
  7. package/src/JB721TiersHook.sol +4 -1
  8. package/src/JB721TiersHookProjectDeployer.sol +68 -30
  9. package/src/JB721TiersHookStore.sol +1 -4
  10. package/src/interfaces/IJB721Checkpoints.sol +21 -14
  11. package/src/interfaces/IJB721CheckpointsDeployer.sol +6 -2
  12. package/src/interfaces/IJB721TiersHookProjectDeployer.sol +2 -0
  13. package/test/utils/AccessJBLib.sol +49 -0
  14. package/test/utils/ForTest_JB721TiersHook.sol +246 -0
  15. package/test/utils/TestBaseWorkflow.sol +213 -0
  16. package/test/utils/UnitTestSetup.sol +805 -0
  17. package/.gas-snapshot +0 -152
  18. package/ADMINISTRATION.md +0 -87
  19. package/ARCHITECTURE.md +0 -98
  20. package/AUDIT_INSTRUCTIONS.md +0 -77
  21. package/RISKS.md +0 -118
  22. package/SKILLS.md +0 -43
  23. package/STYLE_GUIDE.md +0 -610
  24. package/USER_JOURNEYS.md +0 -121
  25. package/assets/findings/nana-721-hook-v6-pashov-ai-audit-report-20260330-091257.md +0 -83
  26. package/slither-ci.config.json +0 -10
  27. package/test/721HookAttacks.t.sol +0 -408
  28. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +0 -985
  29. package/test/Fork.t.sol +0 -2346
  30. package/test/TestAuditGaps.sol +0 -1075
  31. package/test/TestCheckpoints.t.sol +0 -341
  32. package/test/TestSafeTransferReentrancy.t.sol +0 -305
  33. package/test/TestVotingUnitsLifecycle.t.sol +0 -313
  34. package/test/audit/AuditRegressions.t.sol +0 -83
  35. package/test/audit/CrossCurrencySplitNoPrices.t.sol +0 -123
  36. package/test/audit/FreshAudit.t.sol +0 -197
  37. package/test/audit/FutureTierPoC.t.sol +0 -39
  38. package/test/audit/FutureTierRemoval.t.sol +0 -47
  39. package/test/audit/Pass12L18.t.sol +0 -80
  40. package/test/audit/PayCreditsBypassTierSplits.t.sol +0 -200
  41. package/test/audit/ProjectDeployerAuth.t.sol +0 -266
  42. package/test/audit/RepoFindings.t.sol +0 -195
  43. package/test/audit/ReserveActivation.t.sol +0 -87
  44. package/test/audit/RetroactiveReserveBeneficiaryDilution.t.sol +0 -149
  45. package/test/audit/SameCurrencyDecimalMismatch.t.sol +0 -249
  46. package/test/audit/SplitCreditsMismatch.t.sol +0 -219
  47. package/test/audit/SplitFailureRedistribution.t.sol +0 -143
  48. package/test/audit/USDTVoidReturnCompat.t.sol +0 -301
  49. package/test/fork/ERC20CashOutFork.t.sol +0 -633
  50. package/test/fork/ERC20TierSplitFork.t.sol +0 -596
  51. package/test/fork/IssueTokensForSplitsFork.t.sol +0 -516
  52. package/test/invariants/TierLifecycleInvariant.t.sol +0 -188
  53. package/test/invariants/TieredHookStoreInvariant.t.sol +0 -86
  54. package/test/invariants/handlers/TierLifecycleHandler.sol +0 -300
  55. package/test/invariants/handlers/TierStoreHandler.sol +0 -165
  56. package/test/regression/BrokenTerminalDoesNotDos.t.sol +0 -277
  57. package/test/regression/CacheTierLookup.t.sol +0 -190
  58. package/test/regression/ProjectDeployerRulesets.t.sol +0 -358
  59. package/test/regression/ReserveBeneficiaryOverwrite.t.sol +0 -155
  60. package/test/regression/SplitDistributionBugs.t.sol +0 -751
  61. package/test/regression/SplitNoBeneficiary.t.sol +0 -140
  62. package/test/unit/AuditFixes_Unit.t.sol +0 -624
  63. package/test/unit/JB721CheckpointsDeployer_AccessControl.t.sol +0 -116
  64. package/test/unit/JB721TiersRulesetMetadataResolver.t.sol +0 -144
  65. package/test/unit/JBBitmap.t.sol +0 -170
  66. package/test/unit/JBIpfsDecoder.t.sol +0 -136
  67. package/test/unit/TierSupplyReserveCheck.t.sol +0 -221
  68. package/test/unit/adjustTier_Unit.t.sol +0 -1942
  69. package/test/unit/deployer_Unit.t.sol +0 -114
  70. package/test/unit/getters_constructor_Unit.t.sol +0 -593
  71. package/test/unit/mintFor_mintReservesFor_Unit.t.sol +0 -452
  72. package/test/unit/pay_CrossCurrency_Unit.t.sol +0 -530
  73. package/test/unit/pay_Unit.t.sol +0 -1661
  74. package/test/unit/redeem_Unit.t.sol +0 -473
  75. package/test/unit/relayBeneficiary_Unit.t.sol +0 -182
  76. package/test/unit/splitHookDistribution_Unit.t.sol +0 -604
  77. 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
- }