@bananapus/721-hook-v6 0.0.22 → 0.0.24

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.
@@ -26,6 +26,7 @@ import {JBIpfsDecoder} from "./JBIpfsDecoder.sol";
26
26
  /// @notice External library for JB721TiersHook operations extracted to stay within the EIP-170 contract size limit.
27
27
  /// @dev Handles tier adjustments, split calculations, price normalization, and split fund distribution.
28
28
  library JB721TiersHookLib {
29
+ error JB721TiersHookLib_NoTerminalForLeftover(uint256 projectId, address token, uint256 leftoverAmount);
29
30
  // Events mirrored from IJB721TiersHook (emitted via DELEGATECALL from the hook's context).
30
31
  event AddToBalanceReverted(uint256 indexed projectId, address token, uint256 amount, bytes reason);
31
32
  event AddTier(uint256 indexed tierId, JB721TierConfig tier, address caller);
@@ -221,73 +222,26 @@ library JB721TiersHookLib {
221
222
  }
222
223
  }
223
224
 
224
- /// @notice Converts split amounts from tier pricing denomination to payment token denomination.
225
- /// @dev Called after `calculateSplitAmounts` when the payment currency differs from the tier pricing currency.
226
- /// @param totalSplitAmount The total split amount in tier pricing denomination.
227
- /// @param splitMetadata The encoded per-tier breakdown (tierIds, amounts) from calculateSplitAmounts.
228
- /// @param packedPricingContext The packed pricing context (currency, decimals).
229
- /// @param prices The prices contract used for currency conversion.
230
- /// @param projectId The project ID.
231
- /// @param amountCurrency The payment amount currency.
232
- /// @param amountDecimals The payment amount decimals.
233
- /// @return convertedTotal The total split amount converted to payment token denomination.
234
- /// @return convertedMetadata The re-encoded per-tier breakdown with converted amounts.
235
- function convertSplitAmounts(
236
- uint256 totalSplitAmount,
237
- bytes memory splitMetadata,
238
- uint256 packedPricingContext,
239
- IJBPrices prices,
240
- uint256 projectId,
241
- uint256 amountCurrency,
242
- uint256 amountDecimals
243
- )
244
- external
245
- view
246
- returns (uint256 convertedTotal, bytes memory convertedMetadata)
247
- {
248
- // forge-lint: disable-next-line(unsafe-typecast)
249
- uint256 pricingCurrency = uint256(uint32(packedPricingContext));
250
- if (amountCurrency == pricingCurrency) return (totalSplitAmount, splitMetadata);
251
-
252
- // No price oracle available to convert between currencies. Return 0 to skip the split rather than
253
- // forwarding an unconverted amount denominated in the wrong currency, which would over- or under-pay.
254
- if (address(prices) == address(0)) return (0, splitMetadata);
255
-
256
- // forge-lint: disable-next-line(unsafe-typecast)
257
- uint256 pricingDecimals = uint256(uint8(packedPricingContext >> 32));
258
- uint256 ratio = prices.pricePerUnitOf({
259
- projectId: projectId,
260
- pricingCurrency: amountCurrency,
261
- unitCurrency: pricingCurrency,
262
- decimals: amountDecimals
263
- });
264
-
265
- (uint16[] memory tierIds, uint256[] memory amounts) = abi.decode(splitMetadata, (uint16[], uint256[]));
266
- for (uint256 i; i < amounts.length; i++) {
267
- amounts[i] = mulDiv({x: amounts[i], y: ratio, denominator: 10 ** pricingDecimals});
268
- convertedTotal += amounts[i];
269
- }
270
- convertedMetadata = abi.encode(tierIds, amounts);
271
- }
272
-
273
225
  /// @notice Calculates the weight for token minting after accounting for tier split amounts.
274
226
  /// @dev Extracted from the hook to keep mulDiv's bytecode out of the hook (EIP-170 compliance).
275
227
  /// @param contextWeight The original weight from the payment context.
276
228
  /// @param amountValue The payment amount value.
277
229
  /// @param totalSplitAmount The total amount routed to tier splits.
278
- /// @param issueTokensForSplits Whether to issue tokens for the full payment regardless of splits.
230
+ /// @param store The 721 tiers hook store (to read flags).
231
+ /// @param hook The hook address.
279
232
  /// @return weight The adjusted weight for token minting.
280
233
  function calculateWeight(
281
234
  uint256 contextWeight,
282
235
  uint256 amountValue,
283
236
  uint256 totalSplitAmount,
284
- bool issueTokensForSplits
237
+ IJB721TiersHookStore store,
238
+ address hook
285
239
  )
