@bananapus/721-hook-v6 0.0.1 → 0.0.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,9 +18,9 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@bananapus/address-registry-v6": "^0.0.1",
21
- "@bananapus/core-v6": "^0.0.1",
21
+ "@bananapus/core-v6": "^0.0.4",
22
22
  "@bananapus/ownable-v6": "^0.0.1",
23
- "@bananapus/permission-ids-v6": "^0.0.1",
23
+ "@bananapus/permission-ids-v6": "^0.0.2",
24
24
  "@openzeppelin/contracts": "5.2.0",
25
25
  "@prb/math": "^4.1.0",
26
26
  "solady": "^0.1.8"
@@ -1,26 +1,35 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity 0.8.23;
3
3
 
4
+ import {IJBCashOutHook} from "@bananapus/core-v6/src/interfaces/IJBCashOutHook.sol";
4
5
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
6
+ import {IJBPayHook} from "@bananapus/core-v6/src/interfaces/IJBPayHook.sol";
5
7
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
6
8
  import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
9
+ import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
7
10
  import {IJBRulesets} from "@bananapus/core-v6/src/interfaces/IJBRulesets.sol";
11
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
12
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
8
13
  import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
9
14
  import {JBRulesetMetadataResolver} from "@bananapus/core-v6/src/libraries/JBRulesetMetadataResolver.sol";
