@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
package/USER_JOURNEYS.md CHANGED
@@ -81,6 +81,17 @@
81
81
  2. Keep collection-specific behavior in the downstream repo while leaving pay, reserve, and cash-out semantics in this repo.
82
82
  3. Audit hook-store interactions here first, then audit the downstream resolver or wrapper.
83
83
 
84
+ ## Journey 7: Mint NFTs To The Correct Beneficiary During Cross-Chain Payments
85
+
86
+ **Starting state:** a sucker pays the project on behalf of a remote user via `payRemote`, and the 721 hook needs to mint NFTs and accrue credits to the real user instead of the sucker contract.
87
+
88
+ **Success:** NFTs mint to and pay credits accrue to the real remote user.
89
+
90
+ **Flow**
91
+ 1. The sucker calls `terminal.pay()` with itself as both payer and beneficiary, embedding the real user's address in the `JB_RELAY_BENEFICIARY` metadata key.
92
+ 2. `_mintAndUpdateCredits` detects that `payer == beneficiary` and finds relay-beneficiary metadata.
93
+ 3. All NFT minting and credit accounting uses the resolved relay beneficiary instead of the sucker address.
94
+
84
95
  ## Hand-Offs
85
96
 
86
97
  - Use [nana-core-v6](../nana-core-v6/USER_JOURNEYS.md) for the treasury, ruleset, and permission surfaces the hook plugs into.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
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.17",
21
- "@bananapus/core-v6": "^0.0.32",
21
+ "@bananapus/core-v6": "^0.0.34",
22
22
  "@bananapus/ownable-v6": "^0.0.17",
23
- "@bananapus/permission-ids-v6": "^0.0.15",
23
+ "@bananapus/permission-ids-v6": "^0.0.17",
24
24
  "@openzeppelin/contracts": "^5.6.1",
25
25
  "@prb/math": "^4.1.0",
26
26
  "solady": "^0.1.8"
