@bananapus/721-hook-v6 0.0.32 → 0.0.34

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 (35) hide show
  1. package/USER_JOURNEYS.md +11 -0
  2. package/package.json +3 -3
  3. package/script/Deploy.s.sol +53 -19
  4. package/src/JB721Checkpoints.sol +92 -0
  5. package/src/JB721CheckpointsDeployer.sol +45 -0
  6. package/src/JB721TiersHook.sol +90 -116
  7. package/src/abstract/JB721Hook.sol +5 -0
  8. package/src/interfaces/IJB721Checkpoints.sol +34 -0
  9. package/src/interfaces/IJB721CheckpointsDeployer.sol +20 -0
  10. package/src/interfaces/IJB721TiersHook.sol +8 -0
  11. package/src/libraries/JB721Constants.sol +6 -0
  12. package/src/libraries/JB721TiersHookLib.sol +353 -146
  13. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +11 -1
  14. package/test/Fork.t.sol +11 -2
  15. package/test/TestAuditGaps.sol +1 -1
  16. package/test/TestCheckpoints.t.sol +329 -0
  17. package/test/audit/CodexNemesisRepoFindings.t.sol +270 -0
  18. package/test/audit/CodexRetroactiveReserveBeneficiaryDilution.t.sol +161 -0
  19. package/test/audit/CodexSplitCreditsMismatch.t.sol +2 -1
  20. package/test/audit/CrossCurrencySplitNoPrices.t.sol +1 -0
  21. package/test/audit/SameCurrencyDecimalMismatch.t.sol +249 -0
  22. package/test/audit/SplitFailureRedistribution.t.sol +2 -1
  23. package/test/fork/ERC20CashOutFork.t.sol +11 -2
  24. package/test/fork/ERC20TierSplitFork.t.sol +11 -2
  25. package/test/fork/IssueTokensForSplitsFork.t.sol +11 -2
  26. package/test/regression/BrokenTerminalDoesNotDos.t.sol +2 -2
  27. package/test/regression/SplitDistributionBugs.t.sol +5 -5
  28. package/test/regression/SplitNoBeneficiary.t.sol +1 -1
  29. package/test/unit/AuditFixes_Unit.t.sol +5 -5
  30. package/test/unit/pay_CrossCurrency_Unit.t.sol +1 -0
  31. package/test/unit/pay_Unit.t.sol +1 -0
  32. package/test/unit/redeem_Unit.t.sol +3 -3
  33. package/test/unit/relayBeneficiary_Unit.t.sol +182 -0
  34. package/test/unit/splitHookDistribution_Unit.t.sol +6 -6
  35. package/test/unit/tierSplitRouting_Unit.t.sol +2 -2
@@ -6,32 +6,50 @@ import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
6
6
  import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
7
7
  import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
8
8
  import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
9
- import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
10
- import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
11
9
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
12
10
  import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
11
+ import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
12
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
13
+ import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
14
+ import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
13
15
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
14
16
  import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
15
17
  import {mulDiv} from "@prb/math/src/Common.sol";
16
18
 
17
- import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
18
-
19
19
  import {IJB721TiersHookStore} from "../interfaces/IJB721TiersHookStore.sol";
20
20
  import {IJB721TokenUriResolver} from "../interfaces/IJB721TokenUriResolver.sol";
21
- import {JB721TierConfig} from "../structs/JB721TierConfig.sol";
21
+
22
22
  import {JB721Constants} from "./JB721Constants.sol";
23
23
  import {JBIpfsDecoder} from "./JBIpfsDecoder.sol";
24
24
 
25
+ import {JB721TierConfig} from "../structs/JB721TierConfig.sol";
26
+
25
27
  /// @notice External library for JB721TiersHook operations extracted to stay within the EIP-170 contract size limit.
26
28
  /// @dev Handles tier adjustments, split calculations, price normalization, and split fund distribution.