15
+ import {JBAfterCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterCashOutRecordedContext.sol";
10
16
  import {JBAfterPayRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterPayRecordedContext.sol";
11
17
  import {JBBeforeCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforeCashOutRecordedContext.sol";
18
+ import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
19
+ import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
20
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
12
21
  import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
13
22
  import {JBOwnable} from "@bananapus/ownable-v6/src/JBOwnable.sol";
14
23
  import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
24
+ import {IERC2981} from "@openzeppelin/contracts/interfaces/IERC2981.sol";
15
25
  import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
16
26
  import {Context} from "@openzeppelin/contracts/utils/Context.sol";
17
27
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
18
- import {mulDiv} from "@prb/math/src/Common.sol";
19
-
20
- import {JB721Hook} from "./abstract/JB721Hook.sol";
28
+ import {ERC721} from "./abstract/ERC721.sol";
21
29
  import {IJB721TiersHook} from "./interfaces/IJB721TiersHook.sol";
22
30
  import {IJB721TiersHookStore} from "./interfaces/IJB721TiersHookStore.sol";
23
31
  import {IJB721TokenUriResolver} from "./interfaces/IJB721TokenUriResolver.sol";
32
+ import {JB721TiersHookLib} from "./libraries/JB721TiersHookLib.sol";
24
33
  import {JB721TiersRulesetMetadataResolver} from "./libraries/JB721TiersRulesetMetadataResolver.sol";
25
34
  import {JBIpfsDecoder} from "./libraries/JBIpfsDecoder.sol";
26
35
  import {JB721Tier} from "./structs/JB721Tier.sol";
@@ -35,23 +44,33 @@ import {JB721TiersMintReservesConfig} from "./structs/JB721TiersMintReservesConf
35
44
  /// the project is paid, the hook may mint NFTs to the payer, depending on the hook's setup, the amount paid, and
36
45
  /// information specified by the payer. The project's owner can enable NFT cash outs through this hook, allowing
37
46
  /// holders to burn their NFTs to reclaim funds from the project (in proportion to the NFT's price).
38
- contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook {
47
+ contract JB721TiersHook is JBOwnable, ERC2771Context, ERC721, IJB721TiersHook {
39
48
  //*********************************************************************//
40
49
  // --------------------------- custom errors ------------------------- //
41
50
  //*********************************************************************//
42
51
 
43
52
  error JB721TiersHook_AlreadyInitialized(uint256 projectId);
44
53
  error JB721TiersHook_CurrencyMismatch(uint256 paymentCurrency, uint256 tierCurrency);
54
+ error JB721TiersHook_InvalidCashOut();
55
+ error JB721TiersHook_InvalidPay();
45
56
  error JB721TiersHook_InvalidPricingDecimals(uint256 decimals);
46
57
  error JB721TiersHook_MintReserveNftsPaused();
47
58
  error JB721TiersHook_NoProjectId();
48
59
  error JB721TiersHook_Overspending(uint256 leftoverAmount);
49
60
  error JB721TiersHook_TierTransfersPaused();
61
+ error JB721TiersHook_UnauthorizedToken(uint256 tokenId, address holder);
62
+ error JB721TiersHook_UnexpectedTokenCashedOut();
50
63
 
51
64
  //*********************************************************************//
52
65
  // --------------- public immutable stored properties ---------------- //
53
66
  //*********************************************************************//
54
67
 
68
+ /// @notice The directory of terminals and controllers for projects.
69
+ IJBDirectory public immutable override DIRECTORY;
70
+
71
+ /// @notice The ID used when parsing metadata.
72
+ address public immutable override METADATA_ID_TARGET;
73
+
55
74
  /// @notice The contract storing and managing project rulesets.
56
75
  IJBRulesets public immutable override RULESETS;
57
76
 
@@ -61,6 +80,10 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
61
80
  //*********************************************************************//
62
81
  // ---------------------- public stored properties ------------------- //
63
82
  //*********************************************************************//
83
+
84
+ /// @notice The ID of the project that this contract is associated with.
85
+ uint256 public override PROJECT_ID;
86
+
64
87
  /// @notice The base URI for the NFT `tokenUris`.
65
88
  string public override baseURI;
66
89
 
@@ -105,9 +128,10 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
105
128
  address trustedForwarder
106
129
  )
107
130
  JBOwnable(permissions, directory.PROJECTS(), msg.sender, uint88(0))
108
- JB721Hook(directory)
109
131
  ERC2771Context(trustedForwarder)
110
132
  {
133
+ DIRECTORY = directory;
134
+ METADATA_ID_TARGET = address(this);
111
135
  RULESETS = rulesets;
112
136
  STORE = store;
113
137
  }
@@ -116,6 +140,61 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
116
140
  // ------------------------- external views -------------------------- //
117
141
  //*********************************************************************//
118
142
 
143
+ /// @notice The data calculated before a cash out is recorded in the terminal store. This data is provided to the
144
+ /// terminal's `cashOutTokensOf(...)` transaction.
145
+ /// @dev Sets this contract as the cash out hook. Part of `IJBRulesetDataHook`.
146
+ /// @dev This function is used for NFT cash outs, and will only be called if the project's ruleset has
147
+ /// `useDataHookForCashOut` set to `true`.
148
+ /// @param context The cash out context passed to this contract by the `cashOutTokensOf(...)` function.
149
+ /// @return cashOutTaxRate The cash out tax rate influencing the reclaim amount.
150
+ /// @return cashOutCount The amount of tokens that should be considered cashed out.
151
+ /// @return totalSupply The total amount of tokens that are considered to be existing.
152
+ /// @return hookSpecifications The amount and data to send to cash out hooks (this contract) instead of returning to
153
+ /// the beneficiary.
154
+ function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context)
155
+ public
156
+ view
157
+ virtual
158
+ override
159
+ returns (
160
+ uint256 cashOutTaxRate,
161
+ uint256 cashOutCount,
162
+ uint256 totalSupply,
163
+ JBCashOutHookSpecification[] memory hookSpecifications
164
+ )
165
+ {
166
+ // Make sure (fungible) project tokens aren't also being cashed out.
167
+ if (context.cashOutCount > 0) revert JB721TiersHook_UnexpectedTokenCashedOut();
168
+
169
+ // Fetch the cash out hook metadata using the corresponding metadata ID.
170
+ (bool metadataExists, bytes memory metadata) = JBMetadataResolver.getDataFor({
171
+ id: JBMetadataResolver.getId({purpose: "cashOut", target: METADATA_ID_TARGET}), metadata: context.metadata
172
+ });
173
+
174
+ // Use this contract as the only cash out hook.
175
+ hookSpecifications = new JBCashOutHookSpecification[](1);
176
+ hookSpecifications[0] = JBCashOutHookSpecification({hook: this, amount: 0, metadata: bytes("")});
177
+
178
+ uint256[] memory decodedTokenIds;
179
+
180
+ // Decode the metadata.
181
+ if (metadataExists) decodedTokenIds = abi.decode(metadata, (uint256[]));
182
+
183
+ // Use the cash out weight of the provided 721s.
184
+ cashOutCount = STORE.cashOutWeightOf({hook: address(this), tokenIds: decodedTokenIds});
185
+
186
+ // Use the total cash out weight of the 721s.
187
+ totalSupply = STORE.totalCashOutWeight(address(this));
188
+
189
+ // Use the cash out tax rate from the context.
190
+ cashOutTaxRate = context.cashOutTaxRate;
191
+ }
192
+
193
+ /// @notice Required by the IJBRulesetDataHook interfaces. Return false to not leak any permissions.
194
+ function hasMintPermissionFor(uint256, JBRuleset memory, address) external pure returns (bool) {
195
+ return false;
196
+ }
197
+
119
198
  /// @notice The first owner of an NFT.
120
199
  /// @dev This is generally the address which paid for the NFT.
121
200
  /// @param tokenId The token ID of the NFT to get the first owner of.
@@ -158,7 +237,40 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
158
237
  return STORE.balanceOf({hook: address(this), owner: owner});
159
238
  }
160
239
 
161
- /// @notice Initializes a cloned copy of the original `JB721Hook` contract.
240
+ /// @notice The data calculated before a payment is recorded in the terminal store. This data is provided to the
241
+ /// terminal's `pay(...)` transaction.
242
+ /// @dev Sets this contract as the pay hook. Part of `IJBRulesetDataHook`.
243
+ /// @param context The payment context passed to this contract by the `pay(...)` function.
244
+ /// @return weight The new `weight` to use, overriding the ruleset's `weight`.
245
+ /// @return hookSpecifications The amount and data to send to pay hooks (this contract) instead of adding to the
246
+ /// terminal's balance.
247
+ function beforePayRecordedWith(JBBeforePayRecordedContext calldata context)
248
+ public
249
+ view
250
+ virtual
251
+ override
252
+ returns (uint256 weight, JBPayHookSpecification[] memory hookSpecifications)
253
+ {
254
+ weight = context.weight;
255
+ hookSpecifications = new JBPayHookSpecification[](1);
256
+
257
+ // Calculate per-tier split amounts via the library.
258
+ (uint256 totalSplitAmount, bytes memory splitMetadata) =
259
+ JB721TiersHookLib.calculateSplitAmounts(STORE, address(this), METADATA_ID_TARGET, context.metadata);
260
+
261
+ hookSpecifications[0] = JBPayHookSpecification({hook: this, amount: totalSplitAmount, metadata: splitMetadata});
262
+ }
263
+
264
+ /// @notice Indicates if this contract adheres to the specified interface.
265
+ /// @dev See {IERC165-supportsInterface}.
266
+ /// @param interfaceId The ID of the interface to check for adherence to.
267
+ function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721, IERC165) returns (bool) {
268
+ return interfaceId == type(IJB721TiersHook).interfaceId || interfaceId == type(IJBRulesetDataHook).interfaceId
269
+ || interfaceId == type(IJBPayHook).interfaceId || interfaceId == type(IJBCashOutHook).interfaceId
270
+ || interfaceId == type(IERC2981).interfaceId || super.supportsInterface(interfaceId);
271
+ }
272
+
273
+ /// @notice Initializes a cloned copy of the original hook contract.
162
274
  /// @param projectId The ID of the project this this hook is associated with.
163
275
  /// @param name The name of the NFT collection.
164
276
  /// @param symbol The symbol representing the NFT collection.
@@ -187,8 +299,9 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
187
299
  // Make sure a projectId is provided.
188
300
  if (projectId == 0) revert JB721TiersHook_NoProjectId();
189
301
 
190
- // Initialize the superclass.
191
- JB721Hook._initialize({projectId: projectId, name: name, symbol: symbol});
302
+ // Initialize ERC721 and set the project ID.
303
+ ERC721._initialize({name_: name, symbol_: symbol});
304
+ PROJECT_ID = projectId;
192
305
 
193
306
  // Validate pricing decimals are within a reasonable range.
194
307
  if (tiersConfig.decimals > 18) revert JB721TiersHook_InvalidPricingDecimals(tiersConfig.decimals);
@@ -230,31 +343,6 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
230
343
  _transferOwnership(_msgSender());
231
344
  }
