@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
@@ -1,341 +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
- // forge-lint: disable-next-line(unaliased-plain-import)
7
- import "./utils/ForTest_JB721TiersHook.sol";
8
- import {JB721Checkpoints} from "../src/JB721Checkpoints.sol";
9
- import {IJB721Checkpoints} from "../src/interfaces/IJB721Checkpoints.sol";
10
- import {IJB721TiersHook} from "../src/interfaces/IJB721TiersHook.sol";
11
- import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
12
-
13
- /// @title TestCheckpoints
14
- /// @notice Tests the checkpoint module IVotes checkpointed voting power baked into the base hook:
15
- /// delegation, checkpoints, transfer, multi-tier, burn, and module deployment.
16
- contract TestCheckpoints is UnitTestSetup {
17
- /// @notice Deploys a ForTest hook with the given number of tiers.
18
- function _initializeHookWithCheckpoints(uint256 numberOfTiers) internal returns (ForTest_JB721TiersHook tiersHook) {
19
- (JB721TierConfig[] memory tierConfigs,) = _createTiers(defaultTierConfig, numberOfTiers);
20
-
21
- ForTest_JB721TiersHookStore hookStore = new ForTest_JB721TiersHookStore();
22
-
23
- tiersHook = new ForTest_JB721TiersHook(
24
- ForTest_JB721TiersHook.ForTestInitConfig({
25
- projectId: projectId,
26
- name: name,
27
- symbol: symbol,
28
- baseUri: baseUri,
29
- tokenUriResolver: IJB721TokenUriResolver(mockTokenUriResolver),
30
- contractUri: contractUri,
31
- tiers: tierConfigs,
32
- flags: JB721TiersHookFlags({
33
- preventOverspending: false,
34
- issueTokensForSplits: false,
35
- noNewTiersWithReserves: false,
36
- noNewTiersWithVotes: false,
37
- noNewTiersWithOwnerMinting: true
38
- })
39
- }),
40
- IJBDirectory(mockJBDirectory),
41
- IJBPrices(mockJBPrices),
42
- IJBRulesets(mockJBRulesets),
43
- IJB721TiersHookStore(address(hookStore)),
44
- IJBSplits(mockJBSplits)
45
- );
46
-
47
- tiersHook.transferOwnership(owner);
48
- }
49
-
50
- // -------------------------------------------------------------------
51
- // Test 1: Checkpoint module is deployed lazily on first transfer
52
- // -------------------------------------------------------------------
53
- function test_checkpointModule_isDeployedLazily() public {
54
- defaultTierConfig.flags.allowOwnerMint = true;
55
- defaultTierConfig.reserveFrequency = 0;
56
-
57
- ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
58
-
59
- // CHECKPOINTS should NOT be deployed after initialization (lazy deployment).
60
- assertTrue(
61
- address(tiersHook.CHECKPOINTS()) == address(0),
62
- "Checkpoint module should not be deployed after initialization"
63
- );
64
-
65
- // Mint a token to trigger lazy deployment.
66
- uint16[] memory tiersToMint = new uint16[](1);
67
- tiersToMint[0] = 1;
68
- vm.prank(owner);
69
- tiersHook.mintFor(tiersToMint, owner);
70
-
71
- // CHECKPOINTS should now be deployed after the first mint.
72
- assertTrue(
73
- address(tiersHook.CHECKPOINTS()) != address(0), "Checkpoint module should be deployed after first mint"
74
- );
75
- }
76
-
77
- // -------------------------------------------------------------------
78
- // Test 2: supportsInterface still works for base hook
79
- // -------------------------------------------------------------------
80
- function test_supportsInterface() public {
81
- defaultTierConfig.flags.allowOwnerMint = true;
82
- defaultTierConfig.reserveFrequency = 0;
83
-
84
- ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
85
-
86
- assertTrue(tiersHook.supportsInterface(type(IJB721TiersHook).interfaceId), "Should support IJB721TiersHook");
87
- }
88
-
89
- // -------------------------------------------------------------------
90
- // Test 3: Mint + manual delegate -> getVotes equals tier votingUnits
91
- // -------------------------------------------------------------------
92
- function test_mintAndDelegate_getVotes() public {
93
- defaultTierConfig.flags.allowOwnerMint = true;
94
- defaultTierConfig.reserveFrequency = 0;
95
- defaultTierConfig.flags.useVotingUnits = true;
96
- defaultTierConfig.votingUnits = 100;
97
-
98
- ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
99
-
100
- address user = makeAddr("user");
101
-
102
- // Mint an NFT to user (CHECKPOINTS already deployed during init).
103
- uint16[] memory tiersToMint = new uint16[](1);
104
- tiersToMint[0] = 1;
105
- vm.prank(owner);
106
- tiersHook.mintFor(tiersToMint, user);
107
-
108
- IJB721Checkpoints module = tiersHook.CHECKPOINTS();
109
-
110
- // Without delegation, getVotes should be 0.
111
- assertEq(module.getVotes(user), 0, "Votes should be 0 before delegation");
112
-
113
- // User self-delegates.
114
- vm.prank(user);
115
- module.delegate(user);
116
-
117
- assertEq(module.getVotes(user), 100, "Votes should be 100 after delegation");
118
- }
119
-
120
- // -------------------------------------------------------------------
121
- // Test 4: No auto-delegation — delegates(user) stays address(0) after mint
122
- // -------------------------------------------------------------------
123
- function test_noAutoDelegation_delegateStaysZero() public {
124
- defaultTierConfig.flags.allowOwnerMint = true;
125
- defaultTierConfig.reserveFrequency = 0;
126
- defaultTierConfig.flags.useVotingUnits = true;
127
- defaultTierConfig.votingUnits = 100;
128
-
129
- ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
130
-
131
- address user = makeAddr("user");
132
-
133
- // Mint an NFT to user (CHECKPOINTS already deployed during init).
134
- uint16[] memory tiersToMint = new uint16[](1);
135
- tiersToMint[0] = 1;
136
- vm.prank(owner);
137
- tiersHook.mintFor(tiersToMint, user);
138
-
139
- IJB721Checkpoints module = tiersHook.CHECKPOINTS();
140
-
141
- // Delegate should be address(0) — no auto-delegation.
142
- assertEq(module.delegates(user), address(0), "Delegate should be zero after mint");
143
- }
144
-
145
- // -------------------------------------------------------------------
146
- // Test 5: Transfer moves checkpointed votes (with manual delegation)
147
- // -------------------------------------------------------------------
148
- function test_transfer_movesCheckpointedVotes() public {
149
- defaultTierConfig.flags.allowOwnerMint = true;
150
- defaultTierConfig.reserveFrequency = 0;
151
- defaultTierConfig.flags.useVotingUnits = true;
152
- defaultTierConfig.votingUnits = 100;
153
-
154
- ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
155
-
156
- address alice = makeAddr("alice");
157
- address bob = makeAddr("bob");
158
-
159
- // Mint to alice (CHECKPOINTS already deployed during init).
160
- uint16[] memory tiersToMint = new uint16[](1);
161
- tiersToMint[0] = 1;
162
- vm.prank(owner);
163
- tiersHook.mintFor(tiersToMint, alice);
164
-
165
- IJB721Checkpoints module = tiersHook.CHECKPOINTS();
166
-
167
- // Both delegate to themselves.
168
- vm.prank(alice);
169
- module.delegate(alice);
170
- vm.prank(bob);
171
- module.delegate(bob);
172
-
173
- assertEq(module.getVotes(alice), 100, "Alice should have 100 votes");
174
- assertEq(module.getVotes(bob), 0, "Bob should have 0 votes");
175
-
176
- // Transfer NFT from alice to bob.
177
- uint256 tokenId = _generateTokenId(1, 1);
178
- vm.prank(alice);
179
- IERC721(address(tiersHook)).transferFrom(alice, bob, tokenId);
180
-
181
- assertEq(module.getVotes(alice), 0, "Alice should have 0 votes after transfer");
182
- assertEq(module.getVotes(bob), 100, "Bob should have 100 votes after transfer");
183
- }
184
-
185
- // -------------------------------------------------------------------
186
- // Test 6: getPastVotes / getPastTotalSupply checkpoints
187
- // -------------------------------------------------------------------
188
- function test_getPastVotes_checkpoint() public {
189
- defaultTierConfig.flags.allowOwnerMint = true;
190
- defaultTierConfig.reserveFrequency = 0;
191
- defaultTierConfig.flags.useVotingUnits = true;
192
- defaultTierConfig.votingUnits = 100;
193
-
194
- ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
195
-
196
- address user = makeAddr("user");
197
-
198
- // Mint first NFT to test checkpoint tracking.
199
- uint16[] memory tiersToMint = new uint16[](1);
200
- tiersToMint[0] = 1;
201
- vm.prank(owner);
202
- tiersHook.mintFor(tiersToMint, user);
203
-
204
- IJB721Checkpoints module = tiersHook.CHECKPOINTS();
205
-
206
- // User self-delegates so checkpoints are created going forward.
207
- vm.prank(user);
208
- module.delegate(user);
209
-
210
- uint256 blockBeforeSecondMint = block.number;
211
- vm.roll(block.number + 1);
212
-
213
- // Mint a second NFT.
214
- vm.prank(owner);
215
- tiersHook.mintFor(tiersToMint, user);
216
-
217
- uint256 blockAfterSecondMint = block.number;
218
- vm.roll(block.number + 1);
219
-
220
- // Past votes before second mint = 100 (from first NFT + delegation).
221
- assertEq(module.getPastVotes(user, blockBeforeSecondMint), 100, "Past votes before second mint should be 100");
222
- // Past votes after second mint = 200.
223
- assertEq(module.getPastVotes(user, blockAfterSecondMint), 200, "Past votes after second mint should be 200");
224
-
225
- // Past total supply.
226
- assertEq(
227
- module.getPastTotalSupply(blockBeforeSecondMint), 100, "Past total supply before second mint should be 100"
228
- );
229
- assertEq(
230
- module.getPastTotalSupply(blockAfterSecondMint), 200, "Past total supply after second mint should be 200"
231
- );
232
- }
233
-
234
- // -------------------------------------------------------------------
235
- // Test 7: Multi-tier with different voting units
236
- // -------------------------------------------------------------------
237
- function test_multiTier_differentVotingUnits() public {
238
- defaultTierConfig.flags.allowOwnerMint = true;
239
- defaultTierConfig.reserveFrequency = 0;
240
- defaultTierConfig.flags.useVotingUnits = true;
241
-
242
- ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(3);
243
-
244
- // Set custom voting units per tier.
245
- tiersHook.test_store().ForTest_setTierVotingUnits(address(tiersHook), 1, 100);
246
- tiersHook.test_store().ForTest_setTierVotingUnits(address(tiersHook), 2, 200);
247
- tiersHook.test_store().ForTest_setTierVotingUnits(address(tiersHook), 3, 500);
248
-
249
- address user = makeAddr("user");
250
-
251
- // Mint one from tier 1 to test checkpoint tracking.
252
- uint16[] memory tier1 = new uint16[](1);
253
- tier1[0] = 1;
254
- vm.prank(owner);
255
- tiersHook.mintFor(tier1, user);
256
-
257
- IJB721Checkpoints module = tiersHook.CHECKPOINTS();
258
-
259
- // User self-delegates.
260
- vm.prank(user);
261
- module.delegate(user);
262
-
263
- // Mint from remaining tiers.
264
- uint16[] memory tier2 = new uint16[](1);
265
- tier2[0] = 2;
266
- uint16[] memory tier3 = new uint16[](1);
267
- tier3[0] = 3;
268
-
269
- vm.startPrank(owner);
270
- tiersHook.mintFor(tier2, user);
271
- tiersHook.mintFor(tier3, user);
272
- vm.stopPrank();
273
-
274
- // 100 + 200 + 500 = 800.
275
- assertEq(module.getVotes(user), 800, "User should have 800 checkpointed votes");
276
- }
277
-
278
- // -------------------------------------------------------------------
279
- // Test 8: Burn decreases checkpointed total supply
280
- // -------------------------------------------------------------------
281
- function test_burn_decreasesTotalSupply() public {
282
- defaultTierConfig.flags.allowOwnerMint = true;
283
- defaultTierConfig.reserveFrequency = 0;
284
- defaultTierConfig.flags.useVotingUnits = true;
285
- defaultTierConfig.votingUnits = 100;
286
-
287
- ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
288
-
289
- address user = makeAddr("user");
290
-
291
- // Mint 2 NFTs (first one deploys CHECKPOINTS lazily).
292
- uint16[] memory tiersToMint = new uint16[](2);
293
- tiersToMint[0] = 1;
294
- tiersToMint[1] = 1;
295
- vm.prank(owner);
296
- tiersHook.mintFor(tiersToMint, user);
297
-
298
- IJB721Checkpoints module = tiersHook.CHECKPOINTS();
299
-
300
- // User self-delegates.
301
- vm.prank(user);
302
- module.delegate(user);
303
-
304
- assertEq(module.getVotes(user), 200, "User should have 200 votes from 2 NFTs");
305
-
306
- uint256 blockBeforeBurn = block.number;
307
- vm.roll(block.number + 1);
308
-
309
- // Burn one NFT.
310
- uint256[] memory tokensToBurn = new uint256[](1);
311
- tokensToBurn[0] = _generateTokenId(1, 1);
312
- tiersHook.burn(tokensToBurn);
313
-
314
- assertEq(module.getVotes(user), 100, "User should have 100 votes after burning 1 NFT");
315
-
316
- vm.roll(block.number + 1);
317
-
318
- assertEq(module.getPastTotalSupply(blockBeforeBurn), 200, "Past total supply before burn should be 200");
319
- }
320
-
321
- // -------------------------------------------------------------------
322
- // Test 9: Unauthorized onTransfer reverts
323
- // -------------------------------------------------------------------
324
- function test_unauthorizedOnTransfer_reverts() public {
325
- defaultTierConfig.flags.allowOwnerMint = true;
326
- defaultTierConfig.reserveFrequency = 0;
327
-
328
- ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
329
-
330
- // Mint to test checkpoint tracking.
331
- uint16[] memory tiersToMint = new uint16[](1);
332
- tiersToMint[0] = 1;
333
- vm.prank(owner);
334
- tiersHook.mintFor(tiersToMint, makeAddr("user"));
335
-
336
- IJB721Checkpoints module = tiersHook.CHECKPOINTS();
337
-
338
- vm.expectRevert(JB721Checkpoints.JB721Checkpoints_Unauthorized.selector);
339
- module.onTransfer(address(0), address(1), 1);
340
- }
341
- }
@@ -1,305 +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 {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
8
- import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
9
- import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
10
-
11
- // =====================================================================
12
- // Malicious ERC721 receiver that attempts reentrancy via safeTransferFrom
13
- // =====================================================================
14
-
15
- /// @notice A malicious ERC721 receiver that attempts to re-enter the hook contract during onERC721Received.
16
- contract MaliciousReceiver is IERC721Receiver {
17
- address public target;
18
- bytes public reentryCalldata;
19
- bool public reentryAttempted;
20
- bool public reentrySucceeded;
21
-
22
- function setReentryTarget(address _target, bytes memory _calldata) external {
23
- target = _target;
24
- reentryCalldata = _calldata;
25
- }
26
-
27
- function onERC721Received(address, address, uint256, bytes memory) external override returns (bytes4) {
28
- if (reentryCalldata.length > 0) {
29
- reentryAttempted = true;
30
- (bool success,) = target.call(reentryCalldata);
31
- reentrySucceeded = success;
32
- }
33
- return IERC721Receiver.onERC721Received.selector;
34
- }
35
-
36
- receive() external payable {}
37
- }
38
-
39
- /// @notice A malicious ERC721 receiver that attempts to transfer the NFT it just received back to itself during
40
- /// onERC721Received, testing reentrancy via safeTransferFrom.
41
- contract MaliciousRetransferReceiver is IERC721Receiver {
42
- address public hookAddress;
43
- bool public reentryAttempted;
44
- bool public reentryReverted;
45
- uint256 public receivedTokenId;
46
- address public nextRecipient;
47
-
48
- constructor(address _hook, address _nextRecipient) {
49
- hookAddress = _hook;
50
- nextRecipient = _nextRecipient;
51
- }
52
-
53
- function onERC721Received(address, address, uint256 tokenId, bytes memory) external override returns (bytes4) {
54
- receivedTokenId = tokenId;
55
- reentryAttempted = true;
56
- // Attempt to transfer the just-received NFT to another address during the callback.
57
- try IERC721(hookAddress).safeTransferFrom(address(this), nextRecipient, tokenId) {
58
- reentryReverted = false;
59
- } catch {
60
- reentryReverted = true;
61
- }
62
- return IERC721Receiver.onERC721Received.selector;
63
- }
64
-
65
- receive() external payable {}
66
- }
67
-
68
- // =====================================================================
69
- // Test Contract: Reentrancy via safeTransferFrom / onERC721Received
70
- // =====================================================================
71
-
72
- /// @title TestSafeTransferReentrancy
73
- /// @notice Tests that malicious ERC721 receivers cannot exploit reentrancy during safeTransferFrom.
74
- /// @dev The safeTransferFrom flow calls _checkOnERC721Received AFTER the transfer is complete
75
- /// (state already updated), so the receiver's onERC721Received callback fires with consistent state.
76
- /// These tests verify that re-entering the hook during that callback does not corrupt state.
77
- contract TestSafeTransferReentrancy is UnitTestSetup {
78
- using stdStorage for StdStorage;
79
-
80
- // ---------------------------------------------------------------
81
- // Test 1: Malicious receiver tries to re-enter afterPayRecordedWith
82
- // ---------------------------------------------------------------
83
- /// @notice A malicious receiver tries to call afterPayRecordedWith when receiving an NFT via safeTransferFrom.
84
- /// The re-entry fails because the receiver is not registered as a terminal.
85
- function test_safeTransferFrom_maliciousReceiver_cannotReenterAfterPay() public {
86
- // Set up hook with tiers and mint an NFT.
87
- defaultTierConfig.flags.allowOwnerMint = true;
88
- defaultTierConfig.reserveFrequency = 0;
89
- ForTest_JB721TiersHook testHook = _initializeForTestHook(10);
90
-
91
- // Mint an NFT to the beneficiary.
92
- uint16[] memory tiersToMint = new uint16[](1);
93
- tiersToMint[0] = 1;
94
- vm.prank(owner);
95
- testHook.mintFor(tiersToMint, beneficiary);
96
-
97
- uint256 tokenId = _generateTokenId(1, 1);
98
- assertEq(testHook.ownerOf(tokenId), beneficiary, "Beneficiary should own the NFT");
99
-
100
- // Deploy the malicious receiver.
101
- MaliciousReceiver malicious = new MaliciousReceiver();
102
-
103
- // Build reentrant calldata: try to call afterPayRecordedWith.
104
- uint16[] memory mintIds = new uint16[](1);
105
- mintIds[0] = 1;
106
-
107
- bytes[] memory data = new bytes[](1);
108
- data[0] = abi.encode(false, mintIds);
109
- bytes4[] memory ids = new bytes4[](1);
110
- ids[0] = metadataHelper.getId("pay", address(testHook));
111
- bytes memory payerMetadata = metadataHelper.createMetadata(ids, data);
112
-
113
- JBAfterPayRecordedContext memory reentrantContext = JBAfterPayRecordedContext({
114
- payer: beneficiary,
115
- projectId: projectId,
116
- rulesetId: 0,
117
- amount: JBTokenAmount({
118
- token: JBConstants.NATIVE_TOKEN,
119
- value: 1 ether,
120
- decimals: 18,
121
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
122
- }),
123
- forwardedAmount: JBTokenAmount({
124
- token: JBConstants.NATIVE_TOKEN,
125
- value: 0,
126
- decimals: 18,
127
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
128
- }),
129
- weight: 10e18,
130
- newlyIssuedTokenCount: 0,
131
- beneficiary: address(malicious),
132
- hookMetadata: bytes(""),
133
- payerMetadata: payerMetadata
134
- });
135
-
136
- malicious.setReentryTarget(address(testHook), abi.encodeCall(testHook.afterPayRecordedWith, (reentrantContext)));
137
-
138
- // Mock: the malicious receiver is NOT a terminal.
139
- vm.mockCall(
140
- address(mockJBDirectory),
141
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, address(malicious)),
142
- abi.encode(false)
143
- );
144
-
145
- // Transfer the NFT to the malicious receiver via safeTransferFrom.
146
- vm.prank(beneficiary);
147
- testHook.safeTransferFrom(beneficiary, address(malicious), tokenId);
148
-
149
- // Verify the transfer succeeded (state is consistent).
150
- assertEq(testHook.ownerOf(tokenId), address(malicious), "Malicious receiver should own the NFT");
151
-
152
- // Verify the reentrancy was attempted but failed.
153
- assertTrue(malicious.reentryAttempted(), "Reentrancy should have been attempted");
154
- assertFalse(malicious.reentrySucceeded(), "Reentrancy should have failed");
155
- }
156
-
157
- // ---------------------------------------------------------------
158
- // Test 2: Malicious receiver tries to re-enter adjustTiers
159
- // ---------------------------------------------------------------
160
- /// @notice A malicious receiver tries to call adjustTiers during onERC721Received.
161
- /// The call is blocked by permission checks.
162
- function test_safeTransferFrom_maliciousReceiver_cannotReenterAdjustTiers() public {
163
- // Set up hook with tiers and mint an NFT.
164
- defaultTierConfig.flags.allowOwnerMint = true;
165
- defaultTierConfig.reserveFrequency = 0;
166
- ForTest_JB721TiersHook testHook = _initializeForTestHook(10);
167
-
168
- // Mint an NFT to the beneficiary.
169
- uint16[] memory tiersToMint = new uint16[](1);
170
- tiersToMint[0] = 1;
171
- vm.prank(owner);
172
- testHook.mintFor(tiersToMint, beneficiary);
173
-
174
- uint256 tokenId = _generateTokenId(1, 1);
175
-
176
- // Deploy the malicious receiver that tries to remove tiers.
177
- MaliciousReceiver malicious = new MaliciousReceiver();
178
-
179
- // Build reentrant calldata: try to remove tier 1 via adjustTiers.
180
- uint256[] memory tierIdsToRemove = new uint256[](1);
181
- tierIdsToRemove[0] = 1;
182
- malicious.setReentryTarget(
183
- address(testHook), abi.encodeCall(testHook.adjustTiers, (new JB721TierConfig[](0), tierIdsToRemove))
184
- );
185
-
186
- // Mock: the malicious receiver does NOT have permission.
187
- vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(false));
188
-
189
- // Transfer the NFT.
190
- vm.prank(beneficiary);
191
- testHook.safeTransferFrom(beneficiary, address(malicious), tokenId);
192
-
193
- // Verify state consistency.
194
- assertEq(testHook.ownerOf(tokenId), address(malicious), "Malicious receiver should own the NFT");
195
- assertTrue(malicious.reentryAttempted(), "Reentrancy should have been attempted");
196
- assertFalse(malicious.reentrySucceeded(), "adjustTiers reentrancy should have failed");
197
- }
198
-
199
- // ---------------------------------------------------------------
200
- // Test 3: Malicious receiver re-transfers NFT during callback
201
- // ---------------------------------------------------------------
202
- /// @notice A malicious receiver tries to transfer the NFT it just received to another address
203
- /// during onERC721Received. This tests the re-entrant safeTransferFrom scenario.
204
- /// Since _update completes BEFORE _checkOnERC721Received is called, state is already settled
205
- /// and a re-transfer should succeed with correct state.
206
- function test_safeTransferFrom_maliciousReceiver_retransferDuringCallback() public {
207
- // Set up hook with tiers and mint an NFT.
208
- defaultTierConfig.flags.allowOwnerMint = true;
209
- defaultTierConfig.reserveFrequency = 0;
210
- ForTest_JB721TiersHook testHook = _initializeForTestHook(10);
211
-
212
- // Mint an NFT to the beneficiary.
213
- uint16[] memory tiersToMint = new uint16[](1);
214
- tiersToMint[0] = 1;
215
- vm.prank(owner);
216
- testHook.mintFor(tiersToMint, beneficiary);
217
-
218
- uint256 tokenId = _generateTokenId(1, 1);
219
-
220
- // The final recipient is a simple EOA.
221
- address finalRecipient = makeAddr("finalRecipient");
222
-
223
- // Deploy the malicious re-transfer receiver.
224
- MaliciousRetransferReceiver malicious = new MaliciousRetransferReceiver(address(testHook), finalRecipient);
225
-
226
- // Transfer to the malicious receiver.
227
- vm.prank(beneficiary);
228
- testHook.safeTransferFrom(beneficiary, address(malicious), tokenId);
229
-
230
- // The re-transfer should have succeeded (not reverted) because state was fully settled
231
- // before onERC721Received was called.
232
- assertTrue(malicious.reentryAttempted(), "Re-transfer should have been attempted");
233
- assertFalse(malicious.reentryReverted(), "Re-transfer should have succeeded");
234
-
235
- // Verify final state: the finalRecipient should own the NFT.
236
- assertEq(testHook.ownerOf(tokenId), finalRecipient, "Final recipient should own the NFT");
237
-
238
- // Verify voting units are tracked correctly after the double transfer.
239
- // Beneficiary should have 0, malicious should have 0, finalRecipient should have the tier's balance.
240
- IJB721TiersHookStore hookStore = testHook.STORE();
241
- assertEq(
242
- hookStore.tierBalanceOf(address(testHook), beneficiary, 1), 0, "Beneficiary should have 0 tier balance"
243
- );
244
- assertEq(
245
- hookStore.tierBalanceOf(address(testHook), address(malicious), 1), 0, "Malicious should have 0 tier balance"
246
- );
247
- assertEq(
248
- hookStore.tierBalanceOf(address(testHook), finalRecipient, 1),
249
- 1,
250
- "Final recipient should have 1 tier balance"
251
- );
252
- }
253
-
254
- // ---------------------------------------------------------------
255
- // Test 4: State consistency after safeTransferFrom (no reentrancy)
256
- // ---------------------------------------------------------------
257
- /// @notice Verifies that a normal safeTransferFrom to a contract receiver keeps
258
- /// tier balances, voting units, and firstOwner tracking consistent.
259
- function test_safeTransferFrom_normalReceiver_stateConsistent() public {
260
- // Set up hook with tiers and mint an NFT.
261
- defaultTierConfig.flags.allowOwnerMint = true;
262
- defaultTierConfig.reserveFrequency = 0;
263
- ForTest_JB721TiersHook testHook = _initializeForTestHook(10);
264
-
265
- // Mint an NFT to the beneficiary.
266
- uint16[] memory tiersToMint = new uint16[](1);
267
- tiersToMint[0] = 1;
268
- vm.prank(owner);
269
- testHook.mintFor(tiersToMint, beneficiary);
270
-
271
- uint256 tokenId = _generateTokenId(1, 1);
272
-
273
- // Deploy a clean receiver (no reentrancy).
274
- MaliciousReceiver cleanReceiver = new MaliciousReceiver();
275
- // Don't set any reentrancy target — it will just accept the NFT.
276
-
277
- IJB721TiersHookStore hookStore = testHook.STORE();
278
-
279
- // Verify pre-transfer state.
280
- assertEq(
281
- hookStore.tierBalanceOf(address(testHook), beneficiary, 1), 1, "Beneficiary should have 1 NFT pre-xfer"
282
- );
283
- assertEq(
284
- hookStore.tierBalanceOf(address(testHook), address(cleanReceiver), 1),
285
- 0,
286
- "Receiver should have 0 NFTs pre-xfer"
287
- );
288
-
289
- // Transfer.
290
- vm.prank(beneficiary);
291
- testHook.safeTransferFrom(beneficiary, address(cleanReceiver), tokenId);
292
-
293
- // Verify post-transfer state.
294
- assertEq(testHook.ownerOf(tokenId), address(cleanReceiver), "Receiver should own the NFT");
295
- assertEq(
296
- hookStore.tierBalanceOf(address(testHook), beneficiary, 1), 0, "Beneficiary should have 0 NFTs post-xfer"
297
- );
298
- assertEq(
299
- hookStore.tierBalanceOf(address(testHook), address(cleanReceiver), 1),
300
- 1,
301
- "Receiver should have 1 NFT post-xfer"
302
- );
303
- assertEq(testHook.firstOwnerOf(tokenId), beneficiary, "First owner should still be beneficiary");
304
- }
305
- }