@bananapus/721-hook-v6 0.0.32 → 0.0.33

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 (33) 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 +68 -32
  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/JB721TiersHookLib.sol +53 -2
  12. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +11 -1
  13. package/test/Fork.t.sol +11 -2
  14. package/test/TestAuditGaps.sol +1 -1
  15. package/test/TestCheckpoints.t.sol +329 -0
  16. package/test/audit/CodexNemesisRepoFindings.t.sol +270 -0
  17. package/test/audit/CodexRetroactiveReserveBeneficiaryDilution.t.sol +161 -0
  18. package/test/audit/CodexSplitCreditsMismatch.t.sol +2 -1
  19. package/test/audit/CrossCurrencySplitNoPrices.t.sol +1 -0
  20. package/test/audit/SameCurrencyDecimalMismatch.t.sol +249 -0
  21. package/test/audit/SplitFailureRedistribution.t.sol +2 -1
  22. package/test/fork/ERC20CashOutFork.t.sol +11 -2
  23. package/test/fork/ERC20TierSplitFork.t.sol +11 -2
  24. package/test/fork/IssueTokensForSplitsFork.t.sol +11 -2
  25. package/test/regression/BrokenTerminalDoesNotDos.t.sol +2 -2
  26. package/test/regression/SplitDistributionBugs.t.sol +5 -5
  27. package/test/regression/SplitNoBeneficiary.t.sol +1 -1
  28. package/test/unit/AuditFixes_Unit.t.sol +5 -5
  29. package/test/unit/pay_CrossCurrency_Unit.t.sol +1 -0
  30. package/test/unit/pay_Unit.t.sol +1 -0
  31. package/test/unit/redeem_Unit.t.sol +3 -3
  32. package/test/unit/splitHookDistribution_Unit.t.sol +6 -6
  33. 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.33",
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,6 +19,8 @@ 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";
@@ -42,7 +44,6 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
42
44
 
43
45
  error JB721TiersHook_AlreadyInitialized(uint256 projectId);
44
46
  error JB721TiersHook_CantBuyWithCredits();
45
- error JB721TiersHook_CurrencyMismatch(uint256 paymentCurrency, uint256 tierCurrency);
46
47
  error JB721TiersHook_InvalidPricingDecimals(uint256 decimals);
47
48
  error JB721TiersHook_MintReserveNftsPaused();
48
49
  error JB721TiersHook_NoProjectId();
@@ -65,6 +66,9 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
65
66
  /// @notice The contract that stores and manages splits.
66
67
  IJBSplits public immutable override SPLITS;
67
68
 
69
+ /// @notice The deployer used to deploy checkpoint module clones during initialization.
70
+ IJB721CheckpointsDeployer internal immutable CHECKPOINTS_DEPLOYER;
71
+
68
72
  //*********************************************************************//
69
73
  // --------------------- private stored properties ------------------ //
70
74
  //*********************************************************************//
@@ -104,6 +108,10 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
104
108
  /// - pricing decimals in bits 32-39 (8 bits).
105
109
  uint256 internal _packedPricingContext;
106
110
 
111
+ /// @notice The checkpoint module that manages IVotes-compatible checkpointed voting power for this hook's NFTs.
112
+ /// @dev Set once during `initialize()`. Pass this to JBTokenDistributor as the IVotes token.
113
+ IJB721Checkpoints public override CHECKPOINTS;
114
+
107
115
  //*********************************************************************//
108
116
  // -------------------------- constructor ---------------------------- //
109
117
  //*********************************************************************//
@@ -114,6 +122,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
114
122
  /// @param rulesets A contract storing and managing project rulesets.
115
123
  /// @param store The contract which stores the NFT's data.
116
124
  /// @param splits The contract that stores and manages splits.
125
+ /// @param checkpointsDeployer The deployer used to deploy checkpoint module clones during initialization.
117
126
  /// @param trustedForwarder The trusted forwarder for the ERC2771Context.
118
127
  constructor(
119
128
  IJBDirectory directory,
@@ -122,6 +131,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
122
131
  IJBRulesets rulesets,
123
132
  IJB721TiersHookStore store,
124
133
  IJBSplits splits,
134
+ IJB721CheckpointsDeployer checkpointsDeployer,
125
135
  address trustedForwarder
126
136
  )
127
137
  JBOwnable(permissions, directory.PROJECTS(), msg.sender, uint88(0))
@@ -132,6 +142,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
132
142
  RULESETS = rulesets;
133
143
  STORE = store;
134
144
  SPLITS = splits;
145
+ CHECKPOINTS_DEPLOYER = checkpointsDeployer;
135
146
 