232
345
 
233
- /// @notice The combined cash out weight of the NFTs with the specified token IDs.
234
- /// @dev An NFT's cash out weight is its price.
235
- /// @dev To get their relative cash out weight, divide the result by the `totalCashOutWeight(...)`.
236
- /// @param tokenIds The token IDs of the NFTs to get the cumulative cash out weight of.
237
- /// @return weight The cash out weight of the tokenIds.
238
- function cashOutWeightOf(
239
- uint256[] memory tokenIds,
240
- JBBeforeCashOutRecordedContext calldata
241
- )
242
- public
243
- view
244
- virtual
245
- override
246
- returns (uint256)
247
- {
248
- return STORE.cashOutWeightOf({hook: address(this), tokenIds: tokenIds});
249
- }
250
-
251
- /// @notice Indicates if this contract adheres to the specified interface.
252
- /// @dev See {IERC165-supportsInterface}.
253
- /// @param interfaceId The ID of the interface to check for adherence to.
254
- function supportsInterface(bytes4 interfaceId) public view override(IERC165, JB721Hook) returns (bool) {
255
- return interfaceId == type(IJB721TiersHook).interfaceId || JB721Hook.supportsInterface(interfaceId);
256
- }
257
-
258
346
  /// @notice The metadata URI of the NFT with the specified token ID.