27
29
  library JB721TiersHookLib {
30
+ //*********************************************************************//
31
+ // --------------------------- custom errors ------------------------- //
32
+ //*********************************************************************//
33
+
34
+ error JB721TiersHook_CantBuyWithCredits();
35
+ error JB721TiersHook_Overspending(uint256 leftoverAmount);
28
36
  error JB721TiersHookLib_NoTerminalForLeftover(uint256 projectId, address token, uint256 leftoverAmount);
29
37
  error JB721TiersHookLib_SplitFallbackFailed(uint256 projectId, address token, uint256 amount, bytes reason);
30
38
  error JB721TiersHookLib_TokenTransferAmountMismatch(uint256 expectedAmount, uint256 receivedAmount);
39
+
40
+ //*********************************************************************//
41
+ // ------------------------------- events ---------------------------- //
42
+ //*********************************************************************//
43
+
31
44
  event AddTier(uint256 indexed tierId, JB721TierConfig tier, address caller);
32
45
  event RemoveTier(uint256 indexed tierId, address caller);
46
+ event SetDiscountPercent(uint256 indexed tierId, uint256 discountPercent, address caller);
33
47
  event SplitPayoutReverted(uint256 indexed projectId, JBSplit split, uint256 amount, bytes reason, address caller);
34
48
 
49
+ //*********************************************************************//
50
+ // ---------------------- external transactions ---------------------- //
51
+ //*********************************************************************//
52
+
35
53
  /// @notice Handles the full tier adjustment logic: removes tiers, adds tiers, emits events, and sets splits.
36
54
  /// @dev Called via DELEGATECALL from the hook, so events are emitted from the hook's address.
37
55
  /// @param store The 721 tiers hook store.
@@ -89,6 +107,137 @@ library JB721TiersHookLib {
89
107
  }
90
108
  }
91
109
 
110
+ /// @notice Pulls ERC-20 tokens from the terminal (if needed) and distributes forwarded funds to tier splits.
111
+ /// @dev For ERC-20 tokens, pulls from the terminal using the allowance it granted via _beforeTransferTo.
112
+ /// @param directory The directory to look up terminals.
113
+ /// @param splits The splits contract to read tier split groups from.
114
+ /// @param projectId The project ID of the hook.
115
+ /// @param hookAddress The hook address (for computing split group IDs).
116
+ /// @param token The token being distributed.
117
+ /// @param amount The total amount to distribute.
118
+ /// @param decimals The token decimals.
119
+ /// @param encodedSplitData The encoded per-tier breakdown from hookMetadata.
120
+ function distributeAll(
121
+ IJBDirectory directory,
122
+ IJBSplits splits,
123
+ uint256 projectId,
124
+ address hookAddress,
125
+ address token,
126
+ uint256 amount,
127
+ uint256 decimals,
128
+ bytes calldata encodedSplitData
129
+ )
130
+ external
131
+ {
132
+ // For ERC20 tokens, pull from terminal using the allowance it granted via _beforeTransferTo.
133
+ if (token != JBConstants.NATIVE_TOKEN) {
134
+ uint256 balanceBefore = IERC20(token).balanceOf(address(this));
135
+ SafeERC20.safeTransferFrom({token: IERC20(token), from: msg.sender, to: address(this), value: amount});
136
+ uint256 receivedAmount = IERC20(token).balanceOf(address(this)) - balanceBefore;
137
+ if (receivedAmount != amount) {
138
+ revert JB721TiersHookLib_TokenTransferAmountMismatch(amount, receivedAmount);
139
+ }
140
+ }
141
+
142
+ (uint16[] memory tierIds, uint256[] memory amounts) = abi.decode(encodedSplitData, (uint16[], uint256[]));
143
+
144
+ for (uint256 i; i < tierIds.length;) {
145
+ if (amounts[i] == 0) {
146
+ unchecked {
147
+ ++i;
148
+ }
149
+ continue;
150
+ }
151
+ uint256 groupId = uint256(uint160(hookAddress)) | (uint256(tierIds[i]) << 160);
152
+ _distributeSingleSplit({
153
+ directory: directory,
154
+ splitsContract: splits,
155
+ projectId: projectId,
156
+ token: token,
157
+ groupId: groupId,
158
+ amount: amounts[i],
159
+ decimals: decimals
160
+ });
161
+
162
+ unchecked {
163
+ ++i;
164
+ }
165
+ }
166
+ }
167
+
168
+ /// @notice Prepares NFT minting data for a payment: combines credits, decodes metadata, records mint, and checks
169
+ /// overspending.
170
+ /// @dev Called via DELEGATECALL from the hook; uses address(this) as the hook address.
171
+ /// Reverts with JB721TiersHook_CantBuyWithCredits or JB721TiersHook_Overspending on failure.
172
+ /// @param store The 721 tiers hook store.
173
+ /// @param metadataIdTarget The metadata ID target for resolving pay metadata.
174
+ /// @param value The normalized payment value.
175
+ /// @param payer The address that initiated the payment.
176
+ /// @param beneficiary The address to mint NFTs to and track credits for.
177
+ /// @param payCredits The beneficiary's current pay credits balance.
178
+ /// @param payerMetadata The metadata provided by the payer.
179
+ /// @return tokenIds The token IDs minted (empty if none).
180
+ /// @return tierIdsToMint The tier IDs corresponding to each minted token (empty if none).
181
+ /// @return newPayCredits The beneficiary's updated pay credits balance.
182
+ function prepareMint(
183
+ IJB721TiersHookStore store,
184
+ address metadataIdTarget,
185
+ uint256 value,
186
+ address payer,
187
+ address beneficiary,
188
+ uint256 payCredits,
189
+ bytes calldata payerMetadata
190
+ )
191
+ external
192
+ returns (uint256[] memory tokenIds, uint16[] memory tierIdsToMint, uint256 newPayCredits)
193
+ {
194
+ // Resolve metadata first (minimal stack: only 3 return vars). Scope block frees temporaries.
195
+ bool payerDisallowsOverspending;
196
+ {
197
+ (bool found, bytes memory metadata) = JBMetadataResolver.getDataFor({
198
+ id: JBMetadataResolver.getId({purpose: "pay", target: metadataIdTarget}), metadata: payerMetadata
199
+ });
200
+
201
+ if (found) {
202
+ bool payerAllowsOverspending;
203
+ (payerAllowsOverspending, tierIdsToMint) = abi.decode(metadata, (bool, uint16[]));
204
+ payerDisallowsOverspending = !payerAllowsOverspending;
205
+ }
206
+ }
207
+
208
+ // Set the leftover amount as the initial value.
209
+ uint256 leftoverAmount = value;
210
+
211
+ // If the payer is the effective beneficiary, combine their NFT credits with the amount paid.
212
+ // Reuse newPayCredits to hold unused credits (avoids an extra local variable).
213
+ if (payer == beneficiary) {
214
+ leftoverAmount += payCredits;
215
+ } else {
216
+ newPayCredits = payCredits;
217
+ }
218
+
219
+ // Determine whether overspending is allowed (collection flag AND payer preference).
220
+ bool allowOverspending = !store.flagsOf(address(this)).preventOverspending && !payerDisallowsOverspending;
221
+
222
+ // Record the mints.
223
+ if (tierIdsToMint.length != 0) {
224
+ uint256 restrictedCost;
225
+
226
+ // slither-disable-next-line reentrancy-events,reentrancy-no-eth
227
+ (tokenIds, leftoverAmount, restrictedCost) =
228
+ store.recordMint({amount: leftoverAmount, tierIds: tierIdsToMint, isOwnerMint: false});
229
+
230
+ // Credit-restricted tiers must be fully covered by fresh payment (not stored credits).
231
+ if (restrictedCost > value) revert JB721TiersHook_CantBuyWithCredits();
232
+ }
233
+
234
+ // If overspending isn't allowed, revert.
235
+ if (leftoverAmount != 0 && !allowOverspending) revert JB721TiersHook_Overspending(leftoverAmount);
236
+
237
+ // Compute the new pay credits balance: leftover + unused credits (held in newPayCredits).
238
+ newPayCredits = leftoverAmount + newPayCredits;
239
+ }
240
+
92
241
  /// @notice Records new tiers, emits events, and sets their split groups.
