@bananapus/721-hook-v6 0.0.61 → 0.0.63

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/README.md CHANGED
@@ -34,6 +34,7 @@ This repo does more than "mint NFTs on pay." It changes how payment value, tier
34
34
  | `JB721TiersHookDeployer` | Clone factory for deploying a hook for an existing project. |
35
35
  | `JB721TiersHookProjectDeployer` | Convenience deployer for launching a project with a hook already wired in. |
36
36
  | `JB721Hook` | Abstract base for 721 pay and cash-out hook behavior. |
37
+ | `JB721Checkpoints` | Per-hook IVotes checkpoint module. Tracks historical owner checkpoints plus per-tier eligible voting units (`getPastTierVotingUnits`) for tier-scoped reward distribution. |
37
38
 
38
39
  ## Mental Model
39
40
 
@@ -59,6 +60,7 @@ If a bug affects supply, reserve minting, or tier lookup, it usually lives in th
59
60
  - custom token URI resolvers should be treated as part of the trusted surface
60
61
  - adding a 721 hook through a deployer is easy; carrying the right ruleset behavior forward is where mistakes happen
61
62
  - projects should be explicit about whether the hook affects pay, cash out, or only metadata-facing paths
63
+ - per-tier eligible voting units are queryable via `getPastTierVotingUnits(tierId, blockNumber)` for tier-scoped reward denominators, but minting alone does not enroll a token: a token only counts toward that total once it is enrolled (`delegate(address, uint256[])`) or transferred for the first time, and stops counting when burned
62
64
 
63
65
  ## Where State Lives
64
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.61",
3
+ "version": "0.0.63",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,6 +12,7 @@
12
12
  - If you edit tier config or metadata behavior, inspect the corresponding structs and interfaces in `src/structs/` and `src/interfaces/`.
13
13
  - If you edit reserve behavior, verify pending reserve counts, default reserve beneficiary semantics, and cash-out denominator effects together.
14
14
  - If you edit discount behavior, verify mint price and cash-out weight separately. They are intentionally not the same quantity.
15
+ - If you touch checkpoint, `onTransfer`, or `delegate` behavior, verify the per-tier eligible-voting-units trace (`_tierEligibleUnitsOf`, read via `getPastTierVotingUnits`) still moves only on eligibility changes: increment on enrollment or a token's first transfer, decrement on burn, and nothing on mint (so the mint path keeps its zero added checkpoint gas). Keep it in lockstep with `ownerOfAt` eligibility.
15
16
  - If you touch permissions, verify the caller path and permission constants still line up with the downstream ecosystem package that defines them.
16
17
  - If you touch URI behavior, confirm whether the issue belongs in this repo or in a downstream resolver contract that the hook calls.
17
18
 
@@ -50,6 +50,12 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
50
50
  /// @custom:param tokenId The token ID to get historical owner checkpoints for.
51
51
  mapping(uint256 tokenId => Checkpoints.Trace160) internal _ownerCheckpointsOf;
52
52
 
53
+ /// @notice Checkpointed total eligible voting units per tier. A token contributes its tier voting units from the
54
+ /// block it first gains an owner checkpoint (enrollment or first transfer) until it is burned. Mints write
55
+ /// nothing, mirroring `_ownerCheckpointsOf` eligibility. Distributors read this as the tier-scoped denominator.
56
+ /// @custom:param tierId The tier to get the historical eligible voting units for.
57
+ mapping(uint256 tierId => Checkpoints.Trace160) internal _tierEligibleUnitsOf;
58
+
53
59
  //*********************************************************************//
54
60
  // -------------------------- constructor ---------------------------- //
55
61
  //*********************************************************************//
@@ -85,10 +91,15 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
85
91
  revert JB721Checkpoints_NotOwner({tokenId: tokenId, caller: msg.sender});
86
92
  }
87
93
 
88
- // Write an owner checkpoint if the token has none yet.
94
+ // Write an owner checkpoint if the token has none yet, and enroll its tier voting units.
89
95
  if (_ownerCheckpointsOf[tokenId].length() == 0) {
90
96
  // forge-lint: disable-next-line(unsafe-typecast)
91
97
  _ownerCheckpointsOf[tokenId].push({key: uint96(block.number), value: uint160(msg.sender)});
98
+ _updateTierEligibleUnits({
99
+ tierId: STORE.tierIdOfToken(tokenId),
100
+ amount: STORE.tierVotingUnitsOfTokenId({hook: hook, tokenId: tokenId}),
101
+ increase: true
102
+ });
92
103
  }