259
347
  /// @dev Defers to the `tokenUriResolver` if it is set. Otherwise, use the `tokenUri` corresponding with the NFT's
260
348
  /// tier.
@@ -274,51 +362,75 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
274
362
  });
275
363
  }
276
364
 
277
- /// @notice The combined cash out weight of all outstanding NFTs.
278
- /// @dev An NFT's cash out weight is its price.
279
- /// @return weight The total cash out weight.
280
- function totalCashOutWeight(JBBeforeCashOutRecordedContext calldata)
281
- public
282
- view
365
+ //*********************************************************************//
366
+ // ---------------------- external transactions ---------------------- //
367
+ //*********************************************************************//
368
+
369
+ /// @notice Mints one or more NFTs to the `context.beneficiary` upon payment if conditions are met. Part of
370
+ /// `IJBPayHook`.
371
+ /// @dev Reverts if the calling contract is not one of the project's terminals.
372
+ /// @param context The payment context passed in by the terminal.
373
+ // slither-disable-next-line locked-ether
374
+ function afterPayRecordedWith(JBAfterPayRecordedContext calldata context) external payable virtual override {
375
+ uint256 projectId = PROJECT_ID;
376
+
377
+ // Make sure the caller is a terminal of the project, and that the call is being made on behalf of an
378
+ // interaction with the correct project.
379
+ if (!DIRECTORY.isTerminalOf(projectId, IJBTerminal(msg.sender)) || context.projectId != projectId) {
380
+ revert JB721TiersHook_InvalidPay();
381
+ }
382
+
383
+ // Process the payment.
384
+ _processPayment(context);
385
+ }
386
+
387
+ /// @notice Burns the specified NFTs upon token holder cash out, reclaiming funds from the project's balance for
388
+ /// `context.beneficiary`. Part of `IJBCashOutHook`.
389
+ /// @dev Reverts if the calling contract is not one of the project's terminals.
390
+ /// @param context The cash out context passed in by the terminal.
391
+ // slither-disable-next-line locked-ether
392
+ function afterCashOutRecordedWith(JBAfterCashOutRecordedContext calldata context)
393
+ external
394
+ payable
283
395
  virtual
284
396
  override
285
- returns (uint256)
286
397
  {
287
- return STORE.totalCashOutWeight(address(this));
288
- }
398
+ // Keep a reference to the project ID.
399
+ uint256 projectId = PROJECT_ID;
289
400
 