93
242
  /// @dev Used during initialization when tier configs are in memory.
94
243
  /// @param store The 721 tiers hook store.
@@ -128,6 +277,87 @@ library JB721TiersHookLib {
128
277
  });
129
278
  }
130
279
 
280
+ /// @notice Set the discount percent for a tier, emitting an event and recording it in the store.
281
+ /// @param store The 721 tiers hook store.
282
+ /// @param tierId The ID of the tier.
283
+ /// @param discountPercent The discount percent to set.
284
+ /// @param caller The msg.sender of the original call.
285
+ function setDiscountPercentOf(
286
+ IJB721TiersHookStore store,
287
+ uint256 tierId,
288
+ uint256 discountPercent,
289
+ address caller
290
+ )
291
+ external
292
+ {
293
+ emit SetDiscountPercent({tierId: tierId, discountPercent: discountPercent, caller: caller});
294
+ store.recordSetDiscountPercentOf({tierId: tierId, discountPercent: discountPercent});
295
+ }
296
+
297
+ //*********************************************************************//
298
+ // ----------------------- external views ---------------------------- //
299
+ //*********************************************************************//
300
+
301
+ /// @notice Computes split amounts, weight adjustment, and resolved beneficiary for a payment.
302
+ /// @dev Called via DELEGATECALL from the hook; uses address(this) as the hook address.
303
+ /// @param store The 721 tiers hook store.
304
+ /// @param metadataIdTarget The metadata ID target for resolving pay metadata.
305
+ /// @param packedPricingContext The packed pricing context (currency, decimals).
306
+ /// @param prices The prices contract used for currency conversion.
307
+ /// @param context The full payment context.
308
+ /// @return weight The adjusted weight for token minting.
309
+ /// @return totalSplitAmount The total amount to forward for splits.
310
+ /// @return splitMetadata Encoded per-tier breakdown (tierIds, amounts).
311
+ /// @return beneficiary The resolved beneficiary address.
312
+ function computeSplitsAndWeight(
313
+ IJB721TiersHookStore store,
314
+ address metadataIdTarget,
315
+ uint256 packedPricingContext,
316
+ IJBPrices prices,
317
+ JBBeforePayRecordedContext calldata context
318
+ )
319
+ external
320
+ view
321
+ returns (uint256 weight, uint256 totalSplitAmount, bytes memory splitMetadata, address beneficiary)
322
+ {
323
+ // Calculate per-tier split amounts.
324
+ (totalSplitAmount, splitMetadata) = _calculateSplitAmounts({
325
+ store: store, hook: address(this), metadataIdTarget: metadataIdTarget, metadata: context.metadata
326
+ });
327
+
328
+ // Convert split amounts from tier pricing to payment token denomination (if currencies differ)
329
+ // and cap at the actual payment value so the terminal never forwards more than was paid.
330
+ if (totalSplitAmount != 0) {
331
+ (totalSplitAmount, splitMetadata) = _convertAndCapSplitAmounts({
332
+ totalSplitAmount: totalSplitAmount,
333
+ splitMetadata: splitMetadata,
334
+ packedPricingContext: packedPricingContext,
335
+ prices: prices,
336
+ context: context
337
+ });
338
+ }
339
+
340
+ // Adjust weight so the terminal mints tokens only for the amount that actually enters the project.
341
+ weight = _calculateWeight({
342
+ contextWeight: context.weight,
343
+ amountValue: context.amount.value,
344
+ totalSplitAmount: totalSplitAmount,
345
+ store: store,
346
+ hook: address(this)
347
+ });
348
+
349
+ // Resolve the effective beneficiary from payment metadata.
350
+ beneficiary = context.beneficiary;
351
+ {
352
+ (bool found, bytes memory data) =
353
+ JBMetadataResolver.getDataFor({id: JB721Constants.BENEFICIARY_METADATA_ID, metadata: context.metadata});
354
+ if (found && data.length >= 32) {
355
+ address metadataBeneficiary = abi.decode(data, (address));
356
+ if (metadataBeneficiary != address(0)) beneficiary = metadataBeneficiary;
357
+ }
358
+ }
359
+ }
360
+
131
361
  /// @notice Normalizes a payment value based on the packed pricing context.