286
240
  external
287
- pure
241
+ view
288
242
  returns (uint256 weight)
289
243
  {
290
- if (totalSplitAmount == 0 || issueTokensForSplits) {
244
+ if (totalSplitAmount == 0 || store.flagsOf(hook).issueTokensForSplits) {
291
245
  // No splits, or hook configured to give full token credit regardless — full weight.
292
246
  weight = contextWeight;
293
247
  } else if (amountValue > totalSplitAmount) {
@@ -299,6 +253,96 @@ library JB721TiersHookLib {
299
253
  }
300
254
  }
301
255
 
256
+ /// @notice Converts split amounts from tier pricing to payment denomination (if currencies differ), then caps
257
+ /// the total at the actual payment value — proportionally reducing per-tier amounts when the cap applies.
258
+ /// @dev Combines currency conversion and cap into one external call to keep hook bytecode under EIP-170.
259
+ /// @param totalSplitAmount The total split amount in tier pricing denomination.
260
+ /// @param splitMetadata The encoded per-tier breakdown (tierIds, amounts).
261
+ /// @param packedPricingContext The packed pricing context (currency in bits 0-31, decimals in bits 32-39).
262
+ /// @param prices The prices contract used for currency conversion.
263
+ /// @param projectId The project ID.
264
+ /// @param amountCurrency The payment amount currency.
265
+ /// @param amountDecimals The payment amount decimals.
266
+ /// @param amountValue The actual payment value (used as the cap).
267
+ /// @return convertedTotal The total split amount after conversion and capping.
268
+ /// @return convertedMetadata The re-encoded per-tier breakdown with adjusted amounts.
269
+ function convertAndCapSplitAmounts(
270
+ uint256 totalSplitAmount,
271
+ bytes memory splitMetadata,
272
+ uint256 packedPricingContext,
273
+ IJBPrices prices,
274
+ uint256 projectId,
275
+ uint256 amountCurrency,
276
+ uint256 amountDecimals,
277
+ uint256 amountValue
278
+ )
279
+ external
280
+ view
281
+ returns (uint256 convertedTotal, bytes memory convertedMetadata)
282
+ {
283
+ // Start from the input values; conversion and capping modify them in-place below.
284
+ convertedTotal = totalSplitAmount;
285
+ convertedMetadata = splitMetadata;
286
+
287
+ // Convert each per-tier amount from the tier pricing currency to the payment currency.
288
+ // forge-lint: disable-next-line(unsafe-typecast)
289
+ if (amountCurrency != uint256(uint32(packedPricingContext))) {
290
+ // No price oracle available — return 0 to skip the split rather than forwarding an unconverted
291
+ // amount denominated in the wrong currency, which would over- or under-pay.
292
+ if (address(prices) == address(0)) return (0, convertedMetadata);
293
+
294
+ {
295
+ // Get the price ratio: how many payment-currency units per one tier-pricing-currency unit.
296
+ // forge-lint: disable-next-line(unsafe-typecast)
297
+ uint256 ratio = prices.pricePerUnitOf({
298
+ projectId: projectId,
299
+ pricingCurrency: amountCurrency,
300
+ // forge-lint: disable-next-line(unsafe-typecast)
301
+ unitCurrency: uint256(uint32(packedPricingContext)),
302
+ decimals: amountDecimals
303
+ });
304
+
305
+ // The denominator scales each amount from tier-pricing decimals to payment-token decimals.
306
+ // forge-lint: disable-next-line(unsafe-typecast)
307
+ uint256 denom = 10 ** uint256(uint8(packedPricingContext >> 32));
308
+
309
+ // Decode per-tier breakdown so each amount can be converted individually.
310
+ (uint16[] memory tierIds, uint256[] memory amounts) =
311
+ abi.decode(convertedMetadata, (uint16[], uint256[]));
312
+
313
+ // Re-accumulate the total from converted amounts to avoid rounding drift.
314
+ convertedTotal = 0;
315
+ for (uint256 i; i < amounts.length; i++) {
316
+ // Convert this tier's amount: amount * ratio / 10^pricingDecimals.
317
+ amounts[i] = mulDiv({x: amounts[i], y: ratio, denominator: denom});
318
+ convertedTotal += amounts[i];
319
+ }
320
+
321
+ // Re-encode with the converted amounts.
322
+ convertedMetadata = abi.encode(tierIds, amounts);
323
+ }
324
+ }
325
+
326
+ // Cap the total at the actual payment value. Pay credits fund NFT minting (virtual), but splits
327
+ // require real tokens to distribute. Without this cap, a user with sufficient pay credits but
328
+ // insufficient ETH would revert because the terminal can't forward more than what was actually paid.
329
+ if (convertedTotal > amountValue) {
330
+ // Proportionally reduce each per-tier amount to stay in sync with the capped total.
331
+ if (convertedMetadata.length != 0) {
332
+ (uint16[] memory tierIds, uint256[] memory amounts) =
333
+ abi.decode(convertedMetadata, (uint16[], uint256[]));
334
+ for (uint256 i; i < amounts.length; i++) {
335
+ // Scale down: amount * amountValue / originalTotal.
336
+ amounts[i] = mulDiv({x: amounts[i], y: amountValue, denominator: convertedTotal});
337
+ }
338
+ convertedMetadata = abi.encode(tierIds, amounts);
339
+ }
340
+
341
+ // Clamp the total to the payment value.
342
+ convertedTotal = amountValue;
343
+ }
344
+ }
345
+
302
346
  /// @notice Sets split groups in JBSplits for tiers that have splits configured.
303
347
  function _setSplitGroupsFor(
304
348
  IJBSplits splits,
@@ -395,17 +439,18 @@ library JB721TiersHookLib {
395
439
  bool isNativeToken = token == JBConstants.NATIVE_TOKEN;
396
440
  uint256 leftoverPercentage = JBConstants.SPLITS_TOTAL_PERCENT;
397
441
  uint256 leftoverAmount = amount;
442
+ amount = 0;
398
443
 
399
444
  for (uint256 j; j < tierSplits.length; j++) {
400
445
  uint256 payoutAmount =
401
446
  mulDiv({x: leftoverAmount, y: tierSplits[j].percent, denominator: leftoverPercentage});
402
447
  if (payoutAmount != 0) {
403
- // Always subtract from leftover to prevent failed splits from inflating later recipients'
404
- // shares. Failed amounts stay in the contract and are routed to the project's balance with
405
- // the leftover below.
406
448
  unchecked {
407
449
  leftoverAmount -= payoutAmount;
408
450
  }
451
+ // On failure, don't re-add to leftoverAmount — this prevents inflating later recipients.
452
+ // Failed amounts accumulate as the gap between `amount` and `leftoverAmount + total sent`.
453
+ // After the loop, we re-add leftoverPercentage-based residual naturally.
409
454
  if (!_sendPayoutToSplit({
410
455
  directory: directory,
411
456
  split: tierSplits[j],
@@ -415,10 +460,10 @@ library JB721TiersHookLib {
415
460
  groupId: groupId,
416
461
  decimals: decimals
417
462
  })) {
418
- // The payout failed — the funds are still in this contract. Add back to leftover so they
419
- // route to the project's balance after the loop.
463
+ // Payout failed — route to project balance by returning to leftover after the loop.
464
+ // We add back to `amount` (parameter, no longer used for its original purpose).
420
465
  unchecked {
421
- leftoverAmount += payoutAmount;
466
+ amount += payoutAmount;
422
467
  }
423
468
  }
424
469
  }
@@ -427,39 +472,44 @@ library JB721TiersHookLib {
427
472
  }
428
473
  }
429
474
 
475
+ // Route failed payout amounts to the project's balance.
476
+ leftoverAmount += amount;
477
+
430
478
  if (leftoverAmount != 0) {
431
479
  // slither-disable-next-line calls-loop
432
480
  IJBTerminal terminal = directory.primaryTerminalOf({projectId: projectId, token: token});
433
- if (address(terminal) != address(0)) {
434
- if (isNativeToken) {
435
- // slither-disable-next-line arbitrary-send-eth,calls-loop
436
- try terminal.addToBalanceOf{value: leftoverAmount}({
437
- projectId: projectId,
438
- token: token,
439
- amount: leftoverAmount,
440
- shouldReturnHeldFees: false,
441
- memo: "",
442
- metadata: bytes("")
443
- }) {}
444
- catch (bytes memory reason) {
445
- emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
446
- }
447
- } else {
448
- SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: leftoverAmount});
449
- // slither-disable-next-line calls-loop
450
- try terminal.addToBalanceOf({
451
- projectId: projectId,
452
- token: token,
453
- amount: leftoverAmount,
454
- shouldReturnHeldFees: false,
455
- memo: "",
456
- metadata: bytes("")
457
- }) {}
458
- catch (bytes memory reason) {
459
- // Reset approval on failure.
460
- SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
461
- emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
462
- }
481
+ // Revert if there are leftover funds but no terminal to route them to.
482
+ if (address(terminal) == address(0)) {
483
+ revert JB721TiersHookLib_NoTerminalForLeftover(projectId, token, leftoverAmount);
484
+ }
485
+ if (isNativeToken) {
486
+ // slither-disable-next-line arbitrary-send-eth,calls-loop
487
+ try terminal.addToBalanceOf{value: leftoverAmount}({
488
+ projectId: projectId,
489
+ token: token,
490
+ amount: leftoverAmount,
491
+ shouldReturnHeldFees: false,
492
+ memo: "",
493
+ metadata: bytes("")
494
+ }) {}
495
+ catch (bytes memory reason) {
496
+ emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
497
+ }
498
+ } else {
499
+ SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: leftoverAmount});
500
+ // slither-disable-next-line calls-loop
501
+ try terminal.addToBalanceOf({
502
+ projectId: projectId,
503
+ token: token,
504
+ amount: leftoverAmount,
505
+ shouldReturnHeldFees: false,
506
+ memo: "",
507
+ metadata: bytes("")
508
+ }) {}
509
+ catch (bytes memory reason) {
510
+ // Reset approval on failure.
511
+ SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
512
+ emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
463
513
  }
464
514
  }
465
515
  }
@@ -0,0 +1,196 @@
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 {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
9
+ import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
10
+ import {JBAfterPayRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterPayRecordedContext.sol";
11
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
12
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
13
+ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
14
+
15
+ contract CodexPayCreditsBypassTierSplits is UnitTestSetup {
16
+ address internal splitBeneficiary = makeAddr("splitBeneficiary");
17
+
18
+ function setUp() public override {
19
+ super.setUp();
20
+ vm.etch(mockJBSplits, new bytes(0x69));
21
+ }
22
+
23
+ function _buildPayMetadata(
24
+ address hookAddress,
25
+ bool allowOverspending,
26
+ uint16[] memory tierIdsToMint
27
+ )
28
+ internal
29
+ view
30
+ returns (bytes memory)
31
+ {
32
+ bytes[] memory data = new bytes[](1);
33
+ data[0] = abi.encode(allowOverspending, tierIdsToMint);
34
+ bytes4[] memory ids = new bytes4[](1);
35
+ ids[0] = metadataHelper.getId("pay", hookAddress);
36
+ return metadataHelper.createMetadata(ids, data);
37
+ }
38
+
39
+ function _beforePayContext(
40
+ address hookAddress,
41
+ uint256 amountValue,
42
+ uint16[] memory tierIdsToMint
43
+ )
44
+ internal
45
+ view
46
+ returns (JBBeforePayRecordedContext memory)
47
+ {
48
+ return JBBeforePayRecordedContext({
49
+ terminal: mockTerminalAddress,
50
+ payer: beneficiary,
51
+ amount: JBTokenAmount({
52
+ token: JBConstants.NATIVE_TOKEN,
53
+ value: amountValue,
54
+ decimals: 18,
55
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
56
+ }),
57
+ projectId: projectId,
58
+ rulesetId: 0,
59
+ beneficiary: beneficiary,
60
+ weight: 10e18,
61
+ reservedPercent: 5000,
62
+ metadata: _buildPayMetadata(hookAddress, false, tierIdsToMint)
63
+ });
64
+ }
65
+
66
+ function _afterPayContext(
67
+ address hookAddress,
68
+ uint256 amountValue,
69
+ uint256 forwardedAmountValue,
70
+ bytes memory hookMetadata,
71
+ uint16[] memory tierIdsToMint
72
+ )
73
+ internal
74
+ view
75
+ returns (JBAfterPayRecordedContext memory)
76
+ {
77
+ return JBAfterPayRecordedContext({
78
+ payer: beneficiary,
79
+ projectId: projectId,
80
+ rulesetId: 0,
81
+ amount: JBTokenAmount({
82
+ token: JBConstants.NATIVE_TOKEN,
83
+ value: amountValue,
84
+ decimals: 18,
85
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
86
+ }),
87
+ forwardedAmount: JBTokenAmount({
88
+ token: JBConstants.NATIVE_TOKEN,
89
+ value: forwardedAmountValue,
90
+ decimals: 18,
91
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
92
+ }),
93
+ weight: 10e18,
94
+ newlyIssuedTokenCount: 0,
95
+ beneficiary: beneficiary,
96
+ hookMetadata: hookMetadata,
97
+ payerMetadata: _buildPayMetadata(hookAddress, true, tierIdsToMint)
98
+ });
99
+ }
100
+
101
+ function test_payCredits_can_bypass_most_of_tier_split_payment() public {
102
+ ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
103
+ IJB721TiersHookStore hookStore = testHook.STORE();
104
+
105
+ vm.mockCall(
106
+ mockJBDirectory,
107
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
108
+ abi.encode(true)
109
+ );
110
+
111
+ JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
112
+ tierConfigs[0] = JB721TierConfig({
113
+ price: 1 ether,
114
+ initialSupply: 100,
115
+ votingUnits: 0,
116
+ reserveFrequency: 0,
117
+ reserveBeneficiary: reserveBeneficiary,
118
+ encodedIPFSUri: bytes32(uint256(0x1234)),
119
+ category: 1,
120
+ discountPercent: 0,
121
+ allowOwnerMint: false,
122
+ useReserveBeneficiaryAsDefault: false,
123
+ transfersPausable: false,
124
+ cannotBeRemoved: false,
125
+ cannotIncreaseDiscountPercent: false,
126
+ useVotingUnits: false,
127
+ splitPercent: 1_000_000_000,
128
+ splits: new JBSplit[](0)
129
+ });
130
+
131
+ vm.prank(address(testHook));
132
+ uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
133
+
134
+ uint16[] memory noTiers = new uint16[](0);
135
+ JBAfterPayRecordedContext memory creditSeedContext = JBAfterPayRecordedContext({
136
+ payer: beneficiary,
137
+ projectId: projectId,
138
+ rulesetId: 0,
139
+ amount: JBTokenAmount({
140
+ token: JBConstants.NATIVE_TOKEN,
141
+ value: 1 ether,
142
+ decimals: 18,
143
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
144
+ }),
145
+ forwardedAmount: JBTokenAmount({
146
+ token: JBConstants.NATIVE_TOKEN,
147
+ value: 0,
148
+ decimals: 18,
149
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
150
+ }),
151
+ weight: 10e18,
152
+ newlyIssuedTokenCount: 0,
153
+ beneficiary: beneficiary,
154
+ hookMetadata: "",
155
+ payerMetadata: _buildPayMetadata(address(testHook), true, noTiers)
156
+ });
157
+
158
+ vm.prank(mockTerminalAddress);
159
+ testHook.afterPayRecordedWith(creditSeedContext);
160
+ assertEq(testHook.payCreditsOf(beneficiary), 1 ether, "setup: credits should be seeded");
161
+
162
+ uint16[] memory mintIds = new uint16[](1);
163
+ mintIds[0] = uint16(tierIds[0]);
164
+
165
+ (, JBPayHookSpecification[] memory specs) =
166
+ testHook.beforePayRecordedWith(_beforePayContext(address(testHook), 1, mintIds));
167
+ assertEq(specs[0].amount, 1, "forwarded amount is capped to the fresh payment");
168
+
169
+ JBSplit[] memory splits = new JBSplit[](1);
170
+ splits[0] = JBSplit({
171
+ percent: 1_000_000_000,
172
+ projectId: 0,
173
+ beneficiary: payable(splitBeneficiary),
174
+ preferAddToBalance: false,
175
+ lockedUntil: 0,
176
+ hook: IJBSplitHook(address(0))
177
+ });
178
+
179
+ uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
180
+ vm.mockCall(
181
+ mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
182
+ );
183
+
184
+ uint256 splitBalanceBefore = splitBeneficiary.balance;
185
+
186
+ JBAfterPayRecordedContext memory creditMintContext =
187
+ _afterPayContext(address(testHook), 1, 1, specs[0].metadata, mintIds);
188
+ vm.deal(mockTerminalAddress, 1);
189
+ vm.prank(mockTerminalAddress);
190
+ testHook.afterPayRecordedWith{value: 1}(creditMintContext);
191
+
192
+ assertEq(testHook.balanceOf(beneficiary), 1, "beneficiary should receive the NFT");
193
+ assertEq(testHook.payCreditsOf(beneficiary), 1, "credits should fund almost the entire mint");
194
+ assertEq(splitBeneficiary.balance - splitBalanceBefore, 1, "split recipient only gets the fresh payment");
195
+ }
196
+ }