136
147
  // Prevent the implementation contract from being initialized.
137
148
  _initialized = true;
@@ -146,14 +157,8 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
146
157
  /// @param tokenId The token ID of the NFT to get the first owner of.
147
158
  /// @return The address of the NFT's first owner.
148
159
  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);
160
+ address first = _firstOwnerOf[tokenId];
161
+ return first != address(0) ? first : _ownerOf(tokenId);
157
162
  }
158
163
 
159
164
  /// @notice Context for the pricing of this hook's tiers.
@@ -225,8 +230,12 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
225
230
  hook: address(this)
226
231
  });
227
232
 
228
- hookSpecifications[0] =
229
- JBPayHookSpecification({hook: this, noop: false, amount: totalSplitAmount, metadata: splitMetadata});
233
+ hookSpecifications[0] = JBPayHookSpecification({
234
+ hook: this,
235
+ noop: false,
236
+ amount: totalSplitAmount,
237
+ metadata: abi.encode(context.beneficiary, context.payer, splitMetadata)
238
+ });
230
239
  }
231
240
 
232
241
  /// @notice The combined cash out weight of the NFTs with the specified token IDs.
@@ -314,6 +323,9 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
314
323
  || flags.preventOverspending || flags.issueTokensForSplits
315
324
  ) STORE.recordFlags(flags);
316
325
 
326
+ // Deploy the checkpoint module for IVotes-compatible voting power.
327
+ CHECKPOINTS = CHECKPOINTS_DEPLOYER.deploy({hook: address(this), store: STORE});
328
+
317
329
  // Transfer ownership to the initializer.
318
330
  _transferOwnership(_msgSender());
319
331
  }
@@ -321,7 +333,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
321
333
  /// @notice Indicates if this contract adheres to the specified interface.
322
334
  /// @dev See {IERC165-supportsInterface}.
323
335
  /// @param interfaceId The ID of the interface to check for adherence to.
324
- function supportsInterface(bytes4 interfaceId) public view override(IERC165, JB721Hook) returns (bool) {
336
+ function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, JB721Hook) returns (bool) {
325
337
  return interfaceId == type(IJB721TiersHook).interfaceId || JB721Hook.supportsInterface(interfaceId);
326
338
  }
327
339
 
@@ -579,13 +591,13 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
579
591
 
580
592
  /// @notice Returns the calldata, preferred to use over `msg.data`
581
593
  /// @return calldata the `msg.data` of this call