132
362
  /// @param packedPricingContext The packed pricing context (currency, decimals).
133
363
  /// @param prices The prices contract used for currency conversion.
@@ -176,6 +406,40 @@ library JB721TiersHookLib {
176
406
  valid = true;
177
407
  }
178
408
 
409
+ /// @notice Resolves the token URI for a given NFT token ID.
410
+ /// @dev Extracted to the library to keep JBIpfsDecoder bytecode out of the hook contract (EIP-170 compliance).
411
+ /// @param store The 721 tiers hook store.
412
+ /// @param hook The hook address.
413
+ /// @param baseUri The base URI for IPFS-based token URIs.
414
+ /// @param tokenId The token ID to resolve the URI for.
415
+ /// @return The resolved token URI string.
416
+ function resolveTokenURI(
417
+ IJB721TiersHookStore store,
418
+ address hook,
419
+ string memory baseUri,
420
+ uint256 tokenId
421
+ )
422
+ external
423
+ view
424
+ returns (string memory)
425
+ {
426
+ // Get a reference to the `tokenUriResolver`.
427
+ IJB721TokenUriResolver resolver = store.tokenUriResolverOf(hook);
428
+
429
+ // If a `tokenUriResolver` is set, use it to resolve the token URI.
430
+ if (address(resolver) != address(0)) return resolver.tokenUriOf({nft: hook, tokenId: tokenId});
431
+
432
+ // Otherwise, return the token URI corresponding with the NFT's tier.
433
+ return
434
+ JBIpfsDecoder.decode({
435
+ baseUri: baseUri, hexString: store.encodedTierIPFSUriOf({hook: hook, tokenId: tokenId})
436
+ });
437
+ }
438
+
439
+ //*********************************************************************//
440
+ // ----------------------- internal views ---------------------------- //
441
+ //*********************************************************************//
442
+
179
443
  /// @notice Calculates per-tier split amounts for a pay event.