93
104
 
94
105
  unchecked {
@@ -114,14 +125,31 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
114
125
  function onTransfer(address from, address to, uint256 tokenId) external override {
115
126
  if (msg.sender != hook) revert JB721Checkpoints_Unauthorized({caller: msg.sender, hook: hook});
116
127
 
128
+ // Look up this token's tier voting units (lightweight getter — avoids full tier struct construction).
129
+ uint256 votingUnits = STORE.tierVotingUnitsOfTokenId({hook: hook, tokenId: tokenId});
130
+
131
+ // On mint (`from == 0`) nothing is checkpointed: the token is ineligible until enrolled or transferred,
132
+ // so neither the owner trace nor the per-tier eligible-units trace is written.
117
133
  if (from != address(0)) {
134
+ Checkpoints.Trace160 storage ownerTrace = _ownerCheckpointsOf[tokenId];
135
+ bool wasEligible = ownerTrace.length() != 0;
136
+
118
137
  // forge-lint: disable-next-line(unsafe-typecast)
119
- _ownerCheckpointsOf[tokenId].push({key: uint96(block.number), value: uint160(to)});
138
+ ownerTrace.push({key: uint96(block.number), value: uint160(to)});
139
+
140
+ if (to == address(0)) {
141
+ // Burn: remove the tier's units only if the token was already eligible.
142
+ if (wasEligible) {
143
+ _updateTierEligibleUnits({
144
+ tierId: STORE.tierIdOfToken(tokenId), amount: votingUnits, increase: false
145
+ });
146
+ }
147
+ } else if (!wasEligible) {
148
+ // First transfer of a never-enrolled token makes it eligible: add the tier's units.
149
+ _updateTierEligibleUnits({tierId: STORE.tierIdOfToken(tokenId), amount: votingUnits, increase: true});
150
+ }
120
151
  }
121
152
 
122
- // Look up this token's tier to get its voting units.
123
- uint256 votingUnits = STORE.tierOfTokenId({hook: hook, tokenId: tokenId, includeResolvedUri: false}).votingUnits;
124
-
125
153
  // Move checkpointed voting power from the previous owner to the new owner.
126
154
  _transferVotingUnits({from: from, to: to, amount: votingUnits});
127
155
  }
@@ -130,6 +158,12 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
130
158
  // ----------------------- external views ---------------------------- //
131
159
  //*********************************************************************//
132
160
 
161
+ /// @inheritdoc IJB721Checkpoints
162
+ function getPastTierVotingUnits(uint256 tierId, uint256 blockNumber) external view override returns (uint256) {
163
+ // forge-lint: disable-next-line(unsafe-typecast)
164
+ return _tierEligibleUnitsOf[tierId].upperLookupRecent(uint96(_validateTimepoint(blockNumber)));
165
+ }
166
+
133
167
  /// @notice The owner of an NFT at a past block.
134
168
  /// @dev Returns `address(0)` for tokens that have never been enrolled (via `delegate(address, uint256[])`) or
135
169
  /// transferred. Unenrolled tokens are ineligible for snapshot-based distribution.
@@ -163,4 +197,19 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
163
197
  function _getVotingUnits(address account) internal view override returns (uint256) {
164
198
  return STORE.votingUnitsOf({hook: hook, account: account});
165
199
  }
200
+
201
+ //*********************************************************************//
202
+ // ------------------------ private helpers -------------------------- //
203
+ //*********************************************************************//
204
+
205
+ /// @notice Add or remove units from a tier's eligible-voting-units checkpoint at the current block.
206
+ /// @param tierId The tier whose eligible-voting-units trace to update.
207
+ /// @param amount The voting units to add or remove.
208
+ /// @param increase Whether to add `amount`; if false, `amount` is removed.
209
+ function _updateTierEligibleUnits(uint256 tierId, uint256 amount, bool increase) private {
210
+ Checkpoints.Trace160 storage trace = _tierEligibleUnitsOf[tierId];
211
+ uint256 updated = increase ? trace.latest() + amount : trace.latest() - amount;
212
+ // forge-lint: disable-next-line(unsafe-typecast)
213
+ trace.push({key: uint96(block.number), value: uint160(updated)});
214
+ }
166
215
  }