582
- function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) {
594
+ function _msgData() internal view virtual override(ERC2771Context, Context) returns (bytes calldata) {
583
595
  return ERC2771Context._msgData();
584
596
  }
585
597
 
586
598
  /// @notice Returns the sender, preferred to use over `msg.sender`
587
599
  /// @return sender the sender address of this call.
588
- function _msgSender() internal view override(ERC2771Context, Context) returns (address sender) {
600
+ function _msgSender() internal view virtual override(ERC2771Context, Context) returns (address sender) {
589
601
  return ERC2771Context._msgSender();
590
602
  }
591
603
 
@@ -636,17 +648,26 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
636
648
 
637
649
  /// @notice Mint NFTs from the specified tiers and update the beneficiary's pay credits.
638
650
  /// @param value The normalized payment value.
639
- /// @param context Payment context provided by the terminal.
640
- function _mintAndUpdateCredits(uint256 value, JBAfterPayRecordedContext calldata context) internal {
651
+ /// @param payer The address that initiated the payment.
652
+ /// @param payerMetadata The metadata provided by the payer.
653
+ /// @param beneficiary The address to mint NFTs to and track credits for.
654
+ function _mintAndUpdateCredits(
655
+ uint256 value,
656
+ address payer,
657
+ bytes calldata payerMetadata,
658
+ address beneficiary
659
+ )
660
+ internal
661
+ {
641
662
  // Keep a reference to the number of NFT credits the beneficiary already has.
642
- uint256 payCredits = payCreditsOf[context.beneficiary];
663
+ uint256 payCredits = payCreditsOf[beneficiary];
643
664
 
644
665
  // Set the leftover amount as the initial value.
645
666
  uint256 leftoverAmount = value;
646
667
 
647
- // If the payer is the beneficiary, combine their NFT credits with the amount paid.
668
+ // If the payer is the effective beneficiary, combine their NFT credits with the amount paid.
648
669
  uint256 unusedPayCredits;
649
- if (context.payer == context.beneficiary) {
670
+ if (payer == beneficiary) {
650
671
  leftoverAmount += payCredits;
651
672
  } else {
652
673
  // Otherwise, the payer's NFT credits won't be used, and we keep track of the unused credits.
@@ -659,7 +680,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
659
680
 
660
681
  // Resolve the metadata.
661
682
  (bool found, bytes memory metadata) = JBMetadataResolver.getDataFor({
662
- id: JBMetadataResolver.getId({purpose: "pay", target: METADATA_ID_TARGET}), metadata: context.payerMetadata
683
+ id: JBMetadataResolver.getId({purpose: "pay", target: METADATA_ID_TARGET}), metadata: payerMetadata
663
684
  });
664
685
 
665
686
  if (found) {
@@ -693,11 +714,11 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
693
714
  // deployers must set that flag in tier configuration when they need that invariant.
694
715
  if (restrictedCost > value) revert JB721TiersHook_CantBuyWithCredits();
695
716
 
696
- // Mint each token.
717
+ // Mint each token to the effective beneficiary.
697
718
  _mintTokens({
698
719
  tokenIds: tokenIds,
699
720
  tierIds: tierIdsToMint,
700
- beneficiary: context.beneficiary,
721
+ beneficiary: beneficiary,
701
722
  totalAmountPaid: totalAmountPaid
702
723
  });
703
724
  }
@@ -714,19 +735,19 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
714
735
  emit AddPayCredits({
715
736
  amount: newPayCredits - payCredits,
716
737
  newTotalCredits: newPayCredits,
717
- account: context.beneficiary,
738
+ account: beneficiary,
718
739
  caller: _msgSender()
719
740
  });
720
741
  } else {
721
742
  emit UsePayCredits({
722
743
  amount: payCredits - newPayCredits,
723
744
  newTotalCredits: newPayCredits,
724
- account: context.beneficiary,
745
+ account: beneficiary,
725
746
  caller: _msgSender()
726
747
  });
727
748
  }
728
749
 
729
- payCreditsOf[context.beneficiary] = newPayCredits;
750
+ payCreditsOf[beneficiary] = newPayCredits;
730
751
  }
731
752
  }
732
753
 
@@ -749,11 +770,24 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
749
770
  });
750
771
  if (!valid) return;
751
772
 
773
+ // Decode the beneficiary and payer forwarded from beforePayRecordedWith.
774
+ address beneficiary;
775
+ address payer;
776
+ bytes memory splitData;
777
+ if (context.hookMetadata.length != 0) {
778
+ (beneficiary, payer, splitData) = abi.decode(context.hookMetadata, (address, address, bytes));
779
+ }
780
+ // Fall back to context values if none were forwarded.
781
+ if (beneficiary == address(0)) beneficiary = context.beneficiary;
782
+ if (payer == address(0)) payer = context.payer;
783
+
752
784
  // Mint NFTs from the specified tiers and update the beneficiary's pay credits.
753
- _mintAndUpdateCredits({value: value, context: context});
785
+ _mintAndUpdateCredits({
786
+ value: value, payer: payer, payerMetadata: context.payerMetadata, beneficiary: beneficiary
787
+ });
754
788
 
755
789
  // Distribute any forwarded funds to tier split groups.
756
- if (context.hookMetadata.length != 0 && context.forwardedAmount.value != 0) {
790
+ if (splitData.length != 0 && context.forwardedAmount.value != 0) {
757
791
  JB721TiersHookLib.distributeAll({
758
792
  directory: DIRECTORY,
759
793
  splits: SPLITS,
@@ -762,7 +796,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
762
796
  token: context.forwardedAmount.token,
763
797
  amount: context.forwardedAmount.value,
764
798
  decimals: context.forwardedAmount.decimals,
765
- encodedSplitData: context.hookMetadata
799
+ encodedSplitData: splitData
766
800
  });
767
801
  }
768
802
  }
@@ -779,11 +813,10 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
779
813
  /// @param tierId The ID of the tier to set the discount percent for.
780
814
  /// @param discountPercent The discount percent to set for the tier.
781
815
  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
816
  // slither-disable-next-line calls-loop
786
- STORE.recordSetDiscountPercentOf({tierId: tierId, discountPercent: discountPercent});
817
+ JB721TiersHookLib.setDiscountPercentOf({
818
+ store: STORE, tierId: tierId, discountPercent: discountPercent, caller: _msgSender()
819
+ });
787
820
  }
788
821
 
789
822
  /// @notice Before transferring an NFT, register its first owner (if necessary).
@@ -822,5 +855,8 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
822
855
  // Record the transfer.
823
856
  // slither-disable-next-line reentrency-events,calls-loop
