@bananapus/721-hook-v6 0.0.42 → 0.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/foundry.lock +1 -7
  2. package/foundry.toml +1 -1
  3. package/package.json +20 -9
  4. package/script/Deploy.s.sol +2 -2
  5. package/src/JB721Checkpoints.sol +61 -19
  6. package/src/JB721CheckpointsDeployer.sol +10 -5
  7. package/src/JB721TiersHook.sol +66 -53
  8. package/src/JB721TiersHookDeployer.sol +8 -5
  9. package/src/JB721TiersHookProjectDeployer.sol +87 -46
  10. package/src/JB721TiersHookStore.sol +137 -107
  11. package/src/abstract/JB721Hook.sol +8 -6
  12. package/src/interfaces/IJB721Checkpoints.sol +21 -14
  13. package/src/interfaces/IJB721CheckpointsDeployer.sol +7 -3
  14. package/src/interfaces/IJB721TiersHook.sol +3 -3
  15. package/src/interfaces/IJB721TiersHookProjectDeployer.sol +4 -2
  16. package/src/interfaces/IJB721TiersHookStore.sol +11 -11
  17. package/src/libraries/JB721TiersHookLib.sol +1 -1
  18. package/src/structs/JB721TiersHookFlags.sol +1 -1
  19. package/src/structs/JBPayDataHookRulesetMetadata.sol +1 -1
  20. package/test/utils/AccessJBLib.sol +49 -0
  21. package/test/utils/ForTest_JB721TiersHook.sol +246 -0
  22. package/test/utils/TestBaseWorkflow.sol +213 -0
  23. package/test/utils/UnitTestSetup.sol +805 -0
  24. package/.gas-snapshot +0 -152
  25. package/ADMINISTRATION.md +0 -87
  26. package/ARCHITECTURE.md +0 -98
  27. package/AUDIT_INSTRUCTIONS.md +0 -77
  28. package/RISKS.md +0 -118
  29. package/SKILLS.md +0 -43
  30. package/STYLE_GUIDE.md +0 -610
  31. package/USER_JOURNEYS.md +0 -121
  32. package/assets/findings/nana-721-hook-v6-pashov-ai-audit-report-20260330-091257.md +0 -83
  33. package/slither-ci.config.json +0 -10
  34. package/test/721HookAttacks.t.sol +0 -408
  35. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +0 -985
  36. package/test/Fork.t.sol +0 -2346
  37. package/test/TestAuditGaps.sol +0 -1075
  38. package/test/TestCheckpoints.t.sol +0 -341
  39. package/test/TestSafeTransferReentrancy.t.sol +0 -305
  40. package/test/TestVotingUnitsLifecycle.t.sol +0 -313
  41. package/test/audit/AuditRegressions.t.sol +0 -83
  42. package/test/audit/CodexNemesisReserveSellout.t.sol +0 -66
  43. package/test/audit/CrossCurrencySplitNoPrices.t.sol +0 -123
  44. package/test/audit/FreshAudit.t.sol +0 -197
  45. package/test/audit/FutureTierPoC.t.sol +0 -39
  46. package/test/audit/FutureTierRemoval.t.sol +0 -47
  47. package/test/audit/Pass12L18.t.sol +0 -80
  48. package/test/audit/PayCreditsBypassTierSplits.t.sol +0 -200
  49. package/test/audit/ProjectDeployerAuth.t.sol +0 -266
  50. package/test/audit/RepoFindings.t.sol +0 -195
  51. package/test/audit/ReserveActivation.t.sol +0 -87
  52. package/test/audit/ReserveSlotProtection.t.sol +0 -273
  53. package/test/audit/RetroactiveReserveBeneficiaryDilution.t.sol +0 -149
  54. package/test/audit/SameCurrencyDecimalMismatch.t.sol +0 -249
  55. package/test/audit/SplitCreditsMismatch.t.sol +0 -219
  56. package/test/audit/SplitFailureRedistribution.t.sol +0 -143
  57. package/test/audit/USDTVoidReturnCompat.t.sol +0 -301
  58. package/test/fork/ERC20CashOutFork.t.sol +0 -633
  59. package/test/fork/ERC20TierSplitFork.t.sol +0 -596
  60. package/test/fork/IssueTokensForSplitsFork.t.sol +0 -516
  61. package/test/invariants/TierLifecycleInvariant.t.sol +0 -188
  62. package/test/invariants/TieredHookStoreInvariant.t.sol +0 -86
  63. package/test/invariants/handlers/TierLifecycleHandler.sol +0 -300
  64. package/test/invariants/handlers/TierStoreHandler.sol +0 -165
  65. package/test/regression/BrokenTerminalDoesNotDos.t.sol +0 -277
  66. package/test/regression/CacheTierLookup.t.sol +0 -190
  67. package/test/regression/ProjectDeployerRulesets.t.sol +0 -358
  68. package/test/regression/ReserveBeneficiaryOverwrite.t.sol +0 -155
  69. package/test/regression/SplitDistributionBugs.t.sol +0 -751
  70. package/test/regression/SplitNoBeneficiary.t.sol +0 -140
  71. package/test/unit/AuditFixes_Unit.t.sol +0 -624
  72. package/test/unit/JB721CheckpointsDeployer_AccessControl.t.sol +0 -116
  73. package/test/unit/JB721TiersRulesetMetadataResolver.t.sol +0 -144
  74. package/test/unit/JBBitmap.t.sol +0 -170
  75. package/test/unit/JBIpfsDecoder.t.sol +0 -136
  76. package/test/unit/TierSupplyReserveCheck.t.sol +0 -221
  77. package/test/unit/adjustTier_Unit.t.sol +0 -1942
  78. package/test/unit/deployer_Unit.t.sol +0 -114
  79. package/test/unit/getters_constructor_Unit.t.sol +0 -593
  80. package/test/unit/mintFor_mintReservesFor_Unit.t.sol +0 -452
  81. package/test/unit/pay_CrossCurrency_Unit.t.sol +0 -530
  82. package/test/unit/pay_Unit.t.sol +0 -1661
  83. package/test/unit/redeem_Unit.t.sol +0 -473
  84. package/test/unit/relayBeneficiary_Unit.t.sol +0 -182
  85. package/test/unit/splitHookDistribution_Unit.t.sol +0 -604
  86. package/test/unit/tierSplitRouting_Unit.t.sol +0 -757