@@ -10,6 +10,8 @@ import {
10
10
  import {Sphinx} from "@sphinx-labs/contracts/contracts/foundry/SphinxPlugin.sol";
11
11
  import {Script} from "forge-std/Script.sol";
12
12
 
13
+ import {JB721CheckpointsDeployer} from "../src/JB721CheckpointsDeployer.sol";
14
+ import {IJB721CheckpointsDeployer} from "../src/interfaces/IJB721CheckpointsDeployer.sol";
13
15
  import {JB721TiersHookDeployer} from "../src/JB721TiersHookDeployer.sol";
14
16
  import {JB721TiersHookProjectDeployer} from "../src/JB721TiersHookProjectDeployer.sol";
15
17
  import {JB721TiersHookStore} from "../src/JB721TiersHookStore.sol";
@@ -34,6 +36,8 @@ contract DeployScript is Script, Sphinx {
34
36
  bytes32 HOOK_STORE_SALT = "JB721TiersHookStoreV6_";
35
37
  // forge-lint: disable-next-line(mixed-case-variable)
36
38
  bytes32 PROJECT_DEPLOYER_SALT = "JB721TiersHookProjectDeployerV6";
39
+ // forge-lint: disable-next-line(mixed-case-variable)
40
+ bytes32 CHECKPOINTS_DEPLOYER_SALT = "JB721CheckpointsDeployerV6";
37
41
 
38
42
  function configureSphinx() public override {
39
43
  sphinxConfig.projectName = "nana-721-hook-v6";
@@ -69,28 +73,58 @@ contract DeployScript is Script, Sphinx {
69
73
  JB721TiersHookStore store;
70
74
  {
71
75
  // Perform the check for the store.
72
- (address _store, bool _storeIsDeployed) =
73
- _isDeployed(HOOK_STORE_SALT, type(JB721TiersHookStore).creationCode, "");
76
+ (address _store, bool _storeIsDeployed) = _isDeployed({
77
+ salt: HOOK_STORE_SALT, creationCode: type(JB721TiersHookStore).creationCode, arguments: ""
78
+ });
74
79
 
75
80
  // Deploy it if it has not been deployed yet.
76
81
  store = !_storeIsDeployed ? new JB721TiersHookStore{salt: HOOK_STORE_SALT}() : JB721TiersHookStore(_store);
77
82
  }
78
83
 
84
+ JB721CheckpointsDeployer checkpointsDeployer;
85
+ {
86
+ // Perform the check for the deployer.
87
+ (address _deployer, bool _deployerIsDeployed) = _isDeployed({
88
+ salt: CHECKPOINTS_DEPLOYER_SALT,
89
+ creationCode: type(JB721CheckpointsDeployer).creationCode,
90
+ arguments: ""
91
+ });
92
+
93
+ // Deploy it if it has not been deployed yet.
94
+ checkpointsDeployer = !_deployerIsDeployed
95
+ ? new JB721CheckpointsDeployer{salt: CHECKPOINTS_DEPLOYER_SALT}()
96
+ : JB721CheckpointsDeployer(_deployer);
97
+ }
98
+
79
99
  JB721TiersHook hook;
80
100
  {
81
101
  // Perform the check for the registry.
82
- (address _hook, bool _hookIsDeployed) = _isDeployed(
83
- HOOK_SALT,
84
- type(JB721TiersHook).creationCode,
85
- abi.encode(
86
- core.directory, core.permissions, core.prices, core.rulesets, store, core.splits, TRUSTED_FORWARDER
102
+ (address _hook, bool _hookIsDeployed) = _isDeployed({
103
+ salt: HOOK_SALT,
104
+ creationCode: type(JB721TiersHook).creationCode,
105
+ arguments: abi.encode(
106
+ core.directory,
107
+ core.permissions,
108
+ core.prices,
109
+ core.rulesets,
110
+ store,
111
+ core.splits,
112
+ checkpointsDeployer,
113
+ TRUSTED_FORWARDER
87
114
  )
88
- );
115
+ });
89
116
 
90
117
  // Deploy it if it has not been deployed yet.
91
118
  hook = !_hookIsDeployed
92
119
  ? new JB721TiersHook{salt: HOOK_SALT}(
93
- core.directory, core.permissions, core.prices, core.rulesets, store, core.splits, TRUSTED_FORWARDER
120
+ core.directory,
121
+ core.permissions,
122
+ core.prices,
123
+ core.rulesets,
124
+ store,
125
+ core.splits,
126
+ IJB721CheckpointsDeployer(address(checkpointsDeployer)),
127
+ TRUSTED_FORWARDER
94
128
  )
95
129
  : JB721TiersHook(_hook);
96
130
  }
@@ -98,11 +132,11 @@ contract DeployScript is Script, Sphinx {
98
132
  JB721TiersHookDeployer hookDeployer;
99
133
  {
100
134
  // Perform the check for the registry.
101
- (address _hookDeployer, bool _hookDeployerIsDeployed) = _isDeployed(
102
- HOOK_DEPLOYER_SALT,
103
- type(JB721TiersHookDeployer).creationCode,
104
- abi.encode(hook, store, registry.registry, TRUSTED_FORWARDER)
105
- );
135
+ (address _hookDeployer, bool _hookDeployerIsDeployed) = _isDeployed({
136
+ salt: HOOK_DEPLOYER_SALT,
137
+ creationCode: type(JB721TiersHookDeployer).creationCode,
138
+ arguments: abi.encode(hook, store, registry.registry, TRUSTED_FORWARDER)
139
+ });
106
140
 
107
141
  hookDeployer = !_hookDeployerIsDeployed
108
142
  ? new JB721TiersHookDeployer{salt: HOOK_DEPLOYER_SALT}(
@@ -114,11 +148,11 @@ contract DeployScript is Script, Sphinx {
114
148
  JB721TiersHookProjectDeployer projectDeployer;
115
149
  {
116
150
  // Perform the check for the registry.
117
- (address _projectDeployer, bool _projectDeployerIsdeployed) = _isDeployed(
118
- PROJECT_DEPLOYER_SALT,
119
- type(JB721TiersHookProjectDeployer).creationCode,
120
- abi.encode(core.directory, core.permissions, hookDeployer, TRUSTED_FORWARDER)
121
- );
151
+ (address _projectDeployer, bool _projectDeployerIsdeployed) = _isDeployed({
152
+ salt: PROJECT_DEPLOYER_SALT,
153
+ creationCode: type(JB721TiersHookProjectDeployer).creationCode,
154
+ arguments: abi.encode(core.directory, core.permissions, hookDeployer, TRUSTED_FORWARDER)
155
+ });
122
156
 
123
157
  projectDeployer = !_projectDeployerIsdeployed
124
158
  ? new JB721TiersHookProjectDeployer{salt: PROJECT_DEPLOYER_SALT}(
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
5
+ import {Votes} from "@openzeppelin/contracts/governance/utils/Votes.sol";
6
+ import {IJB721Checkpoints} from "./interfaces/IJB721Checkpoints.sol";
7
+ import {IJB721TiersHookStore} from "./interfaces/IJB721TiersHookStore.sol";
8
+
9
+ /// @title JB721Checkpoints
10
+ /// @notice Provides IVotes-compatible checkpointed voting power for a JB721TiersHook. Deployed as an EIP-1167 clone
11
+ /// via JB721CheckpointsDeployer — one module per hook. The hook calls `onTransfer` on every NFT transfer to
12
+ /// maintain accurate vote checkpoints.
13
+ /// @dev EIP712 on clones: OZ stores name/version as immutables (accessible via DELEGATECALL). The storage cache
14
+ /// (`_cachedThis`) is uninitialized on clones, so `domainSeparatorV4()` always rebuilds using the clone's
15
+ /// `address(this)` — correct behavior, tiny gas overhead.
16
+ contract JB721Checkpoints is Votes, IJB721Checkpoints {
17
+ //*********************************************************************//
18
+ // --------------------------- custom errors ------------------------- //
19
+ //*********************************************************************//
20
+
21
+ error JB721Checkpoints_AlreadyInitialized();
22
+ error JB721Checkpoints_Unauthorized();
23
+
24
+ //*********************************************************************//
25
+ // --------------------- private stored properties ------------------ //
26
+ //*********************************************************************//
27
+
28
+ /// @notice Whether this contract has been initialized.
29
+ bool private _initialized;
30
+
31
+ //*********************************************************************//
32
+ // ---------------------- public stored properties ------------------- //
33
+ //*********************************************************************//
34
+
35
+ /// @notice The hook that this module tracks voting power for.
36
+ address public override HOOK;
37
+
38
+ /// @notice The store that holds tier and voting data for the hook's NFTs.
39
+ IJB721TiersHookStore public override STORE;
40
+
41
+ //*********************************************************************//
42
+ // -------------------------- constructor ---------------------------- //
43
+ //*********************************************************************//
44
+
45
+ /// @dev Parameterless. The implementation contract is initialized in the constructor to prevent direct use.
46
+ /// Clones are initialized via `initialize()`.
47
+ constructor() EIP712("JB721Checkpoints", "1") {
48
+ _initialized = true;
49
+ }
50
+
51
+ //*********************************************************************//
52
+ // ---------------------- external transactions ---------------------- //
53
+ //*********************************************************************//
54
+
55
+ /// @notice Initializes a cloned module with its hook and store references.
56
+ /// @dev Can only be called once. Called by the deployer after cloning.
57
+ /// @param hook The hook this module serves.
58
+ /// @param store The store that holds tier data for the hook's NFTs.
59
+ function initialize(address hook, IJB721TiersHookStore store) external override {
60
+ if (_initialized) revert JB721Checkpoints_AlreadyInitialized();
61
+ _initialized = true;
62
+ HOOK = hook;
63
+ STORE = store;
64
+ }
65
+
66
+ /// @notice Called by the hook after every NFT transfer to update checkpointed voting power.
67
+ /// @dev Only callable by the HOOK. Looks up the token's tier voting units from the store.
68
+ /// @param from The previous owner (address(0) on mint).
69
+ /// @param to The new owner (address(0) on burn).
70
+ /// @param tokenId The token ID being transferred.
71
+ function onTransfer(address from, address to, uint256 tokenId) external override {
72
+ if (msg.sender != HOOK) revert JB721Checkpoints_Unauthorized();
73
+
74
+ // Look up this token's tier to get its voting units.
75
+ uint256 votingUnits = STORE.tierOfTokenId({hook: HOOK, tokenId: tokenId, includeResolvedUri: false}).votingUnits;
76
+
77
+ // Move checkpointed voting power from the previous owner to the new owner.
78
+ _transferVotingUnits({from: from, to: to, amount: votingUnits});
79
+ }
80
+
81
+ //*********************************************************************//
82
+ // ------------------------ internal functions ----------------------- //
83
+ //*********************************************************************//
84
+
85
+ /// @notice Returns the total voting units held by an account (across all tiers).
86
+ /// @dev Called by OZ Votes when re-delegating to compute the account's total voting units.
87
+ /// @param account The address to get the voting units of.
88
+ /// @return The total voting units the account holds.
89
+ function _getVotingUnits(address account) internal view override returns (uint256) {
90
+ return STORE.votingUnitsOf({hook: HOOK, account: account});
91
+ }
92
+ }
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {LibClone} from "solady/src/utils/LibClone.sol";
5
+ import {JB721Checkpoints} from "./JB721Checkpoints.sol";
6
+ import {IJB721Checkpoints} from "./interfaces/IJB721Checkpoints.sol";
7
+ import {IJB721CheckpointsDeployer} from "./interfaces/IJB721CheckpointsDeployer.sol";
8
+ import {IJB721TiersHookStore} from "./interfaces/IJB721TiersHookStore.sol";
9
+
10
+ /// @title JB721CheckpointsDeployer
11
+ /// @notice Deploys EIP-1167 clones of JB721Checkpoints for each JB721TiersHook instance.
12
+ /// @dev The implementation is deployed once in the constructor. Each `deploy()` call clones it (~45k gas) and
13
+ /// initializes the clone with the hook and store references.
14
+ contract JB721CheckpointsDeployer is IJB721CheckpointsDeployer {
15
+ //*********************************************************************//
16
+ // --------------- public immutable stored properties ---------------- //
17
+ //*********************************************************************//
18
+
19
+ /// @notice The checkpoint module implementation that clones delegate to.
20
+ address public immutable override IMPLEMENTATION;
21
+
22
+ //*********************************************************************//
23
+ // -------------------------- constructor ---------------------------- //
24
+ //*********************************************************************//
25
+
26
+ constructor() {
27
+ IMPLEMENTATION = address(new JB721Checkpoints());
28
+ }
29
+
30
+ //*********************************************************************//
31
+ // ---------------------- external transactions ---------------------- //
32
+ //*********************************************************************//
33
+
34
+ /// @notice Deploys a new deterministic checkpoint clone for the given hook.
35
+ /// @dev Uses CREATE2 with the hook address as salt so the clone address is the same across chains.
36
+ /// @param hook The hook address the module will serve.
37
+ /// @param store The store that holds tier data for the hook's NFTs.
38
+ /// @return module The newly deployed and initialized checkpoint module.
39
+ function deploy(address hook, IJB721TiersHookStore store) external override returns (IJB721Checkpoints module) {
40
+ module = IJB721Checkpoints(
41
+ LibClone.cloneDeterministic({implementation: IMPLEMENTATION, salt: bytes32(uint256(uint160(hook)))})
42
+ );
43
+ module.initialize({hook: hook, store: store});
44
+ }
45
+ }
@@ -19,9 +19,12 @@ import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol"
19
19
  import {Context} from "@openzeppelin/contracts/utils/Context.sol";
20
20
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
21
21
  import {JB721Hook} from "./abstract/JB721Hook.sol";
22
+ import {IJB721Checkpoints} from "./interfaces/IJB721Checkpoints.sol";
23
+ import {IJB721CheckpointsDeployer} from "./interfaces/IJB721CheckpointsDeployer.sol";
22
24
  import {IJB721TiersHook} from "./interfaces/IJB721TiersHook.sol";
23
25
  import {IJB721TiersHookStore} from "./interfaces/IJB721TiersHookStore.sol";
24
26
  import {IJB721TokenUriResolver} from "./interfaces/IJB721TokenUriResolver.sol";
27
+ import {JB721Constants} from "./libraries/JB721Constants.sol";
25
28
  import {JB721TiersHookLib} from "./libraries/JB721TiersHookLib.sol";
26
29
  import {JB721TiersRulesetMetadataResolver} from "./libraries/JB721TiersRulesetMetadataResolver.sol";
27
30
  import {JB721InitTiersConfig} from "./structs/JB721InitTiersConfig.sol";
@@ -42,7 +45,6 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
42
45
 
43
46
  error JB721TiersHook_AlreadyInitialized(uint256 projectId);
44
47
  error JB721TiersHook_CantBuyWithCredits();
45
- error JB721TiersHook_CurrencyMismatch(uint256 paymentCurrency, uint256 tierCurrency);
46
48
  error JB721TiersHook_InvalidPricingDecimals(uint256 decimals);
47
49
  error JB721TiersHook_MintReserveNftsPaused();
48
50
  error JB721TiersHook_NoProjectId();
@@ -65,6 +67,9 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
65
67
  /// @notice The contract that stores and manages splits.
66
68
  IJBSplits public immutable override SPLITS;
67
69
 
70
+ /// @notice The deployer used to deploy checkpoint module clones during initialization.
71
+ IJB721CheckpointsDeployer internal immutable CHECKPOINTS_DEPLOYER;
72
+
68
73
  //*********************************************************************//
69
74
  // --------------------- private stored properties ------------------ //
70
75
  //*********************************************************************//
@@ -104,6 +109,10 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
104
109
  /// - pricing decimals in bits 32-39 (8 bits).
105
110
  uint256 internal _packedPricingContext;
106
111
 
112
+ /// @notice The checkpoint module that manages IVotes-compatible checkpointed voting power for this hook's NFTs.
113
+ /// @dev Set once during `initialize()`. Pass this to JBTokenDistributor as the IVotes token.
114
+ IJB721Checkpoints public override CHECKPOINTS;
115
+
107
116
  //*********************************************************************//
108
117
  // -------------------------- constructor ---------------------------- //
109
118
  //*********************************************************************//
@@ -114,6 +123,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
114
123
  /// @param rulesets A contract storing and managing project rulesets.
115
124
  /// @param store The contract which stores the NFT's data.
116
125
  /// @param splits The contract that stores and manages splits.
126
+ /// @param checkpointsDeployer The deployer used to deploy checkpoint module clones during initialization.
117
127
  /// @param trustedForwarder The trusted forwarder for the ERC2771Context.
118
128
  constructor(
119
129
  IJBDirectory directory,
@@ -122,6 +132,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
122
132
  IJBRulesets rulesets,
123
133
  IJB721TiersHookStore store,
124
134
  IJBSplits splits,
135
+ IJB721CheckpointsDeployer checkpointsDeployer,
125
136
  address trustedForwarder
126
137
  )
127
138
  JBOwnable(permissions, directory.PROJECTS(), msg.sender, uint88(0))
@@ -132,6 +143,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
132
143
  RULESETS = rulesets;
133
144
  STORE = store;
134
145
  SPLITS = splits;
146
+ CHECKPOINTS_DEPLOYER = checkpointsDeployer;
135
147
 
136
148
  // Prevent the implementation contract from being initialized.
137
149
  _initialized = true;
@@ -146,14 +158,8 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
146
158
  /// @param tokenId The token ID of the NFT to get the first owner of.
147
159
  /// @return The address of the NFT's first owner.
148
160
  function firstOwnerOf(uint256 tokenId) external view override returns (address) {
149
- // Get a reference to the first owner.
150
- address storedFirstOwner = _firstOwnerOf[tokenId];
151
-
152
- // If the stored first owner is set, return it.
153
- if (storedFirstOwner != address(0)) return storedFirstOwner;
154
-
155
- // Otherwise, the first owner must be the current owner.
156
- return _ownerOf(tokenId);
161
+ address first = _firstOwnerOf[tokenId];
162
+ return first != address(0) ? first : _ownerOf(tokenId);
157
163
  }
158
164
 
159
165
  /// @notice Context for the pricing of this hook's tiers.
@@ -196,37 +202,24 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
196
202
  {
197
203
  hookSpecifications = new JBPayHookSpecification[](1);
198
204
 
199
- // Calculate per-tier split amounts via the library.
200
- (uint256 totalSplitAmount, bytes memory splitMetadata) = JB721TiersHookLib.calculateSplitAmounts({
201
- store: STORE, hook: address(this), metadataIdTarget: METADATA_ID_TARGET, metadata: context.metadata
202
- });
203
-
204
- // Convert split amounts from tier pricing to payment token denomination (if currencies differ)
205
- // and cap at the actual payment value so the terminal never forwards more than was paid.
206
- if (totalSplitAmount != 0) {
207
- (totalSplitAmount, splitMetadata) = JB721TiersHookLib.convertAndCapSplitAmounts({
208
- totalSplitAmount: totalSplitAmount,
209
- splitMetadata: splitMetadata,
210
- packedPricingContext: _packedPricingContext,
211
- prices: PRICES,
212
- projectId: context.projectId,
213
- amountCurrency: context.amount.currency,
214
- amountDecimals: context.amount.decimals,
215
- amountValue: context.amount.value
216
- });
217
- }
218
-
219
- // Adjust weight so the terminal mints tokens only for the amount that actually enters the project.
220
- weight = JB721TiersHookLib.calculateWeight({
221
- contextWeight: context.weight,
222
- amountValue: context.amount.value,
223
- totalSplitAmount: totalSplitAmount,
205
+ // Compute split amounts, adjusted weight, and resolved beneficiary in a single library call.
206
+ uint256 totalSplitAmount;
207
+ bytes memory splitMetadata;
208
+ address beneficiary;
209
+ (weight, totalSplitAmount, splitMetadata, beneficiary) = JB721TiersHookLib.computeSplitsAndWeight({
224
210
  store: STORE,
225
- hook: address(this)
211
+ metadataIdTarget: METADATA_ID_TARGET,
212
+ packedPricingContext: _packedPricingContext,
213
+ prices: PRICES,
214
+ context: context
226
215
  });
227
216
 
228
- hookSpecifications[0] =
229
- JBPayHookSpecification({hook: this, noop: false, amount: totalSplitAmount, metadata: splitMetadata});
217
+ hookSpecifications[0] = JBPayHookSpecification({
218
+ hook: this,
219
+ noop: false,
220
+ amount: totalSplitAmount,
221
+ metadata: abi.encode(beneficiary, context.payer, splitMetadata)
222
+ });
230
223
  }
231
224
 
232
225
  /// @notice The combined cash out weight of the NFTs with the specified token IDs.
@@ -314,6 +307,9 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
314
307
  || flags.preventOverspending || flags.issueTokensForSplits
315
308
  ) STORE.recordFlags(flags);
316
309
 
310
+ // Deploy the checkpoint module for IVotes-compatible voting power.
311
+ CHECKPOINTS = CHECKPOINTS_DEPLOYER.deploy({hook: address(this), store: STORE});
312
+
317
313
  // Transfer ownership to the initializer.
318
314
  _transferOwnership(_msgSender());
319
315
  }
@@ -321,7 +317,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
321
317
  /// @notice Indicates if this contract adheres to the specified interface.
322
318
  /// @dev See {IERC165-supportsInterface}.
323
319
  /// @param interfaceId The ID of the interface to check for adherence to.
324
- function supportsInterface(bytes4 interfaceId) public view override(IERC165, JB721Hook) returns (bool) {
320
+ function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, JB721Hook) returns (bool) {
325
321
  return interfaceId == type(IJB721TiersHook).interfaceId || JB721Hook.supportsInterface(interfaceId);
326
322
  }
327
323
 
@@ -579,13 +575,13 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
579
575
 
580
576
  /// @notice Returns the calldata, preferred to use over `msg.data`
581
577
  /// @return calldata the `msg.data` of this call
582
- function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) {
578
+ function _msgData() internal view virtual override(ERC2771Context, Context) returns (bytes calldata) {
583
579
  return ERC2771Context._msgData();
584
580
  }
585
581
 
586
582
  /// @notice Returns the sender, preferred to use over `msg.sender`
587
583
  /// @return sender the sender address of this call.
588
- function _msgSender() internal view override(ERC2771Context, Context) returns (address sender) {
584
+ function _msgSender() internal view virtual override(ERC2771Context, Context) returns (address sender) {
589
585
  return ERC2771Context._msgSender();
590
586
  }
591
587
 
@@ -636,97 +632,60 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
636
632
 
637
633
  /// @notice Mint NFTs from the specified tiers and update the beneficiary's pay credits.
638
634
  /// @param value The normalized payment value.
639
- /// @param context Payment context provided by the terminal.
640
- function _mintAndUpdateCredits(uint256 value, JBAfterPayRecordedContext calldata context) internal {
635
+ /// @param payer The address that initiated the payment.
636
+ /// @param payerMetadata The metadata provided by the payer.
637
+ /// @param beneficiary The address to mint NFTs to and track credits for.
638
+ function _mintAndUpdateCredits(
639
+ uint256 value,
640
+ address payer,
641
+ bytes calldata payerMetadata,
642
+ address beneficiary
643
+ )
644
+ internal
645
+ {
641
646
  // Keep a reference to the number of NFT credits the beneficiary already has.
642
- uint256 payCredits = payCreditsOf[context.beneficiary];
643
-
644
- // Set the leftover amount as the initial value.
645
- uint256 leftoverAmount = value;
646
-
647
- // If the payer is the beneficiary, combine their NFT credits with the amount paid.
648
- uint256 unusedPayCredits;
649
- if (context.payer == context.beneficiary) {
650
- leftoverAmount += payCredits;
651
- } else {
652
- // Otherwise, the payer's NFT credits won't be used, and we keep track of the unused credits.
653
- unusedPayCredits = payCredits;
654
- }
647
+ uint256 payCredits = payCreditsOf[beneficiary];
655
648
 
656
- // Keep a reference to the boolean indicating whether paying more than the price of the NFTs being minted
657
- // is allowed. Defaults to the collection's flag.
658
- bool allowOverspending = !STORE.flagsOf(address(this)).preventOverspending;
659
-
660
- // Resolve the metadata.
661
- (bool found, bytes memory metadata) = JBMetadataResolver.getDataFor({
662
- id: JBMetadataResolver.getId({purpose: "pay", target: METADATA_ID_TARGET}), metadata: context.payerMetadata
649
+ // Compute the mint: combine credits, decode metadata, record mint, and check overspending.
650
+ (uint256[] memory tokenIds, uint16[] memory tierIdsToMint, uint256 newPayCredits) = JB721TiersHookLib.prepareMint({
651
+ store: STORE,
652
+ metadataIdTarget: METADATA_ID_TARGET,
653
+ value: value,
654
+ payer: payer,
655
+ beneficiary: beneficiary,
656
+ payCredits: payCredits,
657
+ payerMetadata: payerMetadata
663
658
  });
664
659
 
665
- if (found) {
666
- // Keep a reference to the IDs of the tier be to minted.
667
- uint16[] memory tierIdsToMint;
668
-
669
- // Keep a reference to the payer's flag indicating whether overspending is allowed.
670
- bool payerAllowsOverspending;
671
-
672
- // Decode the metadata.
673
- (payerAllowsOverspending, tierIdsToMint) = abi.decode(metadata, (bool, uint16[]));
674
-
675
- // Make sure overspending is allowed if requested.
676
- if (allowOverspending && !payerAllowsOverspending) {
677
- allowOverspending = false;
678
- }
679
-
680
- // Mint NFTs from the tiers as specified.
681
- if (tierIdsToMint.length != 0) {
682
- uint256[] memory tokenIds;
683
- uint256 restrictedCost;
684
- uint256 totalAmountPaid = leftoverAmount;
685
-
686
- // Record the mints.
687
- // slither-disable-next-line reentrancy-events,reentrancy-no-eth
688
- (tokenIds, leftoverAmount, restrictedCost) =
689
- STORE.recordMint({amount: leftoverAmount, tierIds: tierIdsToMint, isOwnerMint: false});
690
-
691
- // Enforce `cantBuyWithCredits`: only tiers explicitly configured as credit-restricted must be fully
692
- // covered by fresh payment (not stored credits). Split-bearing tiers are not automatically restricted;
693
- // deployers must set that flag in tier configuration when they need that invariant.
694
- if (restrictedCost > value) revert JB721TiersHook_CantBuyWithCredits();
695
-
696
- // Mint each token.
697
- _mintTokens({
698
- tokenIds: tokenIds,
699
- tierIds: tierIdsToMint,
700
- beneficiary: context.beneficiary,
701
- totalAmountPaid: totalAmountPaid
702
- });
703
- }
660
+ // Mint each token to the effective beneficiary.
661
+ if (tokenIds.length != 0) {
662
+ // totalAmountPaid is the full amount available before recordMint deducted tier prices.
663
+ uint256 totalAmountPaid = (payer == beneficiary) ? value + payCredits : value;
664
+ _mintTokens({
665
+ tokenIds: tokenIds, tierIds: tierIdsToMint, beneficiary: beneficiary, totalAmountPaid: totalAmountPaid
666
+ });
704
667
  }
705
668
 
706
- // If overspending isn't allowed, revert.
707
- if (leftoverAmount != 0 && !allowOverspending) revert JB721TiersHook_Overspending(leftoverAmount);
708
-
709
669
  // Update NFT credits if they changed.
710
- uint256 newPayCredits = leftoverAmount + unusedPayCredits;
711
-
712
670
  if (newPayCredits != payCredits) {
713
671
  if (newPayCredits > payCredits) {
714
672
  emit AddPayCredits({
715
673
  amount: newPayCredits - payCredits,
716
674
  newTotalCredits: newPayCredits,
717
- account: context.beneficiary,
675
+ account: beneficiary,
718
676
  caller: _msgSender()
719
677
  });
720
678
  } else {
721
679
  emit UsePayCredits({
722
680
  amount: payCredits - newPayCredits,
723
681
  newTotalCredits: newPayCredits,
724
- account: context.beneficiary,
682
+ account: beneficiary,
725
683
  caller: _msgSender()
726
684
  });
727
685
  }
728
686
 
729
- payCreditsOf[context.beneficiary] = newPayCredits;
687
+ // slither-disable-next-line reentrancy-no-eth
688
+ payCreditsOf[beneficiary] = newPayCredits;
730
689
  }
731
690
  }
732
691
 
@@ -749,11 +708,24 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
749
708
  });
750
709
  if (!valid) return;
751
710
 
711
+ // Decode the beneficiary and payer forwarded from beforePayRecordedWith.
712
+ address beneficiary;
713
+ address payer;
714
+ bytes memory splitData;
715
+ if (context.hookMetadata.length != 0) {
716
+ (beneficiary, payer, splitData) = abi.decode(context.hookMetadata, (address, address, bytes));
717
+ }
718
+ // Fall back to context values if none were forwarded.
719
+ if (beneficiary == address(0)) beneficiary = context.beneficiary;
720
+ if (payer == address(0)) payer = context.payer;
721
+
752
722
  // Mint NFTs from the specified tiers and update the beneficiary's pay credits.
753
- _mintAndUpdateCredits({value: value, context: context});
723
+ _mintAndUpdateCredits({
724
+ value: value, payer: payer, payerMetadata: context.payerMetadata, beneficiary: beneficiary
725
+ });
754
726
 
755
727
  // Distribute any forwarded funds to tier split groups.
756
- if (context.hookMetadata.length != 0 && context.forwardedAmount.value != 0) {
728
+ if (splitData.length != 0 && context.forwardedAmount.value != 0) {
757
729
  JB721TiersHookLib.distributeAll({
758
730
  directory: DIRECTORY,
759
731
  splits: SPLITS,
@@ -762,7 +734,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
762
734
  token: context.forwardedAmount.token,
763
735
  amount: context.forwardedAmount.value,
764
736
  decimals: context.forwardedAmount.decimals,
765
- encodedSplitData: context.hookMetadata
737
+ encodedSplitData: splitData
766
738
  });
767
739
  }
768
740
  }
@@ -779,11 +751,10 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
779
751
  /// @param tierId The ID of the tier to set the discount percent for.
780
752
  /// @param discountPercent The discount percent to set for the tier.
781
753
  function _setDiscountPercentOf(uint256 tierId, uint256 discountPercent) internal {
782
- emit SetDiscountPercent({tierId: tierId, discountPercent: discountPercent, caller: _msgSender()});
783
-
784
- // Record the discount percent for the tier.
785
754
  // slither-disable-next-line calls-loop
786
- STORE.recordSetDiscountPercentOf({tierId: tierId, discountPercent: discountPercent});
755
+ JB721TiersHookLib.setDiscountPercentOf({
756
+ store: STORE, tierId: tierId, discountPercent: discountPercent, caller: _msgSender()
757
+ });
787
758
  }
788
759
 
789
760
  /// @notice Before transferring an NFT, register its first owner (if necessary).
@@ -822,5 +793,8 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
822
793
  // Record the transfer.
823
794
  // slither-disable-next-line reentrency-events,calls-loop
824
795
  STORE.recordTransferForTier({tierId: tierId, from: from, to: to});
796
+
797
+ // Notify the checkpoint module to update checkpointed voting power.
798
+ CHECKPOINTS.onTransfer({from: from, to: to, tokenId: tokenId});
825
799
  }
826
800
  }