824
857
  STORE.recordTransferForTier({tierId: tierId, from: from, to: to});
858
+
859
+ // Notify the checkpoint module to update checkpointed voting power.
860
+ CHECKPOINTS.onTransfer({from: from, to: to, tokenId: tokenId});
825
861
  }
826
862
  }
@@ -77,6 +77,7 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
77
77
  /// @return cashOutTaxRate The cash out tax rate influencing the reclaim amount.
78
78
  /// @return cashOutCount The amount of tokens that should be considered cashed out.
79
79
  /// @return totalSupply The total amount of tokens that are considered to be existing.
80
+ /// @return effectiveSurplusValue The surplus to use for reclaim calculation.
80
81
  /// @return hookSpecifications The amount and data to send to cash out hooks (this contract) instead of returning to
81
82
  /// the beneficiary.
82
83
  function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context)
@@ -88,6 +89,7 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
88
89
  uint256 cashOutTaxRate,
89
90
  uint256 cashOutCount,
90
91
  uint256 totalSupply,
92
+ uint256 effectiveSurplusValue,
91
93
  JBCashOutHookSpecification[] memory hookSpecifications
92
94
  )
93
95
  {
@@ -114,6 +116,9 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
114
116
  // Use the total cash out weight of the 721s.
115
117
  totalSupply = totalCashOutWeight();
116
118
 
119
+ // Use the surplus from the context.
120
+ effectiveSurplusValue = context.surplus.value;
121
+
117
122
  // Use the cash out tax rate from the context.
118
123
  cashOutTaxRate = context.cashOutTaxRate;
119
124
  }
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ import {IERC5805} from "@openzeppelin/contracts/interfaces/IERC5805.sol";
5
+ import {IJB721TiersHookStore} from "./IJB721TiersHookStore.sol";
6
+
7
+ /// @notice A checkpoint module that provides IVotes-compatible checkpointed voting power for a JB721TiersHook.
8
+ /// @dev Deployed as a clone via JB721CheckpointsDeployer during hook initialization. One module per hook.
9
+ /// Pass this address to JBTokenDistributor as the IVotes token.
10
+ interface IJB721Checkpoints is IERC5805 {
11
+ /// @notice Called by the hook after every NFT transfer to update checkpointed voting power.
12
+ /// @dev Looks up the token's tier voting units from the store internally.
13
+ /// Auto-self-delegates on first receive so checkpoints work without manual delegation.
14
+ /// @param from The previous owner (address(0) on mint).
15
+ /// @param to The new owner (address(0) on burn).
16
+ /// @param tokenId The token ID being transferred (used to look up tier voting units).
17
+ function onTransfer(address from, address to, uint256 tokenId) external;
18
+
19
+ /// @notice Initializes a cloned module with its hook and store references.
20
+ /// @dev Can only be called once. Called by the deployer after cloning.
21
+ /// @param hook The hook this module serves.
22
+ /// @param store The store that holds tier data for the hook's NFTs.
23
+ function initialize(address hook, IJB721TiersHookStore store) external;
24
+
25
+ /// @notice The hook that this module tracks voting power for.
26
+ /// @return The hook address.
27
+ // forge-lint: disable-next-line(mixed-case-function)
28
+ function HOOK() external view returns (address);
29
+
30
+ /// @notice The store that holds tier and voting data for the hook's NFTs.
31
+ /// @return The store contract.
32
+ // forge-lint: disable-next-line(mixed-case-function)
33
+ function STORE() external view returns (IJB721TiersHookStore);
34
+ }
@@ -0,0 +1,20 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ import {IJB721Checkpoints} from "./IJB721Checkpoints.sol";
5
+ import {IJB721TiersHookStore} from "./IJB721TiersHookStore.sol";
6
+
7
+ /// @notice Deploys JB721Checkpoints clones for JB721TiersHook instances.
8
+ interface IJB721CheckpointsDeployer {
9
+ /// @notice The implementation contract that clones are based on.
10
+ /// @return The implementation address.
11
+ // forge-lint: disable-next-line(mixed-case-function)
12
+ function IMPLEMENTATION() external view returns (address);
13
+
14
+ /// @notice Deploys a new deterministic checkpoint clone for the given hook.
15
+ /// @dev Uses CREATE2 with the hook address as salt so the clone address is the same across chains.
16
+ /// @param hook The hook address the module will serve.
17
+ /// @param store The store that holds tier data for the hook's NFTs.
18
+ /// @return module The newly deployed and initialized checkpoint module.
19
+ function deploy(address hook, IJB721TiersHookStore store) external returns (IJB721Checkpoints module);
20
+ }