180
444
  /// @param store The 721 tiers hook store.
181
445
  /// @param hook The hook address.
@@ -183,13 +447,13 @@ library JB721TiersHookLib {
183
447
  /// @param metadata The payer metadata.
184
448
  /// @return totalSplitAmount The total amount to forward for splits.
185
449
  /// @return hookMetadata Encoded per-tier breakdown (tierIds, amounts) for afterPay.
186
- function calculateSplitAmounts(
450
+ function _calculateSplitAmounts(
187
451
  IJB721TiersHookStore store,
188
452
  address hook,
189
453
  address metadataIdTarget,
190
454
  bytes calldata metadata
191
455
  )
192
- external
456
+ internal
193
457
  view
194
458
  returns (uint256 totalSplitAmount, bytes memory hookMetadata)
195
459
  {
@@ -256,14 +520,14 @@ library JB721TiersHookLib {
256
520
  /// @param store The 721 tiers hook store (to read flags).
257
521
  /// @param hook The hook address.
258
522
  /// @return weight The adjusted weight for token minting.
259
- function calculateWeight(
523
+ function _calculateWeight(
260
524
  uint256 contextWeight,
261
525
  uint256 amountValue,
262
526
  uint256 totalSplitAmount,
263
527
  IJB721TiersHookStore store,
264
528
  address hook
265
529
  )
266
- external
530
+ internal
267
531
  view
268
532
  returns (uint256 weight)
269
533
  {
@@ -281,28 +545,22 @@ library JB721TiersHookLib {
281
545
 
282
546
  /// @notice Converts split amounts from tier pricing to payment denomination (if currencies differ), then caps
283
547
  /// the total at the actual payment value — proportionally reducing per-tier amounts when the cap applies.
284
- /// @dev Combines currency conversion and cap into one external call to keep hook bytecode under EIP-170.
548
+ /// @dev Combines currency conversion and cap into one call to keep hook bytecode under EIP-170.
285
549
  /// @param totalSplitAmount The total split amount in tier pricing denomination.
286
550
  /// @param splitMetadata The encoded per-tier breakdown (tierIds, amounts).
287
551
  /// @param packedPricingContext The packed pricing context (currency in bits 0-31, decimals in bits 32-39).
288
552
  /// @param prices The prices contract used for currency conversion.
289
- /// @param projectId The project ID.
290
- /// @param amountCurrency The payment amount currency.
291
- /// @param amountDecimals The payment amount decimals.
292
- /// @param amountValue The actual payment value (used as the cap).
553
+ /// @param context The full payment context (provides projectId, amount currency/decimals/value).
293
554
  /// @return convertedTotal The total split amount after conversion and capping.
294
555
  /// @return convertedMetadata The re-encoded per-tier breakdown with adjusted amounts.
295
- function convertAndCapSplitAmounts(
556
+ function _convertAndCapSplitAmounts(
296
557
  uint256 totalSplitAmount,
297
558
  bytes memory splitMetadata,
298
559
  uint256 packedPricingContext,
299
560
  IJBPrices prices,
300
- uint256 projectId,
301
- uint256 amountCurrency,
302
- uint256 amountDecimals,
303
- uint256 amountValue
561
+ JBBeforePayRecordedContext calldata context
304
562
  )
305
- external
563
+ internal
306
564
  view
307
565
  returns (uint256 convertedTotal, bytes memory convertedMetadata)
308
566
  {
@@ -310,9 +568,13 @@ library JB721TiersHookLib {
310
568
  convertedTotal = totalSplitAmount;
311
569
  convertedMetadata = splitMetadata;
312
570
 
571
+ // Extract pricing decimals for reuse below.
572
+ // forge-lint: disable-next-line(unsafe-typecast)
573
+ uint256 pricingDecimals = uint256(uint8(packedPricingContext >> 32));
574
+
313
575
  // Convert each per-tier amount from the tier pricing currency to the payment currency.
314
576
  // forge-lint: disable-next-line(unsafe-typecast)
315
- if (amountCurrency != uint256(uint32(packedPricingContext))) {
577
+ if (context.amount.currency != uint256(uint32(packedPricingContext))) {
316
578
  // No price oracle available — return 0 to skip the split rather than forwarding an unconverted
317
579
  // amount denominated in the wrong currency, which would over- or under-pay.
318
580
  if (address(prices) == address(0)) return (0, convertedMetadata);
@@ -321,16 +583,15 @@ library JB721TiersHookLib {
321
583
  // Get the price ratio: how many payment-currency units per one tier-pricing-currency unit.
322
584
  // forge-lint: disable-next-line(unsafe-typecast)
323
585
  uint256 ratio = prices.pricePerUnitOf({
324
- projectId: projectId,
325
- pricingCurrency: amountCurrency,
586
+ projectId: context.projectId,
587
+ pricingCurrency: context.amount.currency,
326
588
  // forge-lint: disable-next-line(unsafe-typecast)
327
589
  unitCurrency: uint256(uint32(packedPricingContext)),
328
- decimals: amountDecimals
590
+ decimals: context.amount.decimals
329
591
  });
330
592
 
331
593
  // The denominator scales each amount from tier-pricing decimals to payment-token decimals.
332
- // forge-lint: disable-next-line(unsafe-typecast)
333
- uint256 denom = 10 ** uint256(uint8(packedPricingContext >> 32));
594
+ uint256 denom = 10 ** pricingDecimals;
334
595
 
335
596
  // Decode per-tier breakdown so each amount can be converted individually.
336
597
  (uint16[] memory tierIds, uint256[] memory amounts) =
@@ -351,12 +612,41 @@ library JB721TiersHookLib {
351
612
  // Re-encode with the converted amounts.
352
613
  convertedMetadata = abi.encode(tierIds, amounts);
353
614
  }
615
+ } else if (context.amount.decimals != pricingDecimals) {
616
+ // Same currency but different decimal scales (e.g. pricing at 18 decimals, payment token at 6).
617
+ // Without this branch, split amounts stay in pricing decimals while the cap comparison uses
618
+ // payment decimals — causing orders-of-magnitude mis-scaling. This mirrors the same-currency
619
+ // decimal adjustment in `normalizePaymentValue` (which handles the mint path).
620
+
621
+ // Decode the per-tier breakdown so each amount can be rescaled individually.
622
+ (uint16[] memory tierIds, uint256[] memory amounts) = abi.decode(convertedMetadata, (uint16[], uint256[]));
623
+
624
+ // Re-accumulate the total from rescaled amounts to avoid rounding drift.
625
+ convertedTotal = 0;
626
+ for (uint256 i; i < amounts.length;) {
627
+ // Scale each amount from pricing decimals to payment decimals.
628
+ if (context.amount.decimals > pricingDecimals) {
629
+ // Payment has more decimals — multiply to add precision (e.g. 6→18: multiply by 10^12).
630
+ amounts[i] = amounts[i] * (10 ** (context.amount.decimals - pricingDecimals));
631
+ } else {
632
+ // Payment has fewer decimals — divide to remove precision (e.g. 18→6: divide by 10^12).
633
+ amounts[i] = amounts[i] / (10 ** (pricingDecimals - context.amount.decimals));
634
+ }
635
+ convertedTotal += amounts[i];
636
+
637
+ unchecked {
638
+ ++i;
639
+ }
640
+ }
641
+
642
+ // Re-encode with the rescaled amounts.
643
+ convertedMetadata = abi.encode(tierIds, amounts);
354
644
  }
355
645
 
356
646
  // Cap the total at the actual payment value. Pay credits fund NFT minting (virtual), but splits
357
647
  // require real tokens to distribute. Without this cap, a user with sufficient pay credits but
358
648
  // insufficient ETH would revert because the terminal can't forward more than what was actually paid.
359
- if (convertedTotal > amountValue) {
649
+ if (convertedTotal > context.amount.value) {
360
650
  // Proportionally reduce each per-tier amount to stay in sync with the capped total.
361
651
  if (convertedMetadata.length != 0) {
362
652
  (uint16[] memory tierIds, uint256[] memory amounts) =
@@ -365,7 +655,7 @@ library JB721TiersHookLib {
365
655
  convertedTotal = 0;
366
656
  for (uint256 i; i < amounts.length;) {
367
657
  // Scale down: amount * amountValue / originalTotal.
368
- amounts[i] = mulDiv({x: amounts[i], y: amountValue, denominator: uncappedTotal});
658
+ amounts[i] = mulDiv({x: amounts[i], y: context.amount.value, denominator: uncappedTotal});
369
659
  convertedTotal += amounts[i];
370
660
 
371
661
  unchecked {
@@ -375,104 +665,14 @@ library JB721TiersHookLib {
375
665
  convertedMetadata = abi.encode(tierIds, amounts);
376
666
  } else {
377
667
  // Clamp the total to the payment value.
378
- convertedTotal = amountValue;
668
+ convertedTotal = context.amount.value;
379
669
  }
380
670
  }
381
671
  }
382
672
 
383
- /// @notice Sets split groups in JBSplits for tiers that have splits configured.
384
- function _setSplitGroupsFor(
385
- IJBSplits splits,
386
- uint256 projectId,
387
- address hookAddress,
388
- JB721TierConfig[] memory tiersToAdd,
389
- uint256[] memory tierIdsAdded
390
- )
391
- private
392
- {
393
- uint256 splitGroupCount;
394
- for (uint256 i; i < tiersToAdd.length;) {
395
- if (tiersToAdd[i].splits.length != 0) splitGroupCount++;
396
-
397
- unchecked {
398
- ++i;
399
- }
400
- }
401
- if (splitGroupCount == 0) return;
402
-
403
- JBSplitGroup[] memory splitGroups = new JBSplitGroup[](splitGroupCount);
404
- uint256 groupIndex;
405
- for (uint256 i; i < tiersToAdd.length;) {
406
- if (tiersToAdd[i].splits.length != 0) {
407
- splitGroups[groupIndex] = JBSplitGroup({
408
- groupId: uint256(uint160(hookAddress)) | (tierIdsAdded[i] << 160), splits: tiersToAdd[i].splits
409
- });
410
- groupIndex++;
411
- }
412
-
413
- unchecked {
414
- ++i;
415
- }
416
- }
417
- splits.setSplitGroupsOf({projectId: projectId, rulesetId: 0, splitGroups: splitGroups});
418
- }
419
-
420
- /// @notice Pulls ERC-20 tokens from the terminal (if needed) and distributes forwarded funds to tier splits.
421
- /// @dev For ERC-20 tokens, pulls from the terminal using the allowance it granted via _beforeTransferTo.
422
- /// @param directory The directory to look up terminals.
423
- /// @param splits The splits contract to read tier split groups from.
424
- /// @param projectId The project ID of the hook.
425
- /// @param hookAddress The hook address (for computing split group IDs).
426
- /// @param token The token being distributed.
427
- /// @param amount The total amount to distribute.
428
- /// @param encodedSplitData The encoded per-tier breakdown from hookMetadata.
429
- function distributeAll(
430
- IJBDirectory directory,
431
- IJBSplits splits,
432
- uint256 projectId,
433
- address hookAddress,
434
- address token,
435
- uint256 amount,
436
- uint256 decimals,
437
- bytes calldata encodedSplitData
438
- )
439
- external
440
- {
441
- // For ERC20 tokens, pull from terminal using the allowance it granted via _beforeTransferTo.
442
- if (token != JBConstants.NATIVE_TOKEN) {
443
- uint256 balanceBefore = IERC20(token).balanceOf(address(this));
444
- SafeERC20.safeTransferFrom({token: IERC20(token), from: msg.sender, to: address(this), value: amount});
445
- uint256 receivedAmount = IERC20(token).balanceOf(address(this)) - balanceBefore;
446
- if (receivedAmount != amount) {
447
- revert JB721TiersHookLib_TokenTransferAmountMismatch(amount, receivedAmount);
448
- }
449
- }
450
-
451
- (uint16[] memory tierIds, uint256[] memory amounts) = abi.decode(encodedSplitData, (uint16[], uint256[]));
452
-
453
- for (uint256 i; i < tierIds.length;) {
454
- if (amounts[i] == 0) {
455
- unchecked {
456
- ++i;
457
- }
458
- continue;
459
- }
460
- uint256 groupId = uint256(uint160(hookAddress)) | (uint256(tierIds[i]) << 160);
461
- _distributeSingleSplit({
462
- directory: directory,
463
- splitsContract: splits,
464
- projectId: projectId,
465
- token: token,
466
- groupId: groupId,
467
- amount: amounts[i],
468
- decimals: decimals
469
- });
470
-
471
- unchecked {
472
- ++i;
473
- }
474
- }
475
- }
673
+ //*********************************************************************//
674
+ // ----------------------- private helpers --------------------------- //
675
+ //*********************************************************************//
476
676
 
477
677
  /// @notice Distributes funds for a single tier's split group.
478
678
  /// @dev Edge case: if both `_sendPayoutToSplit` returns false (reverting hook/terminal/beneficiary) AND the
@@ -741,33 +941,40 @@ library JB721TiersHookLib {
741
941
  return false;
742
942
  }
743
943
 
744
- /// @notice Resolves the token URI for a given NFT token ID.
745
- /// @dev Extracted to the library to keep JBIpfsDecoder bytecode out of the hook contract (EIP-170 compliance).
746
- /// @param store The 721 tiers hook store.
747
- /// @param hook The hook address.
748
- /// @param baseUri The base URI for IPFS-based token URIs.
749
- /// @param tokenId The token ID to resolve the URI for.
750
- /// @return The resolved token URI string.
751
- function resolveTokenURI(
752
- IJB721TiersHookStore store,
753
- address hook,
754
- string memory baseUri,
755
- uint256 tokenId
944
+ /// @notice Sets split groups in JBSplits for tiers that have splits configured.
945
+ function _setSplitGroupsFor(
946
+ IJBSplits splits,
947
+ uint256 projectId,
948
+ address hookAddress,
949
+ JB721TierConfig[] memory tiersToAdd,
950
+ uint256[] memory tierIdsAdded
756
951
  )
757
- external
758
- view
759
- returns (string memory)
952
+ private
760
953
  {
761
- // Get a reference to the `tokenUriResolver`.
762
- IJB721TokenUriResolver resolver = store.tokenUriResolverOf(hook);
954
+ uint256 splitGroupCount;
955
+ for (uint256 i; i < tiersToAdd.length;) {
956
+ if (tiersToAdd[i].splits.length != 0) splitGroupCount++;
763
957
 
764
- // If a `tokenUriResolver` is set, use it to resolve the token URI.
765
- if (address(resolver) != address(0)) return resolver.tokenUriOf({nft: hook, tokenId: tokenId});
958
+ unchecked {
959
+ ++i;
960
+ }
961
+ }
962
+ if (splitGroupCount == 0) return;
766
963
 
767
- // Otherwise, return the token URI corresponding with the NFT's tier.
768
- return
769
- JBIpfsDecoder.decode({
770
- baseUri: baseUri, hexString: store.encodedTierIPFSUriOf({hook: hook, tokenId: tokenId})
771
- });
964
+ JBSplitGroup[] memory splitGroups = new JBSplitGroup[](splitGroupCount);
965
+ uint256 groupIndex;
966
+ for (uint256 i; i < tiersToAdd.length;) {
967
+ if (tiersToAdd[i].splits.length != 0) {
968
+ splitGroups[groupIndex] = JBSplitGroup({
969
+ groupId: uint256(uint160(hookAddress)) | (tierIdsAdded[i] << 160), splits: tiersToAdd[i].splits
970
+ });
971
+ groupIndex++;
972
+ }
973
+
974
+ unchecked {
975
+ ++i;
976
+ }
977
+ }
978
+ splits.setSplitGroupsOf({projectId: projectId, rulesetId: 0, splitGroups: splitGroups});
772
979
  }
773
980
  }
@@ -15,6 +15,9 @@ import "../../src/JB721TiersHookDeployer.sol";
15
15
  // forge-lint: disable-next-line(unaliased-plain-import)
16
16
  import "../../src/JB721TiersHookStore.sol";
17
17
 
18
+ import {JB721CheckpointsDeployer} from "../../src/JB721CheckpointsDeployer.sol";
19
+ import {IJB721CheckpointsDeployer} from "../../src/interfaces/IJB721CheckpointsDeployer.sol";
20
+
18
21
  // forge-lint: disable-next-line(unaliased-plain-import)
19
22
  import "../utils/TestBaseWorkflow.sol";
20
23
  // forge-lint: disable-next-line(unaliased-plain-import)
@@ -69,7 +72,14 @@ contract Test_TiersHook_E2E is TestBaseWorkflow {
69
72
  super.setUp();
70
73
  store = new JB721TiersHookStore();
71
74
  hook = new JB721TiersHook(
72
- jbDirectory, jbPermissions, jbPrices, jbRulesets, store, IJBSplits(address(jbSplits)), trustedForwarder
75
+ jbDirectory,
76
+ jbPermissions,
77
+ jbPrices,
78
+ jbRulesets,
79
+ store,
80
+ IJBSplits(address(jbSplits)),
81
+ IJB721CheckpointsDeployer(address(new JB721CheckpointsDeployer())),
82
+ trustedForwarder
73
83
  );
74
84
  addressRegistry = new JBAddressRegistry();
75
85
  JB721TiersHookDeployer hookDeployer = new JB721TiersHookDeployer(hook, store, addressRegistry, trustedForwarder);