290
- //*********************************************************************//
291
- // -------------------------- internal views ------------------------- //
292
- //*********************************************************************//
401
+ // Make sure the caller is a terminal of the project, and that the call is being made on behalf of an
402
+ // interaction with the correct project.
403
+ if (
404
+ msg.value != 0 || !DIRECTORY.isTerminalOf({projectId: projectId, terminal: IJBTerminal(msg.sender)})
405
+ || context.projectId != projectId
406
+ ) revert JB721TiersHook_InvalidCashOut();
407
+
408
+ // Fetch the cash out hook metadata using the corresponding metadata ID.
409
+ (bool metadataExists, bytes memory metadata) = JBMetadataResolver.getDataFor({
410
+ id: JBMetadataResolver.getId({purpose: "cashOut", target: METADATA_ID_TARGET}),
411
+ metadata: context.cashOutMetadata
412
+ });
293
413
 
294
- /// @dev ERC-2771 specifies the context as being a single address (20 bytes).
295
- function _contextSuffixLength() internal view virtual override(ERC2771Context, Context) returns (uint256) {
296
- return super._contextSuffixLength();
297
- }
414
+ uint256[] memory decodedTokenIds;
298
415
 
299
- /// @notice The project's current ruleset.
300
- /// @param projectId The ID of the project to check.
301
- /// @return The project's current ruleset.
302
- function _currentRulesetOf(uint256 projectId) internal view returns (JBRuleset memory) {
303
- // slither-disable-next-line calls-loop
304
- return RULESETS.currentOf(projectId);
305
- }
416
+ // Decode the metadata.
417
+ if (metadataExists) decodedTokenIds = abi.decode(metadata, (uint256[]));
306
418
 
307
- /// @notice Returns the calldata, preferred to use over `msg.data`
308
- /// @return calldata the `msg.data` of this call
309
- function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) {
310
- return ERC2771Context._msgData();
311
- }
419
+ // Iterate through the NFTs, burning them if the owner is correct.
420
+ for (uint256 i; i < decodedTokenIds.length; i++) {
421
+ // Set the current NFT's token ID.
422
+ uint256 tokenId = decodedTokenIds[i];
312
423
 
313
- /// @notice Returns the sender, preferred to use over `msg.sender`
314
- /// @return sender the sender address of this call.
315
- function _msgSender() internal view override(ERC2771Context, Context) returns (address sender) {
316
- return ERC2771Context._msgSender();
317
- }
424
+ // Make sure the token's owner is correct.
425
+ if (_ownerOf(tokenId) != context.holder) revert JB721TiersHook_UnauthorizedToken(tokenId, context.holder);
318
426
 
319
- //*********************************************************************//
320
- // ---------------------- external transactions ---------------------- //
321
- //*********************************************************************//
427
+ // Burn the token.
428
+ _burn(tokenId);
429
+ }
430
+
431
+ // Add to burned counter.
432
+ STORE.recordBurn(decodedTokenIds);
433
+ }
322
434
 
323
435
  /// @notice Add or delete tiers.
324
436
  /// @dev Only the contract's owner or an operator with the `ADJUST_TIERS` permission from the owner can adjust the
@@ -332,28 +444,10 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
332
444
  account: owner(), projectId: PROJECT_ID, permissionId: JBPermissionIds.ADJUST_721_TIERS
333
445
  });
334
446
 
335
- // Remove the tiers.
336
- if (tierIdsToRemove.length != 0) {
337
- // Emit events for each removed tier.
338
- for (uint256 i; i < tierIdsToRemove.length; i++) {
339
- emit RemoveTier({tierId: tierIdsToRemove[i], caller: _msgSender()});
340
- }
341
-
342
- // Record the removed tiers.
343
- // slither-disable-next-line reentrancy-events
344
- STORE.recordRemoveTierIds(tierIdsToRemove);
345
- }
346
-
347
- // Add the tiers.
348
- if (tiersToAdd.length != 0) {
349
- // Record the added tiers in the store.
350
- uint256[] memory tierIdsAdded = STORE.recordAddTiers(tiersToAdd);
351
-
352
- // Emit events for each added tier.
353
- for (uint256 i; i < tiersToAdd.length; i++) {
354
- emit AddTier({tierId: tierIdsAdded[i], tier: tiersToAdd[i], caller: _msgSender()});
355
- }
356
- }
447
+ // Delegate to the library (via DELEGATECALL) for tier removal, addition, event emission, and split setting.
448
+ JB721TiersHookLib.adjustTiersFor(
449
+ STORE, DIRECTORY, PROJECT_ID, address(this), _msgSender(), tiersToAdd, tierIdsToRemove
450
+ );
357
451
  }
