@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.
- package/foundry.lock +1 -7
- package/foundry.toml +1 -1
- package/package.json +20 -9
- package/script/Deploy.s.sol +2 -2
- package/src/JB721Checkpoints.sol +60 -18
- package/src/JB721CheckpointsDeployer.sol +10 -5
- package/src/JB721TiersHook.sol +4 -1
- package/src/JB721TiersHookProjectDeployer.sol +68 -30
- package/src/JB721TiersHookStore.sol +1 -4
- package/src/interfaces/IJB721Checkpoints.sol +21 -14
- package/src/interfaces/IJB721CheckpointsDeployer.sol +6 -2
- package/src/interfaces/IJB721TiersHookProjectDeployer.sol +2 -0
- package/test/utils/AccessJBLib.sol +49 -0
- package/test/utils/ForTest_JB721TiersHook.sol +246 -0
- package/test/utils/TestBaseWorkflow.sol +213 -0
- package/test/utils/UnitTestSetup.sol +805 -0
- package/.gas-snapshot +0 -152
- package/ADMINISTRATION.md +0 -87
- package/ARCHITECTURE.md +0 -98
- package/AUDIT_INSTRUCTIONS.md +0 -77
- package/RISKS.md +0 -118
- package/SKILLS.md +0 -43
- package/STYLE_GUIDE.md +0 -610
- package/USER_JOURNEYS.md +0 -121
- package/assets/findings/nana-721-hook-v6-pashov-ai-audit-report-20260330-091257.md +0 -83
- package/slither-ci.config.json +0 -10
- package/test/721HookAttacks.t.sol +0 -408
- package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +0 -985
- package/test/Fork.t.sol +0 -2346
- package/test/TestAuditGaps.sol +0 -1075
- package/test/TestCheckpoints.t.sol +0 -341
- package/test/TestSafeTransferReentrancy.t.sol +0 -305
- package/test/TestVotingUnitsLifecycle.t.sol +0 -313
- package/test/audit/AuditRegressions.t.sol +0 -83
- package/test/audit/CrossCurrencySplitNoPrices.t.sol +0 -123
- package/test/audit/FreshAudit.t.sol +0 -197
- package/test/audit/FutureTierPoC.t.sol +0 -39
- package/test/audit/FutureTierRemoval.t.sol +0 -47
- package/test/audit/Pass12L18.t.sol +0 -80
- package/test/audit/PayCreditsBypassTierSplits.t.sol +0 -200
- package/test/audit/ProjectDeployerAuth.t.sol +0 -266
- package/test/audit/RepoFindings.t.sol +0 -195
- package/test/audit/ReserveActivation.t.sol +0 -87
- package/test/audit/RetroactiveReserveBeneficiaryDilution.t.sol +0 -149
- package/test/audit/SameCurrencyDecimalMismatch.t.sol +0 -249
- package/test/audit/SplitCreditsMismatch.t.sol +0 -219
- package/test/audit/SplitFailureRedistribution.t.sol +0 -143
- package/test/audit/USDTVoidReturnCompat.t.sol +0 -301
- package/test/fork/ERC20CashOutFork.t.sol +0 -633
- package/test/fork/ERC20TierSplitFork.t.sol +0 -596
- package/test/fork/IssueTokensForSplitsFork.t.sol +0 -516
- package/test/invariants/TierLifecycleInvariant.t.sol +0 -188
- package/test/invariants/TieredHookStoreInvariant.t.sol +0 -86
- package/test/invariants/handlers/TierLifecycleHandler.sol +0 -300
- package/test/invariants/handlers/TierStoreHandler.sol +0 -165
- package/test/regression/BrokenTerminalDoesNotDos.t.sol +0 -277
- package/test/regression/CacheTierLookup.t.sol +0 -190
- package/test/regression/ProjectDeployerRulesets.t.sol +0 -358
- package/test/regression/ReserveBeneficiaryOverwrite.t.sol +0 -155
- package/test/regression/SplitDistributionBugs.t.sol +0 -751
- package/test/regression/SplitNoBeneficiary.t.sol +0 -140
- package/test/unit/AuditFixes_Unit.t.sol +0 -624
- package/test/unit/JB721CheckpointsDeployer_AccessControl.t.sol +0 -116
- package/test/unit/JB721TiersRulesetMetadataResolver.t.sol +0 -144
- package/test/unit/JBBitmap.t.sol +0 -170
- package/test/unit/JBIpfsDecoder.t.sol +0 -136
- package/test/unit/TierSupplyReserveCheck.t.sol +0 -221
- package/test/unit/adjustTier_Unit.t.sol +0 -1942
- package/test/unit/deployer_Unit.t.sol +0 -114
- package/test/unit/getters_constructor_Unit.t.sol +0 -593
- package/test/unit/mintFor_mintReservesFor_Unit.t.sol +0 -452
- package/test/unit/pay_CrossCurrency_Unit.t.sol +0 -530
- package/test/unit/pay_Unit.t.sol +0 -1661
- package/test/unit/redeem_Unit.t.sol +0 -473
- package/test/unit/relayBeneficiary_Unit.t.sol +0 -182
- package/test/unit/splitHookDistribution_Unit.t.sol +0 -604
- 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)
|
package/slither-ci.config.json
DELETED
|
@@ -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
|
-
}
|