@@ -1,751 +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 {IJB721TiersHook} from "../../src/interfaces/IJB721TiersHook.sol";
7
- import {IJB721TiersHookStore} from "../../src/interfaces/IJB721TiersHookStore.sol";
8
- import {JB721TiersHookStore} from "../../src/JB721TiersHookStore.sol";
9
- // forge-lint: disable-next-line(unused-import)
10
- import {JB721TiersHookLib} from "../../src/libraries/JB721TiersHookLib.sol";
11
- import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
12
- import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
13
- import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
14
- import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
15
- import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
16
- import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
17
- import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
18
- import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
19
-
20
- /// @notice Regression tests for split distribution bugs in JB721TiersHookLib.
21
- contract Test_SplitDistributionBugs is UnitTestSetup {
22
- using stdStorage for StdStorage;
23
-
24
- address alice = makeAddr("alice");
25
- address bob = makeAddr("bob");
26
-
27
- function setUp() public override {
28
- super.setUp();
29
- vm.etch(mockJBSplits, new bytes(0x69));
30
- }
31
-
32
- // Helper: build payer metadata for tier IDs.
33
- function _buildPayerMetadata(
34
- address hookAddress,
35
- uint16[] memory tierIdsToMint
36
- )
37
- internal
38
- view
39
- returns (bytes memory)
40
- {
41
- bytes[] memory data = new bytes[](1);
42
- data[0] = abi.encode(false, tierIdsToMint);
43
- bytes4[] memory ids = new bytes4[](1);
44
- ids[0] = metadataHelper.getId("pay", hookAddress);
45
- return metadataHelper.createMetadata(ids, data);
46
- }
47
-
48
- // ──────────────────────────────────────────────────────────────────────
49
- // Split underflow DoS: _distributeSingleSplit with 2+ splits
50
- // ──────────────────────────────────────────────────────────────────────
51
-
52
- /// @notice When two splits each get 50% of a tier's funds, the old code used `amount`
53
- /// (the original total) in mulDiv for every split. After the first split consumed half
54
- /// the funds, the second split would compute its payout from the original `amount`,
55
- /// yielding a value that exceeds `leftoverAmount`. The unchecked subtraction would
56
- /// underflow, causing a revert (DoS).
57
- ///
58
- /// With the fix (using `leftoverAmount` instead of `amount`), the second split
59
- /// correctly computes its payout from the remaining funds and the distribution succeeds.
60
- function test_splitDistribution_twoSplits_usesLeftoverAmount_noUnderflow() public {
61
- ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
62
- IJB721TiersHookStore hookStore = testHook.STORE();
63
-
64
- // Add a tier with 100% split, priced at 1 ETH.
65
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
66
- tierConfigs[0].price = 1 ether;
67
- tierConfigs[0].initialSupply = uint32(100);
68
- tierConfigs[0].category = uint24(1);
69
- tierConfigs[0].encodedIPFSUri = bytes32(uint256(0x1234));
70
- tierConfigs[0].splitPercent = 1_000_000_000; // 100%
71
-
72
- vm.prank(address(testHook));
73
- uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
74
-
75
- // Mock directory checks.
76
- mockAndExpect(
77
- address(mockJBDirectory),
78
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
79
- abi.encode(true)
80
- );
81
-
82
- // Mock splits: TWO beneficiaries each with 50%.
83
- JBSplit[] memory splits = new JBSplit[](2);
84
- splits[0] = JBSplit({
85
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT / 2), // 50%
86
- projectId: 0,
87
- beneficiary: payable(alice),
88
- preferAddToBalance: false,
89
- lockedUntil: 0,
90
- hook: IJBSplitHook(address(0))
91
- });
92
- splits[1] = JBSplit({
93
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT / 2), // 50%
94
- projectId: 0,
95
- beneficiary: payable(bob),
96
- preferAddToBalance: false,
97
- lockedUntil: 0,
98
- hook: IJBSplitHook(address(0))
99
- });
100
-
101
- uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
102
- mockAndExpect(
103
- mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
104
- );
105
-
106
- // Build payer metadata.
107
- uint16[] memory mintIds = new uint16[](1);
108
- mintIds[0] = uint16(tierIds[0]);
109
- bytes memory payerMetadata = _buildPayerMetadata(address(testHook), mintIds);
110
-
111
- // Build hook metadata (per-tier split breakdown).
112
- uint16[] memory splitTierIds = new uint16[](1);
113
- splitTierIds[0] = uint16(tierIds[0]);
114
- uint256[] memory splitAmounts = new uint256[](1);
115
- splitAmounts[0] = 1 ether; // Full tier price as split amount.
116
-
117
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
118
- payer: beneficiary,
119
- projectId: projectId,
120
- rulesetId: 0,
121
- amount: JBTokenAmount({
122
- token: JBConstants.NATIVE_TOKEN,
123
- value: 1 ether,
124
- decimals: 18,
125
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
126
- }),
127
- forwardedAmount: JBTokenAmount({
128
- token: JBConstants.NATIVE_TOKEN,
129
- value: 1 ether,
130
- decimals: 18,
131
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
132
- }),
133
- weight: 10e18,
134
- newlyIssuedTokenCount: 0,
135
- beneficiary: beneficiary,
136
- hookMetadata: abi.encode(beneficiary, beneficiary, abi.encode(splitTierIds, splitAmounts)),
137
- payerMetadata: payerMetadata
138
- });
139
-
140
- uint256 aliceBalanceBefore = alice.balance;
141
- uint256 bobBalanceBefore = bob.balance;
142
-
143
- vm.deal(mockTerminalAddress, 2 ether);
144
- vm.prank(mockTerminalAddress);
145
- // This would revert with the old code due to underflow. With the fix it succeeds.
146
- testHook.afterPayRecordedWith{value: 1 ether}(payContext);
147
-
148
- // Alice and Bob should each receive 0.5 ETH.
149
- assertEq(alice.balance - aliceBalanceBefore, 0.5 ether, "Alice should receive 0.5 ETH");
150
- assertEq(bob.balance - bobBalanceBefore, 0.5 ether, "Bob should receive 0.5 ETH");
151
- }
152
-
153
- // ──────────────────────────────────────────────────────────────────────
154
- // Split amounts must use discounted tier price, not full price
155
- // ──────────────────────────────────────────────────────────────────────
156
-
157
- /// @notice When a tier has a discount, `calculateSplitAmounts()` should use the
158
- /// discounted price (matching what `recordMint` charges), not the full undiscounted
159
- /// tier price. Without the fix, the split amount would be inflated.
160
- function test_calculateSplitAmounts_usesDiscountedPrice() public {
161
- ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
162
- IJB721TiersHookStore hookStore = testHook.STORE();
163
-
164
- // Add a tier with 50% discount and 100% split, priced at 1 ETH.
165
- // discountPercent=100 out of DISCOUNT_DENOMINATOR=200 means a 50% discount.
166
- // So the effective price should be 0.5 ETH.
167
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
168
- tierConfigs[0].price = 1 ether;
169
- tierConfigs[0].initialSupply = uint32(100);
170
- tierConfigs[0].category = uint24(1);
171
- tierConfigs[0].encodedIPFSUri = bytes32(uint256(0x1234));
172
- tierConfigs[0].splitPercent = 1_000_000_000; // 100%
173
- tierConfigs[0].discountPercent = 100; // 50% discount (100/200)
174
-
175
- vm.prank(address(testHook));
176
- uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
177
-
178
- // Build payer metadata requesting that tier.
179
- uint16[] memory mintIds = new uint16[](1);
180
- mintIds[0] = uint16(tierIds[0]);
181
- bytes memory payerMetadata = _buildPayerMetadata(address(testHook), mintIds);
182
-
183
- // Call calculateSplitAmounts via beforePayRecordedWith.
184
- JBBeforePayRecordedContext memory context = JBBeforePayRecordedContext({
185
- terminal: mockTerminalAddress,
186
- payer: beneficiary,
187
- amount: JBTokenAmount({
188
- token: JBConstants.NATIVE_TOKEN,
189
- value: 1 ether,
190
- decimals: 18,
191
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
192
- }),
193
- projectId: projectId,
194
- rulesetId: 0,
195
- beneficiary: beneficiary,
196
- weight: 10e18,
197
- reservedPercent: 5000,
198
- metadata: payerMetadata
199
- });
200
-
201
- (, JBPayHookSpecification[] memory specs) = testHook.beforePayRecordedWith(context);
202
-
203
- // With 50% discount on a 1 ETH tier and 100% split, the split amount should be 0.5 ETH.
204
- // Without the fix, it would be 1 ETH (using the full undiscounted price).
205
- assertEq(specs[0].amount, 0.5 ether, "Split amount should use discounted price (0.5 ETH, not 1 ETH)");
206
- }
207
-
208
- /// @notice Verify the discounted split amount math matches what recordMint charges.
209
- /// A tier priced at 2 ETH with a 25% discount (discountPercent=50, denominator=200)
210
- /// has effective price 1.5 ETH. With 50% split, the split amount should be 0.75 ETH.
211
- function test_calculateSplitAmounts_partialDiscount_partialSplit() public {
212
- ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
213
- IJB721TiersHookStore hookStore = testHook.STORE();
214
-
215
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
216
- tierConfigs[0].price = 2 ether;
217
- tierConfigs[0].initialSupply = uint32(100);
218
- tierConfigs[0].category = uint24(1);
219
- tierConfigs[0].encodedIPFSUri = bytes32(uint256(0x1234));
220
- tierConfigs[0].splitPercent = 500_000_000; // 50%
221
- tierConfigs[0].discountPercent = 50; // 25% discount (50/200)
222
-
223
- vm.prank(address(testHook));
224
- uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
225
-
226
- uint16[] memory mintIds = new uint16[](1);
227
- mintIds[0] = uint16(tierIds[0]);
228
- bytes memory payerMetadata = _buildPayerMetadata(address(testHook), mintIds);
229
-
230
- JBBeforePayRecordedContext memory context = JBBeforePayRecordedContext({
231
- terminal: mockTerminalAddress,
232
- payer: beneficiary,
233
- amount: JBTokenAmount({
234
- token: JBConstants.NATIVE_TOKEN,
235
- value: 2 ether,
236
- decimals: 18,
237
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
238
- }),
239
- projectId: projectId,
240
- rulesetId: 0,
241
- beneficiary: beneficiary,
242
- weight: 10e18,
243
- reservedPercent: 5000,
244
- metadata: payerMetadata
245
- });
246
-
247
- (, JBPayHookSpecification[] memory specs) = testHook.beforePayRecordedWith(context);
248
-
249
- // effectivePrice = 2 ETH - (2 ETH * 50 / 200) = 2 ETH - 0.5 ETH = 1.5 ETH
250
- // splitAmount = 1.5 ETH * 50% = 0.75 ETH
251
- assertEq(specs[0].amount, 0.75 ether, "Split amount should be 0.75 ETH with 25% discount and 50% split");
252
- }
253
-
254
- // ──────────────────────────────────────────────────────────────────────
255
- // Failed ETH send to split beneficiary should not revert entire payment
256
- // ──────────────────────────────────────────────────────────────────────
257
-
258
- /// @notice When an ETH send to a split beneficiary fails (e.g., the beneficiary is a
259
- /// contract that reverts on receive), the old code propagated the revert, causing the
260
- /// entire payment to fail. With the fix, the failed send returns false, and the funds
261
- /// are routed to the project's balance instead.
262
- function test_revertingSplitBeneficiary_routesToProjectBalance() public {
263
- ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
264
- IJB721TiersHookStore hookStore = testHook.STORE();
265
-
266
- // Add a tier with 100% split.
267
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
268
- tierConfigs[0].price = 1 ether;
269
- tierConfigs[0].initialSupply = uint32(100);
270
- tierConfigs[0].category = uint24(1);
271
- tierConfigs[0].encodedIPFSUri = bytes32(uint256(0x1234));
272
- tierConfigs[0].splitPercent = 1_000_000_000; // 100%
273
-
274
- vm.prank(address(testHook));
275
- uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
276
-
277
- // Mock directory checks.
278
- mockAndExpect(
279
- address(mockJBDirectory),
280
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
281
- abi.encode(true)
282
- );
283
-
284
- // Deploy a contract that always reverts when receiving ETH.
285
- RevertOnReceive revertingBeneficiary = new RevertOnReceive();
286
-
287
- // Mock splits: 100% to a beneficiary that reverts on ETH receive.
288
- JBSplit[] memory splits = new JBSplit[](1);
289
- splits[0] = JBSplit({
290
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
291
- projectId: 0,
292
- beneficiary: payable(address(revertingBeneficiary)),
293
- preferAddToBalance: false,
294
- lockedUntil: 0,
295
- hook: IJBSplitHook(address(0))
296
- });
297
-
298
- uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
299
- mockAndExpect(
300
- mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
301
- );
302
-
303
- // Mock the project's primary terminal for the fallback addToBalance.
304
- address projectTerminal = makeAddr("projectTerminal");
305
- vm.etch(projectTerminal, new bytes(0x69));
306
- mockAndExpect(
307
- address(mockJBDirectory),
308
- abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, projectId, JBConstants.NATIVE_TOKEN),
309
- abi.encode(projectTerminal)
310
- );
311
-
312
- // Expect addToBalanceOf to be called on the project's terminal with the full 1 ETH
313
- // (since the beneficiary failed, funds route to project balance).
314
- vm.expectCall(
315
- projectTerminal,
316
- 1 ether,
317
- abi.encodeWithSelector(
318
- IJBTerminal.addToBalanceOf.selector, projectId, JBConstants.NATIVE_TOKEN, 1 ether, false, "", ""
319
- )
320
- );
321
- vm.mockCall(projectTerminal, abi.encodeWithSelector(IJBTerminal.addToBalanceOf.selector), abi.encode());
322
-
323
- // Build payer metadata.
324
- uint16[] memory mintIds = new uint16[](1);
325
- mintIds[0] = uint16(tierIds[0]);
326
- bytes memory payerMetadata = _buildPayerMetadata(address(testHook), mintIds);
327
-
328
- // Build hook metadata.
329
- uint16[] memory splitTierIds = new uint16[](1);
330
- splitTierIds[0] = uint16(tierIds[0]);
331
- uint256[] memory splitAmounts = new uint256[](1);
332
- splitAmounts[0] = 1 ether;
333
-
334
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
335
- payer: beneficiary,
336
- projectId: projectId,
337
- rulesetId: 0,
338
- amount: JBTokenAmount({
339
- token: JBConstants.NATIVE_TOKEN,
340
- value: 1 ether,
341
- decimals: 18,
342
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
343
- }),
344
- forwardedAmount: JBTokenAmount({
345
- token: JBConstants.NATIVE_TOKEN,
346
- value: 1 ether,
347
- decimals: 18,
348
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
349
- }),
350
- weight: 10e18,
351
- newlyIssuedTokenCount: 0,
352
- beneficiary: beneficiary,
353
- hookMetadata: abi.encode(beneficiary, beneficiary, abi.encode(splitTierIds, splitAmounts)),
354
- payerMetadata: payerMetadata
355
- });
356
-
357
- vm.deal(mockTerminalAddress, 2 ether);
358
- vm.prank(mockTerminalAddress);
359
- // With the old code this would revert. With the fix, it succeeds and routes to project balance.
360
- testHook.afterPayRecordedWith{value: 1 ether}(payContext);
361
-
362
- // Verify the reverting beneficiary received nothing.
363
- assertEq(address(revertingBeneficiary).balance, 0, "Reverting beneficiary should receive nothing");
364
- }
365
-
366
- // ──────────────────────────────────────────────────────────────────────
367
- // Reverting split hook (ETH) should not brick payments
368
- // ──────────────────────────────────────────────────────────────────────
369
-
370
- /// @notice A reverting split hook (ETH path) should not revert the entire payment.
371
- /// The try-catch catches the revert, returns false, and funds route to project balance.
372
- function test_revertingSplitHook_eth_routesToProjectBalance() public {
373
- ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
374
- IJB721TiersHookStore hookStore = testHook.STORE();
375
-
376
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
377
- tierConfigs[0].price = 1 ether;
378
- tierConfigs[0].initialSupply = uint32(100);
379
- tierConfigs[0].category = uint24(1);
380
- tierConfigs[0].encodedIPFSUri = bytes32(uint256(0x1234));
381
- tierConfigs[0].splitPercent = 1_000_000_000; // 100%
382
-
383
- vm.prank(address(testHook));
384
- uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
385
-
386
- mockAndExpect(
387
- address(mockJBDirectory),
388
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
389
- abi.encode(true)
390
- );
391
-
392
- // Deploy a split hook that always reverts.
393
- RevertingSplitHook revertingHook = new RevertingSplitHook();
394
-
395
- // Split: 100% to the reverting hook.
396
- JBSplit[] memory splits = new JBSplit[](1);
397
- splits[0] = JBSplit({
398
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
399
- projectId: 0,
400
- beneficiary: payable(address(0)),
401
- preferAddToBalance: false,
402
- lockedUntil: 0,
403
- hook: IJBSplitHook(address(revertingHook))
404
- });
405
-
406
- uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
407
- mockAndExpect(
408
- mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
409
- );
410
-
411
- // Mock the project's primary terminal for the fallback addToBalance.
412
- address projectTerminal = makeAddr("projectTerminal");
413
- vm.etch(projectTerminal, new bytes(0x69));
414
- mockAndExpect(
415
- address(mockJBDirectory),
416
- abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, projectId, JBConstants.NATIVE_TOKEN),
417
- abi.encode(projectTerminal)
418
- );
419
-
420
- // Expect addToBalanceOf on the project's terminal with the full 1 ETH.
421
- vm.expectCall(
422
- projectTerminal,
423
- 1 ether,
424
- abi.encodeWithSelector(
425
- IJBTerminal.addToBalanceOf.selector, projectId, JBConstants.NATIVE_TOKEN, 1 ether, false, "", ""
426
- )
427
- );
428
- vm.mockCall(projectTerminal, abi.encodeWithSelector(IJBTerminal.addToBalanceOf.selector), abi.encode());
429
-
430
- uint16[] memory mintIds = new uint16[](1);
431
- mintIds[0] = uint16(tierIds[0]);
432
- bytes memory payerMetadata = _buildPayerMetadata(address(testHook), mintIds);
433
-
434
- uint16[] memory splitTierIds = new uint16[](1);
435
- splitTierIds[0] = uint16(tierIds[0]);
436
- uint256[] memory splitAmounts = new uint256[](1);
437
- splitAmounts[0] = 1 ether;
438
-
439
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
440
- payer: beneficiary,
441
- projectId: projectId,
442
- rulesetId: 0,
443
- amount: JBTokenAmount({
444
- token: JBConstants.NATIVE_TOKEN,
445
- value: 1 ether,
446
- decimals: 18,
447
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
448
- }),
449
- forwardedAmount: JBTokenAmount({
450
- token: JBConstants.NATIVE_TOKEN,
451
- value: 1 ether,
452
- decimals: 18,
453
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
454
- }),
455
- weight: 10e18,
456
- newlyIssuedTokenCount: 0,
457
- beneficiary: beneficiary,
458
- hookMetadata: abi.encode(beneficiary, beneficiary, abi.encode(splitTierIds, splitAmounts)),
459
- payerMetadata: payerMetadata
460
- });
461
-
462
- // Expect the SplitPayoutReverted event (check only the indexed projectId topic).
463
- vm.expectEmit(true, false, false, false);
464
- emit IJB721TiersHook.SplitPayoutReverted(projectId, splits[0], 1 ether, "", mockTerminalAddress);
465
-
466
- vm.deal(mockTerminalAddress, 2 ether);
467
- vm.prank(mockTerminalAddress);
468
- // Should NOT revert — the try-catch catches the hook's revert.
469
- testHook.afterPayRecordedWith{value: 1 ether}(payContext);
470
-
471
- // Reverting hook should have received nothing.
472
- assertEq(address(revertingHook).balance, 0, "Reverting hook should receive no ETH");
473
- }
474
-
475
- // ──────────────────────────────────────────────────────────────────────
476
- // Reverting split hook (ERC-20) should not brick payments
477
- // ──────────────────────────────────────────────────────────────────────
478
-
479
- /// @notice A reverting ERC-20 split hook should NOT block the payment. Tokens are transferred
480
- /// via safeTransfer BEFORE the callback, so the function must return true regardless of
481
- /// callback success. The hook receives the tokens even though its callback reverted.
482
- function test_revertingSplitHook_erc20_tokensTransferredCallbackIgnored() public {
483
- MintableERC20 usdc = new MintableERC20("USD Coin", "USDC", 6);
484
- uint32 usdcCurrency = uint32(uint160(address(usdc)));
485
-
486
- JB721TiersHook testHook = _initHookDefaultTiers(0, false, usdcCurrency, 6);
487
-
488
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
489
- tierConfigs[0].price = 100e6;
490
- tierConfigs[0].initialSupply = uint32(100);
491
- tierConfigs[0].category = uint24(1);
492
- tierConfigs[0].encodedIPFSUri = bytes32(uint256(0x1234));
493
- tierConfigs[0].splitPercent = 1_000_000_000; // 100%
494
-
495
- vm.prank(address(testHook));
496
- uint256[] memory tierIds = testHook.STORE().recordAddTiers(tierConfigs);
497
-
498
- mockAndExpect(
499
- address(mockJBDirectory),
500
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
501
- abi.encode(true)
502
- );
503
-
504
- RevertingSplitHook revertingHook = new RevertingSplitHook();
505
-
506
- JBSplit[] memory splits = new JBSplit[](1);
507
- splits[0] = JBSplit({
508
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
509
- projectId: 0,
510
- beneficiary: payable(address(0)),
511
- preferAddToBalance: false,
512
- lockedUntil: 0,
513
- hook: IJBSplitHook(address(revertingHook))
514
- });
515
-
516
- uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
517
- mockAndExpect(
518
- mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
519
- );
520
-
521
- JBAfterPayRecordedContext memory payContext =
522
- _buildErc20PayContext(address(testHook), address(usdc), usdcCurrency, 6, tierIds, 100e6);
523
-
524
- usdc.mint(mockTerminalAddress, 100e6);
525
- vm.prank(mockTerminalAddress);
526
- usdc.approve(address(testHook), 100e6);
527
-
528
- // Expect the SplitPayoutReverted event (emitted even though tokens were transferred).
529
- vm.expectEmit(true, false, false, false);
530
- emit IJB721TiersHook.SplitPayoutReverted(projectId, splits[0], 100e6, "", mockTerminalAddress);
531
-
532
- vm.prank(mockTerminalAddress);
533
- testHook.afterPayRecordedWith(payContext);
534
-
535
- assertEq(usdc.balanceOf(address(revertingHook)), 100e6, "Hook should receive ERC20 despite callback revert");
536
- }
537
-
538
- /// @notice Helper to build an ERC-20 afterPay context (reduces stack depth).
539
- function _buildErc20PayContext(
540
- address hookAddress,
541
- address token,
542
- uint32 currency,
543
- uint8 decimals,
544
- uint256[] memory tierIds,
545
- uint256 amount
546
- )
547
- internal
548
- view
549
- returns (JBAfterPayRecordedContext memory)
550
- {
551
- uint16[] memory mintIds = new uint16[](1);
552
- mintIds[0] = uint16(tierIds[0]);
553
- bytes memory payerMetadata = _buildPayerMetadata(hookAddress, mintIds);
554
-
555
- uint16[] memory splitTierIds = new uint16[](1);
556
- splitTierIds[0] = uint16(tierIds[0]);
557
- uint256[] memory splitAmounts = new uint256[](1);
558
- splitAmounts[0] = amount;
559
-
560
- return JBAfterPayRecordedContext({
561
- payer: beneficiary,
562
- projectId: projectId,
563
- rulesetId: 0,
564
- amount: JBTokenAmount({token: token, value: amount, decimals: decimals, currency: currency}),
565
- forwardedAmount: JBTokenAmount({token: token, value: amount, decimals: decimals, currency: currency}),
566
- weight: 10e18,
567
- newlyIssuedTokenCount: 0,
568
- beneficiary: beneficiary,
569
- hookMetadata: abi.encode(beneficiary, beneficiary, abi.encode(splitTierIds, splitAmounts)),
570
- payerMetadata: payerMetadata
571
- });
572
- }
573
-
574
- // ──────────────────────────────────────────────────────────────────────
575
- // Reverting terminal (ETH) should not brick payments
576
- // ──────────────────────────────────────────────────────────────────────
577
-
578
- /// @notice When a split targets a project whose terminal reverts on pay(), the try-catch
579
- /// catches the revert, returns false, and funds route to the project's own balance.
580
- function test_revertingTerminal_eth_routesToProjectBalance() public {
581
- ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
582
- IJB721TiersHookStore hookStore = testHook.STORE();
583
-
584
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
585
- tierConfigs[0].price = 1 ether;
586
- tierConfigs[0].initialSupply = uint32(100);
587
- tierConfigs[0].category = uint24(1);
588
- tierConfigs[0].encodedIPFSUri = bytes32(uint256(0x1234));
589
- tierConfigs[0].splitPercent = 1_000_000_000; // 100%
590
-
591
- vm.prank(address(testHook));
592
- uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
593
-
594
- mockAndExpect(
595
- address(mockJBDirectory),
596
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
597
- abi.encode(true)
598
- );
599
-
600
- // Set up a split targeting project 99 (whose terminal will revert).
601
- uint64 targetProjectId = 99;
602
-
603
- // Mock primaryTerminalOf for project 99 to return a reverting terminal.
604
- address revertingTerminal = makeAddr("revertingTerminal");
605
- vm.etch(revertingTerminal, new bytes(0x69));
606
- mockAndExpect(
607
- address(mockJBDirectory),
608
- abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, targetProjectId, JBConstants.NATIVE_TOKEN),
609
- abi.encode(revertingTerminal)
610
- );
611
-
612
- // Make the terminal's pay() revert.
613
- vm.mockCallRevert(revertingTerminal, abi.encodeWithSelector(IJBTerminal.pay.selector), "terminal broken");
614
-
615
- JBSplit[] memory splits = new JBSplit[](1);
616
- splits[0] = JBSplit({
617
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
618
- projectId: targetProjectId,
619
- beneficiary: payable(alice),
620
- preferAddToBalance: false,
621
- lockedUntil: 0,
622
- hook: IJBSplitHook(address(0))
623
- });
624
-
625
- uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
626
- mockAndExpect(
627
- mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
628
- );
629
-
630
- // Mock the hook's own project's terminal for fallback addToBalance.
631
- address projectTerminal = makeAddr("projectTerminal");
632
- vm.etch(projectTerminal, new bytes(0x69));
633
- mockAndExpect(
634
- address(mockJBDirectory),
635
- abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, projectId, JBConstants.NATIVE_TOKEN),
636
- abi.encode(projectTerminal)
637
- );
638
-
639
- vm.expectCall(
640
- projectTerminal,
641
- 1 ether,
642
- abi.encodeWithSelector(
643
- IJBTerminal.addToBalanceOf.selector, projectId, JBConstants.NATIVE_TOKEN, 1 ether, false, "", ""
644
- )
645
- );
646
- vm.mockCall(projectTerminal, abi.encodeWithSelector(IJBTerminal.addToBalanceOf.selector), abi.encode());
647
-
648
- uint16[] memory mintIds = new uint16[](1);
649
- mintIds[0] = uint16(tierIds[0]);
650
- bytes memory payerMetadata = _buildPayerMetadata(address(testHook), mintIds);
651
-
652
- uint16[] memory splitTierIds = new uint16[](1);
653
- splitTierIds[0] = uint16(tierIds[0]);
654
- uint256[] memory splitAmounts = new uint256[](1);
655
- splitAmounts[0] = 1 ether;
656
-
657
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
658
- payer: beneficiary,
659
- projectId: projectId,
660
- rulesetId: 0,
661
- amount: JBTokenAmount({
662
- token: JBConstants.NATIVE_TOKEN,
663
- value: 1 ether,
664
- decimals: 18,
665
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
666
- }),
667
- forwardedAmount: JBTokenAmount({
668
- token: JBConstants.NATIVE_TOKEN,
669
- value: 1 ether,
670
- decimals: 18,
671
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
672
- }),
673
- weight: 10e18,
674
- newlyIssuedTokenCount: 0,
675
- beneficiary: beneficiary,
676
- hookMetadata: abi.encode(beneficiary, beneficiary, abi.encode(splitTierIds, splitAmounts)),
677
- payerMetadata: payerMetadata
678
- });
679
-
680
- // Expect the SplitPayoutReverted event.
681
- vm.expectEmit(true, false, false, false);
682
- emit IJB721TiersHook.SplitPayoutReverted(projectId, splits[0], 1 ether, "", mockTerminalAddress);
683
-
684
- vm.deal(mockTerminalAddress, 2 ether);
685
- vm.prank(mockTerminalAddress);
686
- // Should NOT revert — terminal failure is caught, funds go to project balance.
687
- testHook.afterPayRecordedWith{value: 1 ether}(payContext);
688
- }
689
-
690
- // ──────────────────────────────────────────────────────────────────────
691
- // splitPercent validation in store
692
- // ──────────────────────────────────────────────────────────────────────
693
-
694
- /// @notice Adding a tier with splitPercent > SPLITS_TOTAL_PERCENT should revert.
695
- function test_recordAddTiers_splitPercentExceedsBounds_reverts() public {
696
- ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
697
- IJB721TiersHookStore hookStore = testHook.STORE();
698
-
699
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
700
- tierConfigs[0].price = 1 ether;
701
- tierConfigs[0].initialSupply = uint32(100);
702
- tierConfigs[0].category = uint24(1);
703
- tierConfigs[0].encodedIPFSUri = bytes32(uint256(0x1234));
704
- tierConfigs[0].splitPercent = JBConstants.SPLITS_TOTAL_PERCENT + 1; // Over the limit.
705
-
706
- vm.prank(address(testHook));
707
- vm.expectRevert(
708
- abi.encodeWithSelector(
709
- JB721TiersHookStore.JB721TiersHookStore_SplitPercentExceedsBounds.selector,
710
- JBConstants.SPLITS_TOTAL_PERCENT + 1,
711
- JBConstants.SPLITS_TOTAL_PERCENT
712
- )
713
- );
714
- hookStore.recordAddTiers(tierConfigs);
715
- }
716
- }
717
-
718
- /// @notice A contract that always reverts when receiving ETH.
719
- contract RevertOnReceive {
720
- receive() external payable {
721
- revert("I reject ETH");
722
- }
723
- }
724
-
725
- /// @notice A split hook that always reverts on processSplitWith but can receive ERC-20 tokens.
726
- contract RevertingSplitHook is IJBSplitHook {
727
- function processSplitWith(JBSplitHookContext calldata) external payable override {
728
- revert("I always revert");
729
- }
730
-
731
- function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
732
- return interfaceId == type(IJBSplitHook).interfaceId || interfaceId == type(IERC165).interfaceId;
733
- }
734
- }
735
-
736
- /// @notice A simple mintable ERC20 for testing.
737
- contract MintableERC20 is ERC20 {
738
- uint8 internal _decimals;
739
-
740
- constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) {
741
- _decimals = decimals_;
742
- }
743
-
744
- function decimals() public view override returns (uint8) {
745
- return _decimals;
746
- }
747
-
748
- function mint(address to, uint256 amount) external {
749
- _mint(to, amount);
750
- }
751
- }