358
452
 
359
453
  /// @notice Manually mint NFTs from the provided tiers .
@@ -522,16 +616,38 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
522
616
  }
523
617
 
524
618
  //*********************************************************************//
525
- // ------------------------ internal functions ----------------------- //
619
+ // -------------------------- internal views ------------------------- //
526
620
  //*********************************************************************//
527
621
 
528
- /// @notice A function which gets called after NFTs have been cashed out and recorded by the terminal.
529
- /// @param tokenIds The token IDs of the NFTs that were burned.
530
- function _didBurn(uint256[] memory tokenIds) internal virtual override {
531
- // Add to burned counter.
532
- STORE.recordBurn(tokenIds);
622
+ /// @dev ERC-2771 specifies the context as being a single address (20 bytes).
623
+ function _contextSuffixLength() internal view virtual override(ERC2771Context, Context) returns (uint256) {
624
+ return super._contextSuffixLength();
625
+ }
626
+
627
+ /// @notice The project's current ruleset.
628
+ /// @param projectId The ID of the project to check.
629
+ /// @return The project's current ruleset.
630
+ function _currentRulesetOf(uint256 projectId) internal view returns (JBRuleset memory) {
631
+ // slither-disable-next-line calls-loop
632
+ return RULESETS.currentOf(projectId);
533
633
  }
534
634
 
635
+ /// @notice Returns the calldata, preferred to use over `msg.data`
636
+ /// @return calldata the `msg.data` of this call
637
+ function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) {
638
+ return ERC2771Context._msgData();
639
+ }
640
+
641
+ /// @notice Returns the sender, preferred to use over `msg.sender`
642
+ /// @return sender the sender address of this call.
643
+ function _msgSender() internal view override(ERC2771Context, Context) returns (address sender) {
644
+ return ERC2771Context._msgSender();
645
+ }
646
+
647
+ //*********************************************************************//
648
+ // ------------------------ internal functions ----------------------- //
649
+ //*********************************************************************//
650
+
535
651
  /// @notice Mints one NFT from each of the specified tiers for the beneficiary.
536
652
  /// @dev The same tier can be specified more than once.
537
653
  /// @param amount The amount to base the mints on. The total price of the NFTs being minted cannot be larger than
@@ -581,36 +697,19 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
581
697
  /// the payer's existing credits are NOT applied to the mint. Only the beneficiary's credits are combined with
582
698
  /// the incoming payment value. Leftover funds after minting are stored as credits for the beneficiary.
583
699
  /// @param context Payment context provided by the terminal after it has recorded the payment in the terminal store.
584
- function _processPayment(JBAfterPayRecordedContext calldata context) internal virtual override {
700
+ function _processPayment(JBAfterPayRecordedContext calldata context) internal virtual {
585
701
  // Normalize the payment value based on the pricing context.
586
702
  uint256 value;
587
-
588
703
  {
589
- uint256 packed = _packedPricingContext;
590
- // pricing currency in bits 0-31 (32 bits).
591
- uint256 pricingCurrency = uint256(uint32(packed));
592
- if (context.amount.currency == pricingCurrency) {
593
- value = context.amount.value;
594
- } else {
595
- // prices in bits 40-199 (160 bits).
596
- IJBPrices prices = IJBPrices(address(uint160(packed >> 40)));
597
- if (prices != IJBPrices(address(0))) {
598
- // pricing decimals in bits 32-39 (8 bits).
599
- uint256 pricingDecimals = uint256(uint8(packed >> 32));
600
- value = mulDiv(
601
- context.amount.value,
602
- 10 ** pricingDecimals,
603
- prices.pricePerUnitOf({
604
- projectId: PROJECT_ID,
605
- pricingCurrency: context.amount.currency,
606
- unitCurrency: pricingCurrency,
607
- decimals: context.amount.decimals
608
- })
609
- );
610
- } else {
611
- revert JB721TiersHook_CurrencyMismatch(context.amount.currency, pricingCurrency);
612
- }
613
- }
704
+ bool valid;
705
+ (value, valid) = JB721TiersHookLib.normalizePaymentValue(
706
+ _packedPricingContext,
707
+ PROJECT_ID,
708
+ context.amount.value,
709
+ context.amount.currency,
710
+ context.amount.decimals
711
+ );
712
+ if (!valid) return;
614
713
  }
615
714
 
616
715
  // Keep a reference to the number of NFT credits the beneficiary already has.
@@ -662,17 +761,14 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
662
761
  }
663
762
  }