@@ -261,6 +261,24 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
261
261
  transfersPausable = (storedTier.packedBools & 0x2) != 0;
262
262
  }
263
263
 
264
+ /// @notice Get only the per-unit voting value for a token's tier, avoiding full struct construction.
265
+ /// @dev Mirrors the `votingUnits` field computed in `_getTierFrom`: a tier with custom voting units configured
266
+ /// (the `useVotingUnits` flag, bit 2 of `packedBools`) contributes its stored voting units per NFT, otherwise it
267
+ /// contributes its price per NFT. Multiply by an account's balance in the tier to get its voting power there.
268
+ /// @param hook The 721 hook contract that the token belongs to.
269
+ /// @param tokenId The token ID.
270
+ /// @return The per-unit voting value for the token's tier.
271
+ function tierVotingUnitsOfTokenId(address hook, uint256 tokenId) external view override returns (uint256) {
272
+ // Get a reference to the tier's ID.
273
+ uint256 tierId = tierIdOfToken(tokenId);
274
+
275
+ // Keep a reference to the stored tier.
276
+ JBStored721Tier memory storedTier = _storedTierOf[hook][tierId];
277
+
278
+ // Bit 2 (0-indexed) of packedBools is useVotingUnits. Use custom voting units if set, otherwise the price.
279
+ return (storedTier.packedBools & 0x4 != 0) ? _tierVotingUnitsOf[hook][tierId] : storedTier.price;
280
+ }
281
+
264
282
  /// @notice Get all active (non-removed) tiers for a hook, with optional filtering by category and pagination.
265
283
  /// Tiers are returned sorted by category.
266
284
  /// @param hook The 721 hook contract to get tiers from.
@@ -8,6 +8,15 @@ import {IJB721TiersHookStore} from "./IJB721TiersHookStore.sol";
8
8
  /// @dev Deployed as a clone via JB721CheckpointsDeployer during hook initialization. One module per hook.
9
9
  /// Pass this address to JBTokenDistributor as the IVotes token.
10
10
  interface IJB721Checkpoints is IERC5805 {
11
+ /// @notice The total eligible voting units of a tier at a past block.
12
+ /// @dev "Eligible" means a token has an owner checkpoint: it was enrolled via `delegate(address,uint256[])` or
13
+ /// transferred at least once. Minted-but-unenrolled tokens are excluded, mirroring `ownerOfAt` eligibility, and
14
+ /// mints never write to this trace. Distributors use this as the denominator for tier-scoped reward pots.
15
+ /// @param tierId The tier to get the eligible voting units of.
16
+ /// @param blockNumber The block number to look up (must be strictly in the past).
17
+ /// @return The tier's eligible voting units at `blockNumber`.
18
+ function getPastTierVotingUnits(uint256 tierId, uint256 blockNumber) external view returns (uint256);
19
+
11
20
  /// @notice The hook that this module tracks voting power for.
12
21
  /// @return The hook address.
13
22
  // forge-lint: disable-next-line(mixed-case-function)
@@ -151,6 +151,12 @@ interface IJB721TiersHookStore {
151
151
  view
152
152
  returns (uint256 tierId, bool transfersPausable);
153
153
 
154
+ /// @notice Get only the per-unit voting value for a token's tier, avoiding full struct construction.
155
+ /// @param hook The 721 hook address.
156
+ /// @param tokenId The token ID.
157
+ /// @return The per-unit voting value for the token's tier (custom voting units if set, otherwise the tier price).
158
+ function tierVotingUnitsOfTokenId(address hook, uint256 tokenId) external view returns (uint256);
159
+
154
160
  /// @notice Get an array of currently active 721 tiers for the provided 721 contract.
155
161
  /// @param hook The 721 contract to get the tiers of.
156
162
  /// @param categories An array of tier categories to get tiers from. Empty for all categories.