@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,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
- }