@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/test/TestAuditGaps.sol
DELETED
|
@@ -1,1075 +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 {IJB721TiersHookStore} from "../src/interfaces/IJB721TiersHookStore.sol";
|
|
7
|
-
import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
|
|
8
|
-
import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
|
|
9
|
-
import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
|
|
10
|
-
import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
|
|
11
|
-
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
12
|
-
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
13
|
-
import {JB721TierConfigFlags} from "../src/structs/JB721TierConfigFlags.sol";
|
|
14
|
-
|
|
15
|
-
// =====================================================================
|
|
16
|
-
// Malicious split hook that attempts reentrancy during fund distribution
|
|
17
|
-
// =====================================================================
|
|
18
|
-
|
|
19
|
-
/// @notice A split hook that re-enters the hook's afterPayRecordedWith during split distribution.
|
|
20
|
-
contract ReentrantSplitHook is IJBSplitHook {
|
|
21
|
-
address public target;
|
|
22
|
-
bytes public reentrantCalldata;
|
|
23
|
-
uint256 public callCount;
|
|
24
|
-
bool public reentryAttempted;
|
|
25
|
-
bool public reentrySucceeded;
|
|
26
|
-
|
|
27
|
-
constructor(address _target, bytes memory _calldata) {
|
|
28
|
-
target = _target;
|
|
29
|
-
reentrantCalldata = _calldata;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function processSplitWith(JBSplitHookContext calldata) external payable override {
|
|
33
|
-
callCount++;
|
|
34
|
-
// Attempt reentrancy on the first call only.
|
|
35
|
-
if (callCount == 1) {
|
|
36
|
-
reentryAttempted = true;
|
|
37
|
-
// Try to re-enter the hook contract by calling afterPayRecordedWith again.
|
|
38
|
-
// This should revert because msg.sender is not a terminal.
|
|
39
|
-
(bool success,) = target.call{value: 0}(reentrantCalldata);
|
|
40
|
-
reentrySucceeded = success;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
|
|
45
|
-
return interfaceId == type(IJBSplitHook).interfaceId || interfaceId == type(IERC165).interfaceId;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
receive() external payable {}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/// @notice A split hook that attempts to re-enter adjustTiers during split distribution.
|
|
52
|
-
contract ReentrantAdjustTiersSplitHook is IJBSplitHook {
|
|
53
|
-
address public hookTarget;
|
|
54
|
-
uint256 public callCount;
|
|
55
|
-
bool public reentryAttempted;
|
|
56
|
-
bool public reentryReverted;
|
|
57
|
-
|
|
58
|
-
constructor(address _hookTarget) {
|
|
59
|
-
hookTarget = _hookTarget;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function processSplitWith(JBSplitHookContext calldata) external payable override {
|
|
63
|
-
callCount++;
|
|
64
|
-
if (callCount == 1) {
|
|
65
|
-
reentryAttempted = true;
|
|
66
|
-
// Try to re-enter via adjustTiers (remove tier 1).
|
|
67
|
-
uint256[] memory tierIdsToRemove = new uint256[](1);
|
|
68
|
-
tierIdsToRemove[0] = 1;
|
|
69
|
-
// This should revert because caller is not the owner/permissioned.
|
|
70
|
-
try IJB721TiersHook(hookTarget).adjustTiers(new JB721TierConfig[](0), tierIdsToRemove) {
|
|
71
|
-
reentryReverted = false;
|
|
72
|
-
} catch {
|
|
73
|
-
reentryReverted = true;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
|
|
79
|
-
return interfaceId == type(IJBSplitHook).interfaceId || interfaceId == type(IERC165).interfaceId;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
receive() external payable {}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// =====================================================================
|
|
86
|
-
// Test Contract: Reentrancy on Split Distribution
|
|
87
|
-
// =====================================================================
|
|
88
|
-
|
|
89
|
-
/// @title TestAuditGaps_Reentrancy
|
|
90
|
-
/// @notice Tests that malicious split hooks cannot exploit reentrancy during NFT split fund distribution.
|
|
91
|
-
contract TestAuditGaps_Reentrancy is UnitTestSetup {
|
|
92
|
-
using stdStorage for StdStorage;
|
|
93
|
-
|
|
94
|
-
function setUp() public override {
|
|
95
|
-
super.setUp();
|
|
96
|
-
vm.etch(mockJBSplits, new bytes(0x69));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// ---------------------------------------------------------------
|
|
100
|
-
// Helpers
|
|
101
|
-
// ---------------------------------------------------------------
|
|
102
|
-
|
|
103
|
-
function _tierConfigWithSplit(
|
|
104
|
-
uint104 price,
|
|
105
|
-
uint32 splitPercent
|
|
106
|
-
)
|
|
107
|
-
internal
|
|
108
|
-
pure
|
|
109
|
-
returns (JB721TierConfig memory config)
|
|
110
|
-
{
|
|
111
|
-
config.price = price;
|
|
112
|
-
config.initialSupply = uint32(100);
|
|
113
|
-
config.category = uint24(1);
|
|
114
|
-
config.encodedIPFSUri = bytes32(uint256(0x1234));
|
|
115
|
-
config.splitPercent = splitPercent;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function _buildPayerMetadata(
|
|
119
|
-
address hookAddress,
|
|
120
|
-
uint16[] memory tierIdsToMint
|
|
121
|
-
)
|
|
122
|
-
internal
|
|
123
|
-
view
|
|
124
|
-
returns (bytes memory)
|
|
125
|
-
{
|
|
126
|
-
bytes[] memory data = new bytes[](1);
|
|
127
|
-
data[0] = abi.encode(false, tierIdsToMint);
|
|
128
|
-
bytes4[] memory ids = new bytes4[](1);
|
|
129
|
-
ids[0] = metadataHelper.getId("pay", hookAddress);
|
|
130
|
-
return metadataHelper.createMetadata(ids, data);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/// @dev Build a full JBAfterPayRecordedContext with split forwarding.
|
|
134
|
-
function _buildPayContextWithSplits(
|
|
135
|
-
address hookAddress,
|
|
136
|
-
uint16[] memory mintIds,
|
|
137
|
-
uint16[] memory splitTierIds,
|
|
138
|
-
uint256[] memory splitAmounts,
|
|
139
|
-
uint256 payValue,
|
|
140
|
-
uint256 forwardValue
|
|
141
|
-
)
|
|
142
|
-
internal
|
|
143
|
-
view
|
|
144
|
-
returns (JBAfterPayRecordedContext memory)
|
|
145
|
-
{
|
|
146
|
-
bytes memory payerMetadata = _buildPayerMetadata(hookAddress, mintIds);
|
|
147
|
-
|
|
148
|
-
return JBAfterPayRecordedContext({
|
|
149
|
-
payer: beneficiary,
|
|
150
|
-
projectId: projectId,
|
|
151
|
-
rulesetId: 0,
|
|
152
|
-
amount: JBTokenAmount({
|
|
153
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
154
|
-
value: payValue,
|
|
155
|
-
decimals: 18,
|
|
156
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
157
|
-
}),
|
|
158
|
-
forwardedAmount: JBTokenAmount({
|
|
159
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
160
|
-
value: forwardValue,
|
|
161
|
-
decimals: 18,
|
|
162
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
163
|
-
}),
|
|
164
|
-
weight: 10e18,
|
|
165
|
-
newlyIssuedTokenCount: 0,
|
|
166
|
-
beneficiary: beneficiary,
|
|
167
|
-
hookMetadata: abi.encode(beneficiary, beneficiary, abi.encode(splitTierIds, splitAmounts)),
|
|
168
|
-
payerMetadata: payerMetadata
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ---------------------------------------------------------------
|
|
173
|
-
// Test 1: Reentrant split hook cannot re-call afterPayRecordedWith
|
|
174
|
-
// ---------------------------------------------------------------
|
|
175
|
-
/// @notice A malicious split hook tries to re-enter afterPayRecordedWith during
|
|
176
|
-
/// fund distribution. The reentrancy is blocked because the split hook's address
|
|
177
|
-
/// is not registered as a terminal in the directory.
|
|
178
|
-
function test_reentrancy_splitHook_cannotReenterAfterPay() public {
|
|
179
|
-
ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
|
|
180
|
-
IJB721TiersHookStore hookStore = testHook.STORE();
|
|
181
|
-
|
|
182
|
-
// Add tier with 100% split, priced at 1 ETH.
|
|
183
|
-
JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
|
|
184
|
-
tierConfigs[0] = _tierConfigWithSplit(1 ether, 1_000_000_000);
|
|
185
|
-
vm.prank(address(testHook));
|
|
186
|
-
uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
|
|
187
|
-
|
|
188
|
-
// Mock the terminal check for the legitimate terminal.
|
|
189
|
-
mockAndExpect(
|
|
190
|
-
address(mockJBDirectory),
|
|
191
|
-
abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
|
|
192
|
-
abi.encode(true)
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
// Build reentrant calldata (a second afterPayRecordedWith call).
|
|
196
|
-
uint16[] memory mintIds = new uint16[](1);
|
|
197
|
-
mintIds[0] = uint16(tierIds[0]);
|
|
198
|
-
|
|
199
|
-
uint16[] memory splitTierIds = new uint16[](1);
|
|
200
|
-
splitTierIds[0] = uint16(tierIds[0]);
|
|
201
|
-
uint256[] memory splitAmounts = new uint256[](1);
|
|
202
|
-
splitAmounts[0] = 1 ether;
|
|
203
|
-
|
|
204
|
-
JBAfterPayRecordedContext memory reentrantContext =
|
|
205
|
-
_buildPayContextWithSplits(address(testHook), mintIds, splitTierIds, splitAmounts, 1 ether, 1 ether);
|
|
206
|
-
|
|
207
|
-
// Create the reentrant split hook.
|
|
208
|
-
ReentrantSplitHook reentrantHook = new ReentrantSplitHook(
|
|
209
|
-
address(testHook), abi.encodeCall(testHook.afterPayRecordedWith, (reentrantContext))
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
// Set up splits: 100% to the malicious hook.
|
|
213
|
-
JBSplit[] memory splits = new JBSplit[](1);
|
|
214
|
-
splits[0] = JBSplit({
|
|
215
|
-
percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
|
|
216
|
-
projectId: 0,
|
|
217
|
-
beneficiary: payable(address(0)),
|
|
218
|
-
preferAddToBalance: false,
|
|
219
|
-
lockedUntil: 0,
|
|
220
|
-
hook: IJBSplitHook(address(reentrantHook))
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
|
|
224
|
-
mockAndExpect(
|
|
225
|
-
mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
|
|
226
|
-
);
|
|
227
|
-
|
|
228
|
-
// The reentrant split hook is NOT a terminal, so mock it as such.
|
|
229
|
-
vm.mockCall(
|
|
230
|
-
address(mockJBDirectory),
|
|
231
|
-
abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, address(reentrantHook)),
|
|
232
|
-
abi.encode(false)
|
|
233
|
-
);
|
|
234
|
-
|
|
235
|
-
// Execute the payment.
|
|
236
|
-
JBAfterPayRecordedContext memory payContext =
|
|
237
|
-
_buildPayContextWithSplits(address(testHook), mintIds, splitTierIds, splitAmounts, 1 ether, 1 ether);
|
|
238
|
-
|
|
239
|
-
vm.deal(mockTerminalAddress, 1 ether);
|
|
240
|
-
vm.prank(mockTerminalAddress);
|
|
241
|
-
testHook.afterPayRecordedWith{value: 1 ether}(payContext);
|
|
242
|
-
|
|
243
|
-
// Verify the split hook was called.
|
|
244
|
-
assertEq(reentrantHook.callCount(), 1, "Split hook should be called once");
|
|
245
|
-
|
|
246
|
-
// Verify reentrancy was attempted but failed (the hook contract checks msg.sender is a terminal).
|
|
247
|
-
assertTrue(reentrantHook.reentryAttempted(), "Reentrancy should have been attempted");
|
|
248
|
-
assertFalse(reentrantHook.reentrySucceeded(), "Reentrancy should have failed");
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// ---------------------------------------------------------------
|
|
252
|
-
// Test 2: Reentrant split hook cannot re-call adjustTiers
|
|
253
|
-
// ---------------------------------------------------------------
|
|
254
|
-
/// @notice A malicious split hook tries to call adjustTiers during fund distribution.
|
|
255
|
-
/// The call is blocked by permission checks (caller is the hook library, not the owner).
|
|
256
|
-
function test_reentrancy_splitHook_cannotReenterAdjustTiers() public {
|
|
257
|
-
ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
|
|
258
|
-
IJB721TiersHookStore hookStore = testHook.STORE();
|
|
259
|
-
|
|
260
|
-
// Add tier with 100% split, priced at 1 ETH.
|
|
261
|
-
JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
|
|
262
|
-
tierConfigs[0] = _tierConfigWithSplit(1 ether, 1_000_000_000);
|
|
263
|
-
vm.prank(address(testHook));
|
|
264
|
-
uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
|
|
265
|
-
|
|
266
|
-
mockAndExpect(
|
|
267
|
-
address(mockJBDirectory),
|
|
268
|
-
abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
|
|
269
|
-
abi.encode(true)
|
|
270
|
-
);
|
|
271
|
-
|
|
272
|
-
// Create the malicious hook that tries adjustTiers.
|
|
273
|
-
ReentrantAdjustTiersSplitHook maliciousHook = new ReentrantAdjustTiersSplitHook(address(testHook));
|
|
274
|
-
|
|
275
|
-
// Mock permissions: the malicious hook does NOT have permission.
|
|
276
|
-
vm.mockCall(
|
|
277
|
-
mockJBPermissions,
|
|
278
|
-
abi.encodeWithSelector(IJBPermissions.hasPermission.selector, address(maliciousHook)),
|
|
279
|
-
abi.encode(false)
|
|
280
|
-
);
|
|
281
|
-
|
|
282
|
-
JBSplit[] memory splits = new JBSplit[](1);
|
|
283
|
-
splits[0] = JBSplit({
|
|
284
|
-
percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
|
|
285
|
-
projectId: 0,
|
|
286
|
-
beneficiary: payable(address(0)),
|
|
287
|
-
preferAddToBalance: false,
|
|
288
|
-
lockedUntil: 0,
|
|
289
|
-
hook: IJBSplitHook(address(maliciousHook))
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
|
|
293
|
-
mockAndExpect(
|
|
294
|
-
mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
|
|
295
|
-
);
|
|
296
|
-
|
|
297
|
-
uint16[] memory mintIds = new uint16[](1);
|
|
298
|
-
mintIds[0] = uint16(tierIds[0]);
|
|
299
|
-
|
|
300
|
-
uint16[] memory splitTierIds = new uint16[](1);
|
|
301
|
-
splitTierIds[0] = uint16(tierIds[0]);
|
|
302
|
-
uint256[] memory splitAmounts = new uint256[](1);
|
|
303
|
-
splitAmounts[0] = 1 ether;
|
|
304
|
-
|
|
305
|
-
JBAfterPayRecordedContext memory payContext =
|
|
306
|
-
_buildPayContextWithSplits(address(testHook), mintIds, splitTierIds, splitAmounts, 1 ether, 1 ether);
|
|
307
|
-
|
|
308
|
-
vm.deal(mockTerminalAddress, 1 ether);
|
|
309
|
-
vm.prank(mockTerminalAddress);
|
|
310
|
-
testHook.afterPayRecordedWith{value: 1 ether}(payContext);
|
|
311
|
-
|
|
312
|
-
// Verify the split hook was called and the reentrancy was attempted.
|
|
313
|
-
assertEq(maliciousHook.callCount(), 1, "Split hook should be called once");
|
|
314
|
-
assertTrue(maliciousHook.reentryAttempted(), "Reentrancy should have been attempted");
|
|
315
|
-
assertTrue(maliciousHook.reentryReverted(), "adjustTiers reentrancy should have reverted");
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// ---------------------------------------------------------------
|
|
319
|
-
// Test 3: Split hook with multiple tiers cannot manipulate state
|
|
320
|
-
// ---------------------------------------------------------------
|
|
321
|
-
/// @notice With multiple tiers having splits, a malicious hook on the first tier cannot
|
|
322
|
-
/// affect the distribution of the second tier. State should be consistent after distribution.
|
|
323
|
-
function test_reentrancy_multiTierSplit_stateConsistent() public {
|
|
324
|
-
ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
|
|
325
|
-
IJB721TiersHookStore hookStore = testHook.STORE();
|
|
326
|
-
|
|
327
|
-
// Add two tiers, each with 50% split, priced at 0.5 ETH each.
|
|
328
|
-
JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](2);
|
|
329
|
-
tierConfigs[0] = _tierConfigWithSplit(0.5 ether, 500_000_000); // 50% split
|
|
330
|
-
tierConfigs[0].category = 1;
|
|
331
|
-
tierConfigs[1] = _tierConfigWithSplit(0.5 ether, 500_000_000); // 50% split
|
|
332
|
-
tierConfigs[1].category = 2;
|
|
333
|
-
vm.prank(address(testHook));
|
|
334
|
-
uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
|
|
335
|
-
|
|
336
|
-
mockAndExpect(
|
|
337
|
-
address(mockJBDirectory),
|
|
338
|
-
abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
|
|
339
|
-
abi.encode(true)
|
|
340
|
-
);
|
|
341
|
-
|
|
342
|
-
// Create the reentrant split hook for tier 1, and a clean beneficiary for tier 2.
|
|
343
|
-
ReentrantAdjustTiersSplitHook maliciousHook = new ReentrantAdjustTiersSplitHook(address(testHook));
|
|
344
|
-
address cleanBeneficiary = makeAddr("cleanBeneficiary");
|
|
345
|
-
|
|
346
|
-
// Tier 1 splits: 100% to malicious hook.
|
|
347
|
-
JBSplit[] memory splits1 = new JBSplit[](1);
|
|
348
|
-
splits1[0] = JBSplit({
|
|
349
|
-
percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
|
|
350
|
-
projectId: 0,
|
|
351
|
-
beneficiary: payable(address(0)),
|
|
352
|
-
preferAddToBalance: false,
|
|
353
|
-
lockedUntil: 0,
|
|
354
|
-
hook: IJBSplitHook(address(maliciousHook))
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
// Tier 2 splits: 100% to clean beneficiary.
|
|
358
|
-
JBSplit[] memory splits2 = new JBSplit[](1);
|
|
359
|
-
splits2[0] = JBSplit({
|
|
360
|
-
percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
|
|
361
|
-
projectId: 0,
|
|
362
|
-
beneficiary: payable(cleanBeneficiary),
|
|
363
|
-
preferAddToBalance: false,
|
|
364
|
-
lockedUntil: 0,
|
|
365
|
-
hook: IJBSplitHook(address(0))
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
uint256 groupId1 = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
|
|
369
|
-
uint256 groupId2 = uint256(uint160(address(testHook))) | (uint256(tierIds[1]) << 160);
|
|
370
|
-
|
|
371
|
-
mockAndExpect(
|
|
372
|
-
mockJBSplits,
|
|
373
|
-
abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId1),
|
|
374
|
-
abi.encode(splits1)
|
|
375
|
-
);
|
|
376
|
-
mockAndExpect(
|
|
377
|
-
mockJBSplits,
|
|
378
|
-
abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId2),
|
|
379
|
-
abi.encode(splits2)
|
|
380
|
-
);
|
|
381
|
-
|
|
382
|
-
// Mock permissions to deny the malicious hook.
|
|
383
|
-
vm.mockCall(
|
|
384
|
-
mockJBPermissions,
|
|
385
|
-
abi.encodeWithSelector(IJBPermissions.hasPermission.selector, address(maliciousHook)),
|
|
386
|
-
abi.encode(false)
|
|
387
|
-
);
|
|
388
|
-
|
|
389
|
-
uint16[] memory mintIds = new uint16[](2);
|
|
390
|
-
mintIds[0] = uint16(tierIds[0]);
|
|
391
|
-
mintIds[1] = uint16(tierIds[1]);
|
|
392
|
-
|
|
393
|
-
uint16[] memory splitTierIds = new uint16[](2);
|
|
394
|
-
splitTierIds[0] = uint16(tierIds[0]);
|
|
395
|
-
splitTierIds[1] = uint16(tierIds[1]);
|
|
396
|
-
uint256[] memory splitAmounts = new uint256[](2);
|
|
397
|
-
splitAmounts[0] = 0.25 ether; // 50% of 0.5 ETH
|
|
398
|
-
splitAmounts[1] = 0.25 ether;
|
|
399
|
-
|
|
400
|
-
JBAfterPayRecordedContext memory payContext =
|
|
401
|
-
_buildPayContextWithSplits(address(testHook), mintIds, splitTierIds, splitAmounts, 1 ether, 0.5 ether);
|
|
402
|
-
|
|
403
|
-
vm.deal(mockTerminalAddress, 1 ether);
|
|
404
|
-
vm.prank(mockTerminalAddress);
|
|
405
|
-
testHook.afterPayRecordedWith{value: 0.5 ether}(payContext);
|
|
406
|
-
|
|
407
|
-
// Verify both splits were distributed.
|
|
408
|
-
assertEq(maliciousHook.callCount(), 1, "Malicious hook should be called for tier 1");
|
|
409
|
-
assertEq(address(maliciousHook).balance, 0.25 ether, "Malicious hook should get tier 1 split");
|
|
410
|
-
assertEq(cleanBeneficiary.balance, 0.25 ether, "Clean beneficiary should get tier 2 split");
|
|
411
|
-
|
|
412
|
-
// Verify NFTs were minted (state is consistent).
|
|
413
|
-
assertEq(testHook.balanceOf(beneficiary), 2, "Beneficiary should have 2 NFTs");
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// =====================================================================
|
|
418
|
-
// Test Contract: Gas Limits with Hundreds of Tiers
|
|
419
|
-
// =====================================================================
|
|
420
|
-
|
|
421
|
-
/// @title TestAuditGaps_GasLimits
|
|
422
|
-
/// @notice Tests that operations with 100+ tiers do not hit gas limits or behave unexpectedly.
|
|
423
|
-
contract TestAuditGaps_GasLimits is UnitTestSetup {
|
|
424
|
-
using stdStorage for StdStorage;
|
|
425
|
-
|
|
426
|
-
/// @dev The block gas limit on mainnet is 30M. We use a generous limit for safety.
|
|
427
|
-
uint256 constant BLOCK_GAS_LIMIT = 30_000_000;
|
|
428
|
-
uint256 constant OPERATING_ENVELOPE_SOFT_LIMIT = 200;
|
|
429
|
-
|
|
430
|
-
// ---------------------------------------------------------------
|
|
431
|
-
// Test 1: Add 100 tiers in a single adjustTiers call
|
|
432
|
-
// ---------------------------------------------------------------
|
|
433
|
-
/// @notice Adding 100 tiers in a single transaction should succeed within the block gas limit.
|
|
434
|
-
function test_gasLimit_add100Tiers() public {
|
|
435
|
-
defaultTierConfig.initialSupply = 10;
|
|
436
|
-
defaultTierConfig.reserveFrequency = 0;
|
|
437
|
-
|
|
438
|
-
JB721TiersHook targetHook = _initHookDefaultTiers(0);
|
|
439
|
-
|
|
440
|
-
// Build 100 tier configs, sorted by category.
|
|
441
|
-
JB721TierConfig[] memory newTiers = new JB721TierConfig[](100);
|
|
442
|
-
for (uint256 i; i < 100; i++) {
|
|
443
|
-
newTiers[i] = JB721TierConfig({
|
|
444
|
-
price: uint104((i + 1) * 1e15), // Different prices
|
|
445
|
-
initialSupply: uint32(10),
|
|
446
|
-
votingUnits: 0,
|
|
447
|
-
reserveFrequency: 0,
|
|
448
|
-
reserveBeneficiary: reserveBeneficiary,
|
|
449
|
-
encodedIPFSUri: tokenUris[i % 10],
|
|
450
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
451
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
452
|
-
category: uint24(i + 1), // Ascending categories
|
|
453
|
-
discountPercent: 0,
|
|
454
|
-
flags: JB721TierConfigFlags({
|
|
455
|
-
allowOwnerMint: false,
|
|
456
|
-
useReserveBeneficiaryAsDefault: false,
|
|
457
|
-
transfersPausable: false,
|
|
458
|
-
useVotingUnits: false,
|
|
459
|
-
cantBeRemoved: false,
|
|
460
|
-
cantIncreaseDiscountPercent: false,
|
|
461
|
-
cantBuyWithCredits: false
|
|
462
|
-
}),
|
|
463
|
-
splitPercent: 0,
|
|
464
|
-
splits: new JBSplit[](0)
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
|
|
469
|
-
|
|
470
|
-
uint256 gasBefore = gasleft();
|
|
471
|
-
vm.prank(owner);
|
|
472
|
-
targetHook.adjustTiers(newTiers, new uint256[](0));
|
|
473
|
-
uint256 gasUsed = gasBefore - gasleft();
|
|
474
|
-
|
|
475
|
-
// Verify all 100 tiers were added.
|
|
476
|
-
IJB721TiersHookStore hookStore = targetHook.STORE();
|
|
477
|
-
assertEq(hookStore.maxTierIdOf(address(targetHook)), 100, "Should have 100 tiers");
|
|
478
|
-
|
|
479
|
-
// Verify gas usage is within block gas limit.
|
|
480
|
-
assertTrue(gasUsed < BLOCK_GAS_LIMIT, "Adding 100 tiers should fit within block gas limit");
|
|
481
|
-
|
|
482
|
-
// Log gas for visibility.
|
|
483
|
-
emit log_named_uint("Gas used to add 100 tiers", gasUsed);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// ---------------------------------------------------------------
|
|
487
|
-
// Test 2: Read tiersOf with 100+ tiers
|
|
488
|
-
// ---------------------------------------------------------------
|
|
489
|
-
/// @notice Reading all tiers via tiersOf should succeed with 100+ tiers.
|
|
490
|
-
function test_gasLimit_readTiersOf_100() public {
|
|
491
|
-
defaultTierConfig.initialSupply = 10;
|
|
492
|
-
defaultTierConfig.reserveFrequency = 0;
|
|
493
|
-
|
|
494
|
-
JB721TiersHook targetHook = _initHookDefaultTiers(0);
|
|
495
|
-
|
|
496
|
-
// Add 100 tiers.
|
|
497
|
-
JB721TierConfig[] memory newTiers = new JB721TierConfig[](100);
|
|
498
|
-
for (uint256 i; i < 100; i++) {
|
|
499
|
-
newTiers[i] = JB721TierConfig({
|
|
500
|
-
price: uint104((i + 1) * 1e15),
|
|
501
|
-
initialSupply: uint32(10),
|
|
502
|
-
votingUnits: 0,
|
|
503
|
-
reserveFrequency: 0,
|
|
504
|
-
reserveBeneficiary: reserveBeneficiary,
|
|
505
|
-
encodedIPFSUri: tokenUris[i % 10],
|
|
506
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
507
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
508
|
-
category: uint24(i + 1),
|
|
509
|
-
discountPercent: 0,
|
|
510
|
-
flags: JB721TierConfigFlags({
|
|
511
|
-
allowOwnerMint: false,
|
|
512
|
-
useReserveBeneficiaryAsDefault: false,
|
|
513
|
-
transfersPausable: false,
|
|
514
|
-
useVotingUnits: false,
|
|
515
|
-
cantBeRemoved: false,
|
|
516
|
-
cantIncreaseDiscountPercent: false,
|
|
517
|
-
cantBuyWithCredits: false
|
|
518
|
-
}),
|
|
519
|
-
splitPercent: 0,
|
|
520
|
-
splits: new JBSplit[](0)
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
|
|
525
|
-
|
|
526
|
-
vm.prank(owner);
|
|
527
|
-
targetHook.adjustTiers(newTiers, new uint256[](0));
|
|
528
|
-
|
|
529
|
-
IJB721TiersHookStore hookStore = targetHook.STORE();
|
|
530
|
-
|
|
531
|
-
// Read all 100 tiers.
|
|
532
|
-
uint256 gasBefore = gasleft();
|
|
533
|
-
JB721Tier[] memory allTiers = hookStore.tiersOf(address(targetHook), new uint256[](0), false, 0, 100);
|
|
534
|
-
uint256 gasUsed = gasBefore - gasleft();
|
|
535
|
-
|
|
536
|
-
assertEq(allTiers.length, 100, "Should return 100 tiers");
|
|
537
|
-
assertTrue(gasUsed < BLOCK_GAS_LIMIT, "Reading 100 tiers should fit within block gas limit");
|
|
538
|
-
|
|
539
|
-
emit log_named_uint("Gas used to read 100 tiers", gasUsed);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// ---------------------------------------------------------------
|
|
543
|
-
// Test 3: totalCashOutWeight with 100 tiers (some minted)
|
|
544
|
-
// ---------------------------------------------------------------
|
|
545
|
-
/// @notice totalCashOutWeight iterates all tiers. With 100 tiers and some minted, it should not
|
|
546
|
-
/// exceed gas limits.
|
|
547
|
-
function test_gasLimit_totalCashOutWeight_100tiers() public {
|
|
548
|
-
defaultTierConfig.initialSupply = 10;
|
|
549
|
-
defaultTierConfig.reserveFrequency = 0;
|
|
550
|
-
|
|
551
|
-
ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
|
|
552
|
-
IJB721TiersHookStore hookStore = targetHook.STORE();
|
|
553
|
-
|
|
554
|
-
// Add 100 tiers.
|
|
555
|
-
JB721TierConfig[] memory newTiers = new JB721TierConfig[](100);
|
|
556
|
-
for (uint256 i; i < 100; i++) {
|
|
557
|
-
newTiers[i] = JB721TierConfig({
|
|
558
|
-
price: uint104((i + 1) * 1e15),
|
|
559
|
-
initialSupply: uint32(10),
|
|
560
|
-
votingUnits: 0,
|
|
561
|
-
reserveFrequency: 0,
|
|
562
|
-
reserveBeneficiary: reserveBeneficiary,
|
|
563
|
-
encodedIPFSUri: tokenUris[i % 10],
|
|
564
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
565
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
566
|
-
category: uint24(i + 1),
|
|
567
|
-
discountPercent: 0,
|
|
568
|
-
flags: JB721TierConfigFlags({
|
|
569
|
-
allowOwnerMint: false,
|
|
570
|
-
useReserveBeneficiaryAsDefault: false,
|
|
571
|
-
transfersPausable: false,
|
|
572
|
-
useVotingUnits: false,
|
|
573
|
-
cantBeRemoved: false,
|
|
574
|
-
cantIncreaseDiscountPercent: false,
|
|
575
|
-
cantBuyWithCredits: false
|
|
576
|
-
}),
|
|
577
|
-
splitPercent: 0,
|
|
578
|
-
splits: new JBSplit[](0)
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
vm.prank(address(targetHook));
|
|
583
|
-
hookStore.recordAddTiers(newTiers);
|
|
584
|
-
|
|
585
|
-
// Mock the directory for terminal auth.
|
|
586
|
-
mockAndExpect(
|
|
587
|
-
address(mockJBDirectory),
|
|
588
|
-
abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
|
|
589
|
-
abi.encode(true)
|
|
590
|
-
);
|
|
591
|
-
|
|
592
|
-
// Mint 1 NFT from each of the first 10 tiers.
|
|
593
|
-
uint16[] memory tierIdsToMint = new uint16[](10);
|
|
594
|
-
uint256 totalCost;
|
|
595
|
-
for (uint256 i; i < 10; i++) {
|
|
596
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
597
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
598
|
-
tierIdsToMint[i] = uint16(i + 1);
|
|
599
|
-
totalCost += (i + 1) * 1e15;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
bytes[] memory data = new bytes[](1);
|
|
603
|
-
data[0] = abi.encode(false, tierIdsToMint);
|
|
604
|
-
bytes4[] memory ids = new bytes4[](1);
|
|
605
|
-
ids[0] = metadataHelper.getId("pay", address(targetHook));
|
|
606
|
-
bytes memory payerMetadata = metadataHelper.createMetadata(ids, data);
|
|
607
|
-
|
|
608
|
-
JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
|
|
609
|
-
payer: beneficiary,
|
|
610
|
-
projectId: projectId,
|
|
611
|
-
rulesetId: 0,
|
|
612
|
-
amount: JBTokenAmount({
|
|
613
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
614
|
-
value: totalCost,
|
|
615
|
-
decimals: 18,
|
|
616
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
617
|
-
}),
|
|
618
|
-
forwardedAmount: JBTokenAmount({
|
|
619
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
620
|
-
value: 0,
|
|
621
|
-
decimals: 18,
|
|
622
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
623
|
-
}),
|
|
624
|
-
weight: 10e18,
|
|
625
|
-
newlyIssuedTokenCount: 0,
|
|
626
|
-
beneficiary: beneficiary,
|
|
627
|
-
hookMetadata: bytes(""),
|
|
628
|
-
payerMetadata: payerMetadata
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
vm.prank(mockTerminalAddress);
|
|
632
|
-
targetHook.afterPayRecordedWith(payContext);
|
|
633
|
-
|
|
634
|
-
assertEq(targetHook.balanceOf(beneficiary), 10, "10 NFTs minted");
|
|
635
|
-
|
|
636
|
-
// Now measure gas for totalCashOutWeight.
|
|
637
|
-
uint256 gasBefore = gasleft();
|
|
638
|
-
uint256 weight = hookStore.totalCashOutWeight(address(targetHook));
|
|
639
|
-
uint256 gasUsed = gasBefore - gasleft();
|
|
640
|
-
|
|
641
|
-
assertTrue(weight > 0, "Cash out weight should be non-zero");
|
|
642
|
-
assertTrue(gasUsed < BLOCK_GAS_LIMIT, "totalCashOutWeight should fit within block gas limit");
|
|
643
|
-
|
|
644
|
-
emit log_named_uint("Gas used for totalCashOutWeight (100 tiers, 10 minted)", gasUsed);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// ---------------------------------------------------------------
|
|
648
|
-
// Test 4: balanceOf with 100 tiers
|
|
649
|
-
// ---------------------------------------------------------------
|
|
650
|
-
/// @notice balanceOf iterates all tiers. With 100 tiers it should not be too expensive.
|
|
651
|
-
function test_gasLimit_balanceOf_100tiers() public {
|
|
652
|
-
defaultTierConfig.initialSupply = 10;
|
|
653
|
-
defaultTierConfig.reserveFrequency = 0;
|
|
654
|
-
|
|
655
|
-
ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
|
|
656
|
-
IJB721TiersHookStore hookStore = targetHook.STORE();
|
|
657
|
-
|
|
658
|
-
// Add 100 tiers.
|
|
659
|
-
JB721TierConfig[] memory newTiers = new JB721TierConfig[](100);
|
|
660
|
-
for (uint256 i; i < 100; i++) {
|
|
661
|
-
newTiers[i] = JB721TierConfig({
|
|
662
|
-
price: uint104((i + 1) * 1e15),
|
|
663
|
-
initialSupply: uint32(10),
|
|
664
|
-
votingUnits: 0,
|
|
665
|
-
reserveFrequency: 0,
|
|
666
|
-
reserveBeneficiary: reserveBeneficiary,
|
|
667
|
-
encodedIPFSUri: tokenUris[i % 10],
|
|
668
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
669
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
670
|
-
category: uint24(i + 1),
|
|
671
|
-
discountPercent: 0,
|
|
672
|
-
flags: JB721TierConfigFlags({
|
|
673
|
-
allowOwnerMint: false,
|
|
674
|
-
useReserveBeneficiaryAsDefault: false,
|
|
675
|
-
transfersPausable: false,
|
|
676
|
-
useVotingUnits: false,
|
|
677
|
-
cantBeRemoved: false,
|
|
678
|
-
cantIncreaseDiscountPercent: false,
|
|
679
|
-
cantBuyWithCredits: false
|
|
680
|
-
}),
|
|
681
|
-
splitPercent: 0,
|
|
682
|
-
splits: new JBSplit[](0)
|
|
683
|
-
});
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
vm.prank(address(targetHook));
|
|
687
|
-
hookStore.recordAddTiers(newTiers);
|
|
688
|
-
|
|
689
|
-
// Measure gas for balanceOf with 100 tiers (user has 0 NFTs).
|
|
690
|
-
uint256 gasBefore = gasleft();
|
|
691
|
-
uint256 balance = hookStore.balanceOf(address(targetHook), beneficiary);
|
|
692
|
-
uint256 gasUsed = gasBefore - gasleft();
|
|
693
|
-
|
|
694
|
-
assertEq(balance, 0, "Balance should be 0");
|
|
695
|
-
assertTrue(gasUsed < BLOCK_GAS_LIMIT, "balanceOf with 100 tiers should be within gas limit");
|
|
696
|
-
|
|
697
|
-
emit log_named_uint("Gas used for balanceOf (100 tiers, 0 NFTs)", gasUsed);
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// ---------------------------------------------------------------
|
|
701
|
-
// Test 5: Add 200 tiers and verify store correctness
|
|
702
|
-
// ---------------------------------------------------------------
|
|
703
|
-
/// @notice Adding 200 tiers should still work and the store should report correct data.
|
|
704
|
-
function test_gasLimit_add200Tiers_storeCorrectness() public {
|
|
705
|
-
defaultTierConfig.initialSupply = 5;
|
|
706
|
-
defaultTierConfig.reserveFrequency = 0;
|
|
707
|
-
|
|
708
|
-
JB721TiersHook targetHook = _initHookDefaultTiers(0);
|
|
709
|
-
|
|
710
|
-
// Build 200 tiers in a single batch.
|
|
711
|
-
JB721TierConfig[] memory newTiers = new JB721TierConfig[](200);
|
|
712
|
-
for (uint256 i; i < 200; i++) {
|
|
713
|
-
newTiers[i] = JB721TierConfig({
|
|
714
|
-
price: uint104((i + 1) * 1e14),
|
|
715
|
-
initialSupply: uint32(5),
|
|
716
|
-
votingUnits: 0,
|
|
717
|
-
reserveFrequency: 0,
|
|
718
|
-
reserveBeneficiary: reserveBeneficiary,
|
|
719
|
-
encodedIPFSUri: tokenUris[i % 10],
|
|
720
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
721
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
722
|
-
category: uint24(i + 1),
|
|
723
|
-
discountPercent: 0,
|
|
724
|
-
flags: JB721TierConfigFlags({
|
|
725
|
-
allowOwnerMint: false,
|
|
726
|
-
useReserveBeneficiaryAsDefault: false,
|
|
727
|
-
transfersPausable: false,
|
|
728
|
-
useVotingUnits: false,
|
|
729
|
-
cantBeRemoved: false,
|
|
730
|
-
cantIncreaseDiscountPercent: false,
|
|
731
|
-
cantBuyWithCredits: false
|
|
732
|
-
}),
|
|
733
|
-
splitPercent: 0,
|
|
734
|
-
splits: new JBSplit[](0)
|
|
735
|
-
});
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
|
|
739
|
-
|
|
740
|
-
uint256 gasBefore = gasleft();
|
|
741
|
-
vm.prank(owner);
|
|
742
|
-
targetHook.adjustTiers(newTiers, new uint256[](0));
|
|
743
|
-
uint256 gasUsed = gasBefore - gasleft();
|
|
744
|
-
|
|
745
|
-
IJB721TiersHookStore hookStore = targetHook.STORE();
|
|
746
|
-
|
|
747
|
-
// Verify all 200 tiers were added.
|
|
748
|
-
assertEq(hookStore.maxTierIdOf(address(targetHook)), 200, "Should have 200 tiers");
|
|
749
|
-
|
|
750
|
-
// Spot check first and last tier.
|
|
751
|
-
JB721Tier memory firstTier = hookStore.tierOf(address(targetHook), 1, false);
|
|
752
|
-
assertEq(firstTier.price, 1e14, "First tier price should be correct");
|
|
753
|
-
assertEq(firstTier.initialSupply, 5, "First tier supply should be 5");
|
|
754
|
-
|
|
755
|
-
JB721Tier memory lastTier = hookStore.tierOf(address(targetHook), 200, false);
|
|
756
|
-
assertEq(lastTier.price, 200 * 1e14, "Last tier price should be correct");
|
|
757
|
-
assertEq(lastTier.initialSupply, 5, "Last tier supply should be 5");
|
|
758
|
-
|
|
759
|
-
assertTrue(gasUsed < BLOCK_GAS_LIMIT, "Adding 200 tiers should fit within block gas limit");
|
|
760
|
-
|
|
761
|
-
emit log_named_uint("Gas used to add 200 tiers", gasUsed);
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// ---------------------------------------------------------------
|
|
765
|
-
// Test 6: Remove many tiers and verify gas is bounded
|
|
766
|
-
// ---------------------------------------------------------------
|
|
767
|
-
/// @notice Removing 50 tiers from a 100-tier collection should be gas-efficient.
|
|
768
|
-
function test_gasLimit_remove50TiersFrom100() public {
|
|
769
|
-
defaultTierConfig.initialSupply = 10;
|
|
770
|
-
defaultTierConfig.reserveFrequency = 0;
|
|
771
|
-
|
|
772
|
-
JB721TiersHook targetHook = _initHookDefaultTiers(0);
|
|
773
|
-
|
|
774
|
-
// Add 100 tiers.
|
|
775
|
-
JB721TierConfig[] memory newTiers = new JB721TierConfig[](100);
|
|
776
|
-
for (uint256 i; i < 100; i++) {
|
|
777
|
-
newTiers[i] = JB721TierConfig({
|
|
778
|
-
price: uint104((i + 1) * 1e15),
|
|
779
|
-
initialSupply: uint32(10),
|
|
780
|
-
votingUnits: 0,
|
|
781
|
-
reserveFrequency: 0,
|
|
782
|
-
reserveBeneficiary: reserveBeneficiary,
|
|
783
|
-
encodedIPFSUri: tokenUris[i % 10],
|
|
784
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
785
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
786
|
-
category: uint24(i + 1),
|
|
787
|
-
discountPercent: 0,
|
|
788
|
-
flags: JB721TierConfigFlags({
|
|
789
|
-
allowOwnerMint: false,
|
|
790
|
-
useReserveBeneficiaryAsDefault: false,
|
|
791
|
-
transfersPausable: false,
|
|
792
|
-
useVotingUnits: false,
|
|
793
|
-
cantBeRemoved: false,
|
|
794
|
-
cantIncreaseDiscountPercent: false,
|
|
795
|
-
cantBuyWithCredits: false
|
|
796
|
-
}),
|
|
797
|
-
splitPercent: 0,
|
|
798
|
-
splits: new JBSplit[](0)
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
|
|
803
|
-
|
|
804
|
-
vm.prank(owner);
|
|
805
|
-
targetHook.adjustTiers(newTiers, new uint256[](0));
|
|
806
|
-
|
|
807
|
-
// Now remove 50 tiers (odd-numbered tiers: 1, 3, 5, ..., 99).
|
|
808
|
-
uint256[] memory tierIdsToRemove = new uint256[](50);
|
|
809
|
-
for (uint256 i; i < 50; i++) {
|
|
810
|
-
tierIdsToRemove[i] = (i * 2) + 1; // 1, 3, 5, ...
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
uint256 gasBefore = gasleft();
|
|
814
|
-
vm.prank(owner);
|
|
815
|
-
targetHook.adjustTiers(new JB721TierConfig[](0), tierIdsToRemove);
|
|
816
|
-
uint256 gasUsed = gasBefore - gasleft();
|
|
817
|
-
|
|
818
|
-
IJB721TiersHookStore hookStore = targetHook.STORE();
|
|
819
|
-
|
|
820
|
-
// Verify the removed tiers are marked as removed.
|
|
821
|
-
assertTrue(hookStore.isTierRemoved(address(targetHook), 1), "Tier 1 should be removed");
|
|
822
|
-
assertTrue(hookStore.isTierRemoved(address(targetHook), 99), "Tier 99 should be removed");
|
|
823
|
-
|
|
824
|
-
// Verify even tiers are still active.
|
|
825
|
-
assertFalse(hookStore.isTierRemoved(address(targetHook), 2), "Tier 2 should still be active");
|
|
826
|
-
assertFalse(hookStore.isTierRemoved(address(targetHook), 100), "Tier 100 should still be active");
|
|
827
|
-
|
|
828
|
-
// Reading the active tiers should return 50 (the even-numbered ones).
|
|
829
|
-
JB721Tier[] memory activeTiers = hookStore.tiersOf(address(targetHook), new uint256[](0), false, 0, 100);
|
|
830
|
-
assertEq(activeTiers.length, 50, "Should have 50 active tiers after removing 50");
|
|
831
|
-
|
|
832
|
-
assertTrue(gasUsed < BLOCK_GAS_LIMIT, "Removing 50 tiers should fit within block gas limit");
|
|
833
|
-
|
|
834
|
-
emit log_named_uint("Gas used to remove 50 tiers from 100", gasUsed);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// ---------------------------------------------------------------
|
|
838
|
-
// Test 7: Mint from many different tiers in a single payment
|
|
839
|
-
// ---------------------------------------------------------------
|
|
840
|
-
/// @notice Minting 1 NFT from each of 50 different tiers in a single payment should not
|
|
841
|
-
/// exceed gas limits.
|
|
842
|
-
function test_gasLimit_mintFrom50TiersInSinglePayment() public {
|
|
843
|
-
defaultTierConfig.initialSupply = 10;
|
|
844
|
-
defaultTierConfig.reserveFrequency = 0;
|
|
845
|
-
|
|
846
|
-
ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
|
|
847
|
-
IJB721TiersHookStore hookStore = targetHook.STORE();
|
|
848
|
-
|
|
849
|
-
// Add 50 tiers.
|
|
850
|
-
JB721TierConfig[] memory newTiers = new JB721TierConfig[](50);
|
|
851
|
-
for (uint256 i; i < 50; i++) {
|
|
852
|
-
newTiers[i] = JB721TierConfig({
|
|
853
|
-
price: uint104((i + 1) * 1e15),
|
|
854
|
-
initialSupply: uint32(10),
|
|
855
|
-
votingUnits: 0,
|
|
856
|
-
reserveFrequency: 0,
|
|
857
|
-
reserveBeneficiary: reserveBeneficiary,
|
|
858
|
-
encodedIPFSUri: tokenUris[i % 10],
|
|
859
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
860
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
861
|
-
category: uint24(i + 1),
|
|
862
|
-
discountPercent: 0,
|
|
863
|
-
flags: JB721TierConfigFlags({
|
|
864
|
-
allowOwnerMint: false,
|
|
865
|
-
useReserveBeneficiaryAsDefault: false,
|
|
866
|
-
transfersPausable: false,
|
|
867
|
-
useVotingUnits: false,
|
|
868
|
-
cantBeRemoved: false,
|
|
869
|
-
cantIncreaseDiscountPercent: false,
|
|
870
|
-
cantBuyWithCredits: false
|
|
871
|
-
}),
|
|
872
|
-
splitPercent: 0,
|
|
873
|
-
splits: new JBSplit[](0)
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
vm.prank(address(targetHook));
|
|
878
|
-
hookStore.recordAddTiers(newTiers);
|
|
879
|
-
|
|
880
|
-
mockAndExpect(
|
|
881
|
-
address(mockJBDirectory),
|
|
882
|
-
abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
|
|
883
|
-
abi.encode(true)
|
|
884
|
-
);
|
|
885
|
-
|
|
886
|
-
// Mint 1 NFT from each of the 50 tiers.
|
|
887
|
-
uint16[] memory tierIdsToMint = new uint16[](50);
|
|
888
|
-
uint256 totalCost;
|
|
889
|
-
for (uint256 i; i < 50; i++) {
|
|
890
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
891
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
892
|
-
tierIdsToMint[i] = uint16(i + 1);
|
|
893
|
-
totalCost += (i + 1) * 1e15;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
bytes[] memory data = new bytes[](1);
|
|
897
|
-
data[0] = abi.encode(false, tierIdsToMint);
|
|
898
|
-
bytes4[] memory metaIds = new bytes4[](1);
|
|
899
|
-
metaIds[0] = metadataHelper.getId("pay", address(targetHook));
|
|
900
|
-
bytes memory payerMetadata = metadataHelper.createMetadata(metaIds, data);
|
|
901
|
-
|
|
902
|
-
JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
|
|
903
|
-
payer: beneficiary,
|
|
904
|
-
projectId: projectId,
|
|
905
|
-
rulesetId: 0,
|
|
906
|
-
amount: JBTokenAmount({
|
|
907
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
908
|
-
value: totalCost,
|
|
909
|
-
decimals: 18,
|
|
910
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
911
|
-
}),
|
|
912
|
-
forwardedAmount: JBTokenAmount({
|
|
913
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
914
|
-
value: 0,
|
|
915
|
-
decimals: 18,
|
|
916
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
917
|
-
}),
|
|
918
|
-
weight: 10e18,
|
|
919
|
-
newlyIssuedTokenCount: 0,
|
|
920
|
-
beneficiary: beneficiary,
|
|
921
|
-
hookMetadata: bytes(""),
|
|
922
|
-
payerMetadata: payerMetadata
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
uint256 gasBefore = gasleft();
|
|
926
|
-
vm.prank(mockTerminalAddress);
|
|
927
|
-
targetHook.afterPayRecordedWith(payContext);
|
|
928
|
-
uint256 gasUsed = gasBefore - gasleft();
|
|
929
|
-
|
|
930
|
-
assertEq(targetHook.balanceOf(beneficiary), 50, "Should have minted 50 NFTs");
|
|
931
|
-
assertTrue(gasUsed < BLOCK_GAS_LIMIT, "Minting from 50 tiers should fit within block gas limit");
|
|
932
|
-
|
|
933
|
-
emit log_named_uint("Gas used to mint from 50 tiers in single payment", gasUsed);
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
/// @notice The expensive read paths scale with tier count, not just with the beneficiary's holdings.
|
|
937
|
-
/// This test exists to prove that a 100-tier catalog is materially more expensive than a 10-tier catalog even
|
|
938
|
-
/// when the queried user owns zero NFTs.
|
|
939
|
-
function test_operatingEnvelope_balanceOf_100tiersIsMateriallyMoreExpensiveThan10tiers() public {
|
|
940
|
-
uint256 gasFor10 = _measureBalanceOfGas({tierCount: 10});
|
|
941
|
-
uint256 gasFor100 = _measureBalanceOfGas({tierCount: 100});
|
|
942
|
-
|
|
943
|
-
assertGt(gasFor100, gasFor10 * 4, "100-tier balanceOf should be materially more expensive than 10 tiers");
|
|
944
|
-
emit log_named_uint("Gas used for balanceOf (10 tiers)", gasFor10);
|
|
945
|
-
emit log_named_uint("Gas used for balanceOf (100 tiers)", gasFor100);
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
/// @notice Cash-out accounting also scales with the catalog size because totalCashOutWeight walks the tier set.
|
|
949
|
-
/// We use a ratio check instead of an absolute snapshot so the test stays stable across compiler changes while
|
|
950
|
-
/// still proving the production-scale cost increase.
|
|
951
|
-
function test_operatingEnvelope_totalCashOutWeight_100tiersIsMateriallyMoreExpensiveThan10tiers() public {
|
|
952
|
-
uint256 gasFor10 = _measureTotalCashOutWeightGas({tierCount: 10, mintedCount: 10});
|
|
953
|
-
uint256 gasFor100 = _measureTotalCashOutWeightGas({tierCount: 100, mintedCount: 10});
|
|
954
|
-
|
|
955
|
-
assertGt(
|
|
956
|
-
gasFor100, gasFor10 * 4, "100-tier totalCashOutWeight should be materially more expensive than 10 tiers"
|
|
957
|
-
);
|
|
958
|
-
emit log_named_uint("Gas used for totalCashOutWeight (10 tiers)", gasFor10);
|
|
959
|
-
emit log_named_uint("Gas used for totalCashOutWeight (100 tiers)", gasFor100);
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
function _measureBalanceOfGas(uint256 tierCount) internal returns (uint256 gasUsed) {
|
|
963
|
-
defaultTierConfig.initialSupply = 10;
|
|
964
|
-
defaultTierConfig.reserveFrequency = 0;
|
|
965
|
-
|
|
966
|
-
ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
|
|
967
|
-
IJB721TiersHookStore hookStore = targetHook.STORE();
|
|
968
|
-
|
|
969
|
-
vm.prank(address(targetHook));
|
|
970
|
-
hookStore.recordAddTiers(_sequentialTierConfigs(tierCount, 1e15, 10));
|
|
971
|
-
|
|
972
|
-
uint256 gasBefore = gasleft();
|
|
973
|
-
hookStore.balanceOf(address(targetHook), beneficiary);
|
|
974
|
-
gasUsed = gasBefore - gasleft();
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
function _measureTotalCashOutWeightGas(uint256 tierCount, uint256 mintedCount) internal returns (uint256 gasUsed) {
|
|
978
|
-
defaultTierConfig.initialSupply = 10;
|
|
979
|
-
defaultTierConfig.reserveFrequency = 0;
|
|
980
|
-
|
|
981
|
-
ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
|
|
982
|
-
IJB721TiersHookStore hookStore = targetHook.STORE();
|
|
983
|
-
|
|
984
|
-
vm.prank(address(targetHook));
|
|
985
|
-
hookStore.recordAddTiers(_sequentialTierConfigs(tierCount, 1e15, 10));
|
|
986
|
-
|
|
987
|
-
mockAndExpect(
|
|
988
|
-
address(mockJBDirectory),
|
|
989
|
-
abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
|
|
990
|
-
abi.encode(true)
|
|
991
|
-
);
|
|
992
|
-
|
|
993
|
-
uint16[] memory tierIdsToMint = new uint16[](mintedCount);
|
|
994
|
-
uint256 totalCost;
|
|
995
|
-
for (uint256 i; i < mintedCount; i++) {
|
|
996
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
997
|
-
tierIdsToMint[i] = uint16(i + 1);
|
|
998
|
-
totalCost += (i + 1) * 1e15;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
bytes[] memory data = new bytes[](1);
|
|
1002
|
-
data[0] = abi.encode(false, tierIdsToMint);
|
|
1003
|
-
bytes4[] memory ids = new bytes4[](1);
|
|
1004
|
-
ids[0] = metadataHelper.getId("pay", address(targetHook));
|
|
1005
|
-
bytes memory payerMetadata = metadataHelper.createMetadata(ids, data);
|
|
1006
|
-
|
|
1007
|
-
JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
|
|
1008
|
-
payer: beneficiary,
|
|
1009
|
-
projectId: projectId,
|
|
1010
|
-
rulesetId: 0,
|
|
1011
|
-
amount: JBTokenAmount({
|
|
1012
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
1013
|
-
value: totalCost,
|
|
1014
|
-
decimals: 18,
|
|
1015
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
1016
|
-
}),
|
|
1017
|
-
forwardedAmount: JBTokenAmount({
|
|
1018
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
1019
|
-
value: 0,
|
|
1020
|
-
decimals: 18,
|
|
1021
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
1022
|
-
}),
|
|
1023
|
-
weight: 10e18,
|
|
1024
|
-
newlyIssuedTokenCount: 0,
|
|
1025
|
-
beneficiary: beneficiary,
|
|
1026
|
-
hookMetadata: bytes(""),
|
|
1027
|
-
payerMetadata: payerMetadata
|
|
1028
|
-
});
|
|
1029
|
-
|
|
1030
|
-
vm.prank(mockTerminalAddress);
|
|
1031
|
-
targetHook.afterPayRecordedWith(payContext);
|
|
1032
|
-
|
|
1033
|
-
uint256 gasBefore = gasleft();
|
|
1034
|
-
hookStore.totalCashOutWeight(address(targetHook));
|
|
1035
|
-
gasUsed = gasBefore - gasleft();
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
function _sequentialTierConfigs(
|
|
1039
|
-
uint256 tierCount,
|
|
1040
|
-
uint104 priceStep,
|
|
1041
|
-
uint32 initialSupply
|
|
1042
|
-
)
|
|
1043
|
-
internal
|
|
1044
|
-
view
|
|
1045
|
-
returns (JB721TierConfig[] memory newTiers)
|
|
1046
|
-
{
|
|
1047
|
-
require(tierCount <= OPERATING_ENVELOPE_SOFT_LIMIT, "test helper only sized for envelope coverage");
|
|
1048
|
-
|
|
1049
|
-
newTiers = new JB721TierConfig[](tierCount);
|
|
1050
|
-
for (uint256 i; i < tierCount; i++) {
|
|
1051
|
-
newTiers[i] = JB721TierConfig({
|
|
1052
|
-
price: uint104((i + 1) * priceStep),
|
|
1053
|
-
initialSupply: initialSupply,
|
|
1054
|
-
votingUnits: 0,
|
|
1055
|
-
reserveFrequency: 0,
|
|
1056
|
-
reserveBeneficiary: reserveBeneficiary,
|
|
1057
|
-
encodedIPFSUri: tokenUris[i % 10],
|
|
1058
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
1059
|
-
category: uint24(i + 1),
|
|
1060
|
-
discountPercent: 0,
|
|
1061
|
-
flags: JB721TierConfigFlags({
|
|
1062
|
-
allowOwnerMint: false,
|
|
1063
|
-
useReserveBeneficiaryAsDefault: false,
|
|
1064
|
-
transfersPausable: false,
|
|
1065
|
-
useVotingUnits: false,
|
|
1066
|
-
cantBeRemoved: false,
|
|
1067
|
-
cantIncreaseDiscountPercent: false,
|
|
1068
|
-
cantBuyWithCredits: false
|
|
1069
|
-
}),
|
|
1070
|
-
splitPercent: 0,
|
|
1071
|
-
splits: new JBSplit[](0)
|
|
1072
|
-
});
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
}
|