664
763
 
665
- // If overspending is allowed and there are leftover funds, add those funds to the beneficiary's NFT credits.
666
- if (leftoverAmount != 0) {
667
- // If overspending isn't allowed, revert.
668
- if (!allowOverspending) revert JB721TiersHook_Overspending(leftoverAmount);
764
+ // If overspending isn't allowed, revert.
765
+ if (leftoverAmount != 0 && !allowOverspending) revert JB721TiersHook_Overspending(leftoverAmount);
669
766
 
670
- // Store the leftover amount as NFT credits.
671
- unchecked {
672
- // Keep a reference to the amount of new NFT credits.
673
- uint256 newPayCredits = leftoverAmount + unusedPayCredits;
767
+ // Update NFT credits if they changed.
768
+ unchecked {
769
+ uint256 newPayCredits = leftoverAmount + unusedPayCredits;
674
770
 
675
- // Emit the change in NFT credits.
771
+ if (newPayCredits != payCredits) {
676
772
  if (newPayCredits > payCredits) {
677
773
  emit AddPayCredits({
678
774
  amount: newPayCredits - payCredits,
@@ -680,7 +776,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
680
776
  account: context.beneficiary,
681
777
  caller: _msgSender()
682
778
  });
683
- } else if (payCredits > newPayCredits) {
779
+ } else {
684
780
  emit UsePayCredits({
685
781
  amount: payCredits - newPayCredits,
686
782
  newTotalCredits: newPayCredits,
@@ -689,21 +785,15 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
689
785
  });
690
786
  }
691
787
 
692
- // Store the new NFT credits for the beneficiary.
693
788
  payCreditsOf[context.beneficiary] = newPayCredits;
694
789
  }
695
- // Otherwise, reset their NFT credits.
696
- } else if (payCredits != unusedPayCredits) {
697
- // Emit the change in NFT credits.
698
- emit UsePayCredits({
699
- amount: payCredits - unusedPayCredits,
700
- newTotalCredits: unusedPayCredits,
701
- account: context.beneficiary,
702
- caller: _msgSender()
703
- });
790
+ }
704
791
 
705
- // Store the new NFT credits.
706
- payCreditsOf[context.beneficiary] = unusedPayCredits;
792
+ // Distribute any forwarded funds to tier split groups.
793
+ if (context.hookMetadata.length != 0 && context.forwardedAmount.value != 0) {
794
+ JB721TiersHookLib.distributeAll(
795
+ DIRECTORY, PROJECT_ID, address(this), context.forwardedAmount.token, context.hookMetadata
796
+ );
707
797
  }
708
798
  }
709
799
 
@@ -100,6 +100,7 @@ contract JB721TiersHookDeployer is ERC2771Context, IJB721TiersHookDeployer {
100
100
  JBOwnable(address(newHook)).transferOwnership(_msgSender());
101
101
 
102
102
  // Increment the nonce.
103
+ // slither-disable-next-line reentrancy-benign
103
104
  ++_nonce;
104
105
 
105
106
  // Add the hook to the address registry. This contract's nonce starts at 1.
@@ -139,6 +139,13 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
139
139
  /// @custom:returns The following tier's ID.
140
140
  mapping(address hook => mapping(uint256 tierId => uint256)) internal _tierIdAfter;
141
141
 
142
+ /// @notice Returns the custom voting units for the provided tier ID on the provided hook.
143
+ /// @dev Only populated when `useVotingUnits` is true. When not set, voting power defaults to the tier's price.
144
+ /// @custom:param hook The address of the 721 contract.
145
+ /// @custom:param tierId The ID of the tier.
146
+ /// @custom:returns The voting units for the tier.
147
+ mapping(address hook => mapping(uint256 tierId => uint32)) internal _tierVotingUnitsOf;
148
+
142
149
  //*********************************************************************//
143
150
  // ------------------------- external views -------------------------- //
144
151
  //*********************************************************************//
@@ -232,7 +239,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
232
239
  (,, bool useVotingUnits,,) = _unpackBools(storedTier.packedBools);
233
240
 
234
241
  // Return the address' voting units within the tier.
235
- return balance * (useVotingUnits ? storedTier.votingUnits : storedTier.price);
242
+ return balance * (useVotingUnits ? _tierVotingUnitsOf[hook][tierId] : storedTier.price);
236
243
  }
237
244
 
238
245
  /// @notice Gets an array of currently active 721 tiers for the provided 721 contract.
@@ -368,7 +375,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
368
375
 
369
376
  // Add the voting units for the address' balance in this tier.
370
377
  // Use custom voting units if set. Otherwise, use the tier's price.
371
- units += balance * (useVotingUnits ? storedTier.votingUnits : storedTier.price);
378
+ units += balance * (useVotingUnits ? _tierVotingUnitsOf[hook][i] : storedTier.price);
372
379
  }
373
380
  }
374
381
 
@@ -529,7 +536,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
529
536
  price: storedTier.price,
530
537
  remainingSupply: storedTier.remainingSupply,
531
538
  initialSupply: storedTier.initialSupply,
532
- votingUnits: useVotingUnits ? storedTier.votingUnits : storedTier.price,
539
+ votingUnits: useVotingUnits ? _tierVotingUnitsOf[hook][tierId] : storedTier.price,
533
540
  // No reserve frequency if there is no reserve beneficiary.
534
541
  reserveFrequency: reserveBeneficiary == address(0) ? 0 : storedTier.reserveFrequency,
535
542
  reserveBeneficiary: reserveBeneficiary,
@@ -540,6 +547,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
540
547
  transfersPausable: transfersPausable,
541
548
  cannotBeRemoved: cannotBeRemoved,
542
549
  cannotIncreaseDiscountPercent: cannotIncreaseDiscountPercent,
550
+ splitPercent: storedTier.splitPercent,
543
551
  resolvedUri: !includeResolvedUri || tokenUriResolverOf[hook] == IJB721TokenUriResolver(address(0))
544
552
  ? ""
545
553
  : tokenUriResolverOf[hook].tokenUriOf({
@@ -848,7 +856,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
848
856
  price: uint104(tierToAdd.price),
849
857
  remainingSupply: uint32(tierToAdd.initialSupply),
850
858
  initialSupply: uint32(tierToAdd.initialSupply),
851
- votingUnits: uint32(tierToAdd.votingUnits),
859
+ splitPercent: uint32(tierToAdd.splitPercent),
852
860
  reserveFrequency: uint16(tierToAdd.reserveFrequency),
853
861
  category: uint24(tierToAdd.category),
854
862
  discountPercent: uint8(tierToAdd.discountPercent),
@@ -861,6 +869,11 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
861
869
  })
862
870
  });
863
871
 
872
+ // Store voting units in a separate mapping if custom voting units are used.
873
+ if (tierToAdd.useVotingUnits && tierToAdd.votingUnits != 0) {
874
+ _tierVotingUnitsOf[msg.sender][tierId] = uint32(tierToAdd.votingUnits);
875
+ }
876
+
864
877
  // If this is the first tier in a new category, store it as the first tier in that category.
865
878
  // The `_startingTierIdOfCategory` of the category "0" will always be the same as the `_tierIdAfter` the 0th
866
879
  // tier.