@bananapus/721-hook-v6 0.0.74 → 0.0.76

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
@@ -67,6 +67,12 @@ If a bug affects supply, reserve minting, or tier lookup, it usually lives in th
67
67
  - per-tier owner-tracked voting units are queryable via `getPastTierVotingUnits(tierId, blockNumber)`: mints, transfers, and burns write ownership history, so the trace follows owned units regardless of delegation
68
68
  - active delegated vote totals are queryable globally via `getPastTotalActiveVotes(blockNumber)` / `getTotalActiveVotes()` and per tier via `getPastTotalTierActiveVotes(tierId, blockNumber)` / `getTotalTierActiveVotes(tierId)`. These totals include only voting units held by accounts with a nonzero delegate, so a token in undelegated custody does not count and returned tokens become active again if the holder's delegation is still set.
69
69
  - per-account active tier voting units are queryable via `getPastAccountTierActiveVotes(account, tierId, blockNumber)`. This follows the account holding the tier units, not the delegate receiving voting power, so reward distributors can cap tier-scoped claims against the holder's active units even when votes are delegated to another address.
70
+ - current and historical tier membership is queryable via `hasTiersOfAt(account, tierIds, matchMode, blockNumber)`,
71
+ using checkpointed per-account tier balances with `Any` and `All` match modes. Empty tier arrays fail closed, and
72
+ future blocks revert.
73
+ - voting-unit reads and delegation activation use each owner's nonzero held-tier bitmap, not every tier in the catalog.
74
+ This scans one storage word per 256 tier IDs (at most 256 words at the `uint16` tier cap) plus the owner's held
75
+ tiers, and updates one bitmap word when an account first enters or fully exits a tier.
70
76
 
71
77
  ## Where state lives
72
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.74",
3
+ "version": "0.0.76",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -7,6 +7,7 @@ import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
7
7
  import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
8
8
  import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol";
9
9
 
10
+ import {JB721TierOwnerMatch} from "./enums/JB721TierOwnerMatch.sol";
10
11
  import {IJB721Checkpoints} from "./interfaces/IJB721Checkpoints.sol";
11
12
  import {IJB721TiersHook} from "./interfaces/IJB721TiersHook.sol";
12
13
  import {IJB721TiersHookStore} from "./interfaces/IJB721TiersHookStore.sol";
@@ -62,6 +63,13 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
62
63
  /// @custom:param tierId The tier to get historical active voting units for.
63
64
  mapping(address account => mapping(uint256 tierId => Checkpoints.Trace160)) internal _accountTierActiveVotesOf;
64
65
 
66
+ /// @notice Checkpointed NFT balances per account and tier.
67
+ /// @dev Maintained by `onTransfer` and owner-checkpoint backfills so tier-membership checks can be queried at a
68
+ /// block without scanning every token in the tier.
69
+ /// @custom:param account The account to get historical tier balances for.
70
+ /// @custom:param tierId The tier to get historical balances for.
71
+ mapping(address account => mapping(uint256 tierId => Checkpoints.Trace160)) internal _accountTierBalancesOf;
72
+
65
73
  /// @notice Checkpointed token owners for historical reward eligibility.
66
74
  /// @dev Mint and transfer hooks write this automatically; `delegate` only backfills missing pre-upgrade history.
67
75
  /// @custom:param tokenId The token ID to get historical owner checkpoints for.
@@ -126,8 +134,16 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
126
134
  if (_ownerCheckpointsOf[tokenId].length() == 0) {
127
135
  // forge-lint: disable-next-line(unsafe-typecast)
128
136
  _ownerCheckpointsOf[tokenId].push({key: uint96(block.number), value: uint160(msg.sender)});
137
+
138
+ // Reuse the token's encoded tier ID for both account-balance and tier-supply traces.
139
+ uint256 tierId = STORE.tierIdOfToken(tokenId);
140
+
141
+ // Add this backfilled token to the caller's checkpointed tier balance.
142
+ _adjustAccountTierBalance({account: msg.sender, tierId: tierId, increase: true});
143
+
144
+ // Add this backfilled token's units to the tier's owner-tracked total.
129
145
  _updateTierEligibleUnits({
130
- tierId: STORE.tierIdOfToken(tokenId),
146
+ tierId: tierId,
131
147
  amount: STORE.tierVotingUnitsOfTokenId({hook: hook, tokenId: tokenId}),
132
148
  increase: true
133
149
  });
@@ -144,7 +160,10 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
144
160
  /// @param hookAddress The hook this module serves.
145
161
  function initialize(address hookAddress) external override {
146
162
  if (hook != address(0)) revert JB721Checkpoints_AlreadyInitialized({hook: hook});
147
- // `hook` cannot be zero when called through the deployer because `msg.sender` must equal `hook`.
163
+ // No caller check is needed (and there is none): `JB721CheckpointsDeployer.deploy` creates this clone with
164
+ // the hook as the CREATE2 salt and calls `initialize(hook)` atomically in the same call, so a clone can only
165
+ // ever be bound to its salt-designated hook and there is no front-run window between cloning and init. The
166
+ // implementation itself is locked against direct use by the `address(1)` sentinel set in its constructor.
148
167
  hook = hookAddress;
149
168
  }
150
169
 
@@ -156,6 +175,9 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
156
175
  function onTransfer(address from, address to, uint256 tokenId) external override {
157
176
  if (msg.sender != hook) revert JB721Checkpoints_Unauthorized({caller: msg.sender, hook: hook});
158
177
 
178
+ // Self-transfers leave ownership, voting units, and tier-balance traces unchanged.
179
+ if (from == to) return;
180
+
159
181
  // Look up this token's tier ID and voting units once; both the owner and active traces need them.
160
182
  uint256 tierId = STORE.tierIdOfToken(tokenId);
161
183
 
@@ -176,15 +198,22 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
176
198
 
177
199
  if (from == address(0) && to != address(0)) {
178
200
  // Mint: add the token's tier units to the owner-tracked tier supply.
201
+ _adjustAccountTierBalance({account: to, tierId: tierId, increase: true});
179
202
  _updateTierEligibleUnits({tierId: tierId, amount: votingUnits, increase: true});
180
203
  } else if (to == address(0)) {
181
204
  // Burn: remove the tier's units only if the token was already part of the owner-tracked supply.
182
205
  if (wasEligible) {
206
+ _adjustAccountTierBalance({account: from, tierId: tierId, increase: false});
183
207
  _updateTierEligibleUnits({tierId: tierId, amount: votingUnits, increase: false});
184
208
  }
185
209
  } else if (!wasEligible) {
186
210
  // First transfer of a pre-upgrade uncheckpointed token makes its ownership history trackable.
211
+ _adjustAccountTierBalance({account: to, tierId: tierId, increase: true});
187
212
  _updateTierEligibleUnits({tierId: tierId, amount: votingUnits, increase: true});
213
+ } else {
214
+ // Transfer: move one tier balance unit between checkpointed accounts.
215
+ _adjustAccountTierBalance({account: from, tierId: tierId, increase: false});
216
+ _adjustAccountTierBalance({account: to, tierId: tierId, increase: true});
188
217
  }
189
218
 
190
219
  // The tier active total decreases if units leave an account that already has a nonzero delegate.
@@ -295,14 +324,66 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
295
324
  activeVotes = _tierActiveSupplyCheckpointsOf[tierId].latest();
296
325
  }
297
326
 
298
- /// @notice The owner of an NFT at a past block.
327
+ /// @notice Whether an owner held any or all of the provided tier IDs at a block.
328
+ /// @dev Empty arrays return `false`. `blockNumber` may be the current block, but not a future block.
329
+ /// @param account The account to check.
330
+ /// @param tierIds The tier IDs to check.
331
+ /// @param matchMode Whether to require any or all tier IDs to be held.
332
+ /// @param blockNumber The block number to look up.
333
+ /// @return hasTiers Whether the owner satisfies the requested tier match at `blockNumber`.
334
+ function hasTiersOfAt(
335
+ address account,
336
+ uint256[] calldata tierIds,
337
+ JB721TierOwnerMatch matchMode,
338
+ uint256 blockNumber
339
+ )
340
+ external
341
+ view
342
+ override
343
+ returns (bool hasTiers)
344
+ {
345
+ // Empty tier requirements fail closed so default-decoded calls cannot grant membership.
346
+ if (tierIds.length == 0) return false;
347
+
348
+ // Convert once so every tier lookup uses the same checkpoint key.
349
+ uint96 blockNumber96 = _validateCurrentOrPastTimepoint(blockNumber);
350
+
351
+ if (matchMode == JB721TierOwnerMatch.Any) {
352
+ // In `Any` mode, one nonzero tier balance is enough to satisfy the query.
353
+ for (uint256 i; i < tierIds.length;) {
354
+ // A nonzero checkpointed balance means the account held this tier at the requested block.
355
+ if (_accountTierBalancesOf[account][tierIds[i]].upperLookupRecent(blockNumber96) != 0) return true;
356
+
357
+ unchecked {
358
+ ++i;
359
+ }
360
+ }
361
+
362
+ // None of the queried tiers had a nonzero balance for the account at the requested block.
363
+ return false;
364
+ }
365
+
366
+ // In `All` mode, every queried tier must have a nonzero balance.
367
+ for (uint256 i; i < tierIds.length;) {
368
+ // Missing any queried tier is enough to fail the all-tiers match.
369
+ if (_accountTierBalancesOf[account][tierIds[i]].upperLookupRecent(blockNumber96) == 0) return false;
370
+
371
+ unchecked {
372
+ ++i;
373
+ }
374
+ }
375
+
376
+ // Every queried tier matched.
377
+ return true;
378
+ }
379
+
380
+ /// @notice The owner of an NFT at a current or past block.
299
381
  /// @dev Returns `address(0)` if no ownership checkpoint exists or the query predates the first checkpoint.
300
382
  /// @param tokenId The token ID of the NFT to get the historical owner of.
301
- /// @param blockNumber The block number to look up.
383
+ /// @param blockNumber The current or past block number to look up.
302
384
  /// @return owner The owner of the token at `blockNumber`, or zero if no owner is proven at that block.
303
385
  function ownerOfAt(uint256 tokenId, uint256 blockNumber) external view override returns (address owner) {
304
- // forge-lint: disable-next-line(unsafe-typecast)
305
- uint96 blockNumber96 = uint96(blockNumber);
386
+ uint96 blockNumber96 = _validateCurrentOrPastTimepoint(blockNumber);
306
387
 
307
388
  Checkpoints.Trace160 storage checkpoints = _ownerCheckpointsOf[tokenId];
308
389
  uint256 checkpointCount = checkpoints.length();
@@ -398,18 +479,15 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
398
479
  /// @param amount The voting units to add or remove.
399
480
  /// @param increase Whether to add `amount`; if false, `amount` is removed.
400
481
  function _adjustAccountTierActiveVotes(address account, uint256 tierId, uint256 amount, bool increase) private {
401
- // Ignore zero-unit updates because they do not change the account's active tier total.
402
- if (amount == 0) return;
403
-
404
- // Keep a reference to the account's active-voting-units trace for this tier.
405
- Checkpoints.Trace160 storage trace = _accountTierActiveVotesOf[account][tierId];
406
-
407
- // Calculate the next account-tier active total from its latest checkpointed value.
408
- uint256 updated = increase ? trace.latest() + amount : trace.latest() - amount;
482
+ _pushTraceDelta({trace: _accountTierActiveVotesOf[account][tierId], amount: amount, increase: increase});
483
+ }
409
484
 
410
- // Write the new account-tier active total at the current block.
411
- // forge-lint: disable-next-line(unsafe-typecast)
412
- trace.push({key: uint96(block.number), value: uint160(updated)});
485
+ /// @notice Add or remove one NFT from an account's tier-balance checkpoint at the current block.
486
+ /// @param account The account whose tier-balance trace should be updated.
487
+ /// @param tierId The tier whose balance should be updated.
488
+ /// @param increase Whether to add one NFT; if false, one NFT is removed.
489
+ function _adjustAccountTierBalance(address account, uint256 tierId, bool increase) private {
490
+ _pushTraceDelta({trace: _accountTierBalancesOf[account][tierId], amount: 1, increase: increase});
413
491
  }
414
492
 
415
493
  /// @notice Add or remove units from a tier's active-voting-units checkpoint at the current block.
@@ -417,18 +495,7 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
417
495
  /// @param amount The voting units to add or remove.
418
496
  /// @param increase Whether to add `amount`; if false, `amount` is removed.
419
497
  function _adjustTotalTierActiveVotes(uint256 tierId, uint256 amount, bool increase) private {
420
- // Ignore zero-unit updates because they do not change this tier's active total.
421
- if (amount == 0) return;
422
-
423
- // Keep a reference to the tier's active-voting-units trace.
424
- Checkpoints.Trace160 storage trace = _tierActiveSupplyCheckpointsOf[tierId];
425
-
426
- // Calculate the next tier active total by adding or subtracting from the latest checkpointed value.
427
- uint256 updated = increase ? trace.latest() + amount : trace.latest() - amount;
428
-
429
- // Write the new tier active total at the current block.
430
- // forge-lint: disable-next-line(unsafe-typecast)
431
- trace.push({key: uint96(block.number), value: uint160(updated)});
498
+ _pushTraceDelta({trace: _tierActiveSupplyCheckpointsOf[tierId], amount: amount, increase: increase});
432
499
  }
433
500
 
434
501
  /// @notice Apply an account delegation change to every tier-level active total the account contributes to.
@@ -437,25 +504,28 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
437
504
  /// @param account The account whose tier voting units should be added or removed.
438
505
  /// @param increase Whether to add `account`'s tier voting units; if false, they are removed.
439
506
  function _applyAccountDelegationToTierActiveVotes(address account, bool increase) private {
440
- // Read the largest tier ID once; tier IDs are sequential and 1-indexed for each hook.
441
- uint256 tierId = STORE.maxTierIdOf(hook);
507
+ // Read the account's held-tier IDs and voting units once so empty tiers are never visited.
508
+ (uint256[] memory tierIds, uint256[] memory tierVotingUnits) =
509
+ STORE.heldTierVotingUnitsOf({hook: hook, account: account});
442
510
 
443
- // Walk each tier from max to 1 so empty hooks skip cleanly when maxTierId is zero.
444
- while (tierId != 0) {
445
- // Read only this account's units for the tier; empty tiers return zero and are ignored below.
446
- uint256 tierVotingUnits = STORE.tierVotingUnitsOf({hook: hook, account: account, tierId: tierId});
511
+ // Walk only tiers the account currently holds.
512
+ for (uint256 i; i < tierIds.length;) {
513
+ // Get a reference to the tier ID being iterated on.
514
+ uint256 tierId = tierIds[i];
515
+
516
+ // Keep a reference to this account's voting units for the tier.
517
+ uint256 votingUnits = tierVotingUnits[i];
447
518
 
448
519
  // Only write checkpoints for tiers whose active totals actually change.
449
- if (tierVotingUnits != 0) {
450
- _adjustTotalTierActiveVotes({tierId: tierId, amount: tierVotingUnits, increase: increase});
520
+ if (votingUnits != 0) {
521
+ _adjustTotalTierActiveVotes({tierId: tierId, amount: votingUnits, increase: increase});
451
522
  _adjustAccountTierActiveVotes({
452
- account: account, tierId: tierId, amount: tierVotingUnits, increase: increase
523
+ account: account, tierId: tierId, amount: votingUnits, increase: increase
453
524
  });
454
525
  }
455
526
 
456
527
  unchecked {
457
- // The loop condition proves tierId is nonzero, so the decrement cannot underflow.
458
- --tierId;
528
+ ++i;
459
529
  }
460
530
  }
461
531
  }
@@ -482,17 +552,30 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
482
552
  /// @param amount The voting units to add or remove.
483
553
  /// @param increase Whether to add `amount`; if false, `amount` is removed.
484
554
  function _updateTierEligibleUnits(uint256 tierId, uint256 amount, bool increase) private {
485
- // Ignore zero-unit updates because they do not change this tier's owner-tracked total.
486
- if (amount == 0) return;
555
+ _pushTraceDelta({trace: _tierEligibleUnitsOf[tierId], amount: amount, increase: increase});
556
+ }
487
557
 
488
- // Keep a reference to the tier's owner-tracked voting-units trace.
489
- Checkpoints.Trace160 storage trace = _tierEligibleUnitsOf[tierId];
558
+ /// @notice Add or remove an amount from a `Trace160` checkpoint at the current block.
559
+ /// @param trace The checkpoint trace to update.
560
+ /// @param amount The amount to add or remove.
561
+ /// @param increase Whether to add `amount`; if false, `amount` is removed.
562
+ function _pushTraceDelta(Checkpoints.Trace160 storage trace, uint256 amount, bool increase) private {
563
+ // Ignore zero-unit updates because they do not change the checkpointed total.
564
+ if (amount == 0) return;
490
565
 
491
- // Calculate the next owner-tracked total by adding or subtracting from the latest checkpointed value.
566
+ // Calculate the next trace total by adding or subtracting from the latest checkpointed value.
492
567
  uint256 updated = increase ? trace.latest() + amount : trace.latest() - amount;
493
568
 
494
- // Write the new owner-tracked total at the current block.
495
- // forge-lint: disable-next-line(unsafe-typecast)
496
- trace.push({key: uint96(block.number), value: uint160(updated)});
569
+ // Write the new trace total at the current block.
570
+ trace.push({key: uint96(block.number), value: SafeCast.toUint160(updated)});
571
+ }
572
+
573
+ /// @notice Validate a timepoint that may be current or past, but not future.
574
+ /// @param timepoint The block number to validate.
575
+ /// @return The timepoint as a uint96 checkpoint key.
576
+ function _validateCurrentOrPastTimepoint(uint256 timepoint) private view returns (uint96) {
577
+ uint48 currentTimepoint = clock();
578
+ if (timepoint > currentTimepoint) revert ERC5805FutureLookup(timepoint, currentTimepoint);
579
+ return SafeCast.toUint96(timepoint);
497
580
  }
498
581
  }
@@ -1,8 +1,9 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity 0.8.28;
3
3
 
4
- import {mulDiv} from "@prb/math/src/Common.sol";
5
4
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
5
+ import {mulDiv} from "@prb/math/src/Common.sol";
6
+ import {LibBit} from "solady/src/utils/LibBit.sol";
6
7
 
7
8
  import {IJB721TiersHookStore} from "./interfaces/IJB721TiersHookStore.sol";
8
9
  import {IJB721TokenUriResolver} from "./interfaces/IJB721TokenUriResolver.sol";
@@ -166,6 +167,15 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
166
167
  /// @custom:returns The flags.
167
168
  mapping(address hook => JB721TiersHookFlags) internal _flagsOf;
168
169
 
170
+ /// @notice Bitmap words whose set bits indicate tier IDs with nonzero balances for the provided owner.
171
+ /// @dev Maintained in `recordTransferForTier`, so voting-unit reads and delegation updates walk set bits instead
172
+ /// of every tier ever added to the hook. Each word tracks 256 tier IDs.
173
+ /// @custom:param hook The 721 contract to get held tier IDs from.
174
+ /// @custom:param owner The address to get held tier IDs for.
175
+ /// @custom:param depth The bitmap word depth to read. Each depth stores 256 tier IDs.
176
+ mapping(address hook => mapping(address owner => mapping(uint256 depth => uint256 word))) internal
177
+ _heldTiersBitmapWordOf;
178
+
169
179
  /// @notice Return the ID of the last sorted tier from the provided 721 contract.
170
180
  /// @dev If not set, it is assumed the `maxTierIdOf` is the last sorted tier ID.
171
181
  /// @custom:param hook The 721 contract to get the last sorted tier ID from.
@@ -232,6 +242,72 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
232
242
  return _flagsOf[hook];
233
243
  }
234
244
 
245
+ /// @notice The tier IDs whose balances are nonzero for the provided owner.
246
+ /// @param hook The 721 hook contract to get held tier IDs from.
247
+ /// @param owner The address to get held tier IDs for.
248
+ /// @return tierIds The tier IDs with a nonzero balance for the owner.
249
+ function heldTierIdsOf(
250
+ address hook,
251
+ address owner
252
+ )
253
+ external
254
+ view
255
+ virtual
256
+ override
257
+ returns (uint256[] memory tierIds)
258
+ {
259
+ return _heldTierIdsOf({hook: hook, owner: owner});
260
+ }
261
+
262
+ /// @notice The tier IDs and voting units whose balances are nonzero for the provided account.
263
+ /// @param hook The 721 hook contract to get held tier IDs from.
264
+ /// @param account The address to get held tier IDs and voting units for.
265
+ /// @return tierIds The tier IDs with a nonzero balance for the account.
266
+ /// @return units The account's voting units for each returned tier ID.
267
+ function heldTierVotingUnitsOf(
268
+ address hook,
269
+ address account
270
+ )
271
+ external
272
+ view
273
+ virtual
274
+ override
275
+ returns (uint256[] memory tierIds, uint256[] memory units)
276
+ {
277
+ // Allocate both arrays to the exact number of held tier IDs.
278
+ tierIds = new uint256[](_numberOfHeldTiersOf({hook: hook, owner: account}));
279
+ units = new uint256[](tierIds.length);
280
+
281
+ // Fill both arrays in ascending tier ID order.
282
+ uint256 index;
283
+ uint256 maxDepth = maxTierIdOf[hook] >> 8;
284
+ for (uint256 depth; depth <= maxDepth;) {
285
+ // Keep a local copy so consumed bits do not mutate storage.
286
+ uint256 heldTiersBitmapWord = _heldTiersBitmapWordOf[hook][account][depth];
287
+
288
+ // Resolve each set bit to its tier ID and voting units.
289
+ while (heldTiersBitmapWord != 0) {
290
+ // Store the tier ID represented by the lowest set bit.
291
+ uint256 tierId = (depth << 8) + LibBit.ffs(heldTiersBitmapWord);
292
+ tierIds[index] = tierId;
293
+
294
+ // Store the account's voting units for the tier at the same index.
295
+ units[index] = _votingUnitsForHeldTier({hook: hook, account: account, tierId: tierId});
296
+
297
+ unchecked {
298
+ ++index;
299
+ }
300
+
301
+ // Clear the consumed bit from the local word.
302
+ heldTiersBitmapWord &= heldTiersBitmapWord - 1;
303
+ }
304
+
305
+ unchecked {
306
+ ++depth;
307
+ }
308
+ }
309
+ }
310
+
235
311
  /// @notice Check whether a tier has been removed. Removed tiers can no longer be minted from, but existing NFTs
236
312
  /// from that tier remain valid and can still be cashed out.
237
313
  /// @param hook The 721 hook contract the tier belongs to.
@@ -441,19 +517,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
441
517
  override
442
518
  returns (uint256)
443
519
  {
444
- // Get a reference to the account's balance in this tier.
445
- uint256 balance = tierBalanceOf[hook][account][tierId];
446
-
447
- if (balance == 0) return 0;
448
-
449
- // Keep a reference to the stored tier.
450
- JBStored721Tier memory storedTier = _storedTierOf[hook][tierId];
451
-
452
- // Check if voting units should be used. Price will be used otherwise.
453
- (,, bool useVotingUnits,,,) = _unpackBools(storedTier.packedBools);
454
-
455
- // Return the address' voting units within the tier.
456
- return balance * (useVotingUnits ? _tierVotingUnitsOf[hook][tierId] : storedTier.price);
520
+ return _votingUnitsForHeldTier({hook: hook, account: account, tierId: tierId});
457
521
  }
458
522
 
459
523
  /// @notice The total number of NFTs currently in circulation for a hook (minted minus burned, across all tiers).
@@ -486,34 +550,27 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
486
550
  /// @param account The address to get the voting unit total of.
487
551
  /// @return units The total voting units the address holds across all tiers.
488
552
  function votingUnitsOf(address hook, address account) external view virtual override returns (uint256 units) {
489
- // Keep a reference to the greatest tier ID.
490
- uint256 maxTierId = maxTierIdOf[hook];
491
-
492
- // Loop through all tiers.
493
- for (uint256 i = maxTierId; i != 0;) {
494
- // Get a reference to the account's balance in this tier.
495
- uint256 balance = tierBalanceOf[hook][account][i];
553
+ // Keep a reference to the last bitmap word which can contain a valid tier ID.
554
+ uint256 maxDepth = maxTierIdOf[hook] >> 8;
555
+
556
+ // Loop only through 256-tier bitmap words that can contain known tiers.
557
+ for (uint256 depth; depth <= maxDepth;) {
558
+ // Keep a local copy so consumed bits do not mutate storage.
559
+ uint256 heldTiersBitmapWord = _heldTiersBitmapWordOf[hook][account][depth];
560
+
561
+ // Walk only set bits, so empty tiers in the word are skipped.
562
+ while (heldTiersBitmapWord != 0) {
563
+ // Add the voting units represented by the lowest set bit.
564
+ units += _votingUnitsForHeldTier({
565
+ hook: hook, account: account, tierId: (depth << 8) + LibBit.ffs(heldTiersBitmapWord)
566
+ });
496
567
 
497
- // If the account has no balance, return.
498
- if (balance == 0) {
499
- unchecked {
500
- --i;
501
- }
502
- continue;
568
+ // Clear the consumed bit from the local word.
569
+ heldTiersBitmapWord &= heldTiersBitmapWord - 1;
503
570
  }
504
571
 
505
- // Get the tier.
506
- JBStored721Tier memory storedTier = _storedTierOf[hook][i];
507
-
508
- // Parse the flags.
509
- (,, bool useVotingUnits,,,) = _unpackBools(storedTier.packedBools);
510
-
511
- // Add the voting units for the address' balance in this tier.
512
- // Use custom voting units if set. Otherwise, use the tier's price.
513
- units += balance * (useVotingUnits ? _tierVotingUnitsOf[hook][i] : storedTier.price);
514
-
515
572
  unchecked {
516
- --i;
573
+ ++depth;
517
574
  }
518
575
  }
519
576
  }
@@ -656,6 +713,58 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
656
713
  });
657
714
  }
658
715
 
716
+ /// @notice The tier IDs whose balances are nonzero for the provided owner.
717
+ /// @param hook The 721 hook contract to get held tier IDs from.
718
+ /// @param owner The address to get held tier IDs for.
719
+ /// @return tierIds The tier IDs with a nonzero balance for the owner.
720
+ function _heldTierIdsOf(address hook, address owner) internal view returns (uint256[] memory tierIds) {
721
+ // Allocate the exact number of held tier IDs.
722
+ tierIds = new uint256[](_numberOfHeldTiersOf({hook: hook, owner: owner}));
723
+
724
+ // Fill the returned array in ascending tier ID order.
725
+ uint256 index;
726
+ uint256 maxDepth = maxTierIdOf[hook] >> 8;
727
+ for (uint256 depth; depth <= maxDepth;) {
728
+ // Keep a local copy so consumed bits do not mutate storage.
729
+ uint256 heldTiersBitmapWord = _heldTiersBitmapWordOf[hook][owner][depth];
730
+
731
+ // Resolve each set bit to its tier ID.
732
+ while (heldTiersBitmapWord != 0) {
733
+ // Store the tier ID represented by the lowest set bit.
734
+ tierIds[index] = (depth << 8) + LibBit.ffs(heldTiersBitmapWord);
735
+
736
+ unchecked {
737
+ ++index;
738
+ }
739
+
740
+ // Clear the consumed bit from the local word.
741
+ heldTiersBitmapWord &= heldTiersBitmapWord - 1;
742
+ }
743
+
744
+ unchecked {
745
+ ++depth;
746
+ }
747
+ }
748
+ }
749
+
750
+ /// @notice The number of tier IDs whose balances are nonzero for the provided owner.
751
+ /// @param hook The 721 hook contract to count held tier IDs from.
752
+ /// @param owner The address to count held tier IDs for.
753
+ /// @return numberOfHeldTiers The number of tier IDs with a nonzero balance for the owner.
754
+ function _numberOfHeldTiersOf(address hook, address owner) internal view returns (uint256 numberOfHeldTiers) {
755
+ // Keep a reference to the last bitmap word which can contain a valid tier ID.
756
+ uint256 maxDepth = maxTierIdOf[hook] >> 8;
757
+
758
+ for (uint256 depth; depth <= maxDepth;) {
759
+ // Count only set bits, because each set bit represents one held tier ID.
760
+ numberOfHeldTiers += LibBit.popCount(_heldTiersBitmapWordOf[hook][owner][depth]);
761
+
762
+ unchecked {
763
+ ++depth;
764
+ }
765
+ }
766
+ }
767
+
659
768
  /// @notice Check whether a tier has been removed while refreshing the relevant bitmap word if needed.
660
769
  /// @param hook The 721 contract to check for removals on.
661
770
  /// @param tierId The ID of the tier to check the removal status of.
@@ -763,6 +872,27 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
763
872
  }
764
873
  }
765
874
 
875
+ /// @notice Get an address's voting units within one held tier.
876
+ /// @param hook The 721 hook contract that the tier belongs to.
877
+ /// @param account The address to get voting units for.
878
+ /// @param tierId The ID of the tier.
879
+ /// @return units The account's total voting units within the tier.
880
+ function _votingUnitsForHeldTier(address hook, address account, uint256 tierId) internal view returns (uint256) {
881
+ // Get a reference to the account's balance in this tier.
882
+ uint256 balance = tierBalanceOf[hook][account][tierId];
883
+
884
+ if (balance == 0) return 0;
885
+
886
+ // Keep a reference to the stored tier.
887
+ JBStored721Tier memory storedTier = _storedTierOf[hook][tierId];
888
+
889
+ // Check if voting units should be used. Price will be used otherwise.
890
+ (,, bool useVotingUnits,,,) = _unpackBools(storedTier.packedBools);
891
+
892
+ // Return the address' voting units within the tier.
893
+ return balance * (useVotingUnits ? _tierVotingUnitsOf[hook][tierId] : storedTier.price);
894
+ }
895
+
766
896
  /// @notice Pack six tier-level boolean flags into a single uint8 for compact storage in `JBStored721Tier`.
767
897
  /// @dev Bit layout: 0=allowOwnerMint, 1=transfersPausable, 2=useVotingUnits, 3=cantBeRemoved,
768
898
  /// 4=cantIncreaseDiscountPercent, 5=cantBuyWithCredits.
@@ -1458,20 +1588,56 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
1458
1588
  /// @param from The address to transfer the 721 from.
1459
1589
  /// @param to The address to transfer the 721 to.
1460
1590
  function recordTransferForTier(uint256 tierId, address from, address to) external override {
1591
+ // Self-transfers leave the per-tier balance, aggregate balance, and held-tier bitmap unchanged.
1592
+ if (from == to) return;
1593
+
1461
1594
  // If this is not a mint,
1462
1595
  if (from != address(0)) {
1463
- // then subtract the tier balance from the sender, and the running per-owner balance read by `balanceOf`.
1464
- --tierBalanceOf[msg.sender][from][tierId];
1596
+ // then subtract the tier balance from the sender.
1597
+ uint256 fromTierBalance = --tierBalanceOf[msg.sender][from][tierId];
1598
+
1599
+ // Remove the tier from the sender's held bitmap if this transfer consumed their last NFT in the tier.
1600
+ if (fromTierBalance == 0) _removeHeldTier({hook: msg.sender, owner: from, tierId: tierId});
1601
+
1602
+ // Keep the running per-owner balance read by `balanceOf` in sync.
1465
1603
  --balanceOf[msg.sender][from];
1466
1604
  }
1467
1605
 
1468
1606
  // If this is not a burn,
1469
1607
  if (to != address(0)) {
1608
+ // Keep a reference to the receiver's current tier balance before mutating it.
1609
+ uint256 toTierBalance = tierBalanceOf[msg.sender][to][tierId];
1610
+
1611
+ // Add the tier to the receiver's held bitmap before their first NFT in the tier is recorded.
1612
+ if (toTierBalance == 0) _addHeldTier({hook: msg.sender, owner: to, tierId: tierId});
1613
+
1470
1614
  unchecked {
1471
1615
  // then increase the tier balance for the receiver, and the running per-owner balance.
1472
- ++tierBalanceOf[msg.sender][to][tierId];
1616
+ tierBalanceOf[msg.sender][to][tierId] = toTierBalance + 1;
1473
1617
  ++balanceOf[msg.sender][to];
1474
1618
  }
1475
1619
  }
1476
1620
  }
1621
+
1622
+ //*********************************************************************//
1623
+ // ----------------------- internal helpers -------------------------- //
1624
+ //*********************************************************************//
1625
+
1626
+ /// @notice Add a tier to an owner's held-tier bitmap.
1627
+ /// @param hook The 721 hook contract the tier belongs to.
1628
+ /// @param owner The owner whose held-tier bitmap is being updated.
1629
+ /// @param tierId The tier ID to add.
1630
+ function _addHeldTier(address hook, address owner, uint256 tierId) internal {
1631
+ // Set the bit representing this tier ID.
1632
+ _heldTiersBitmapWordOf[hook][owner].setId(tierId);
1633
+ }
1634
+
1635
+ /// @notice Remove a tier from an owner's held-tier bitmap.
1636
+ /// @param hook The 721 hook contract the tier belongs to.
1637
+ /// @param owner The owner whose held-tier bitmap is being updated.
1638
+ /// @param tierId The tier ID to remove.
1639
+ function _removeHeldTier(address hook, address owner, uint256 tierId) internal {
1640
+ // Clear the bit representing this tier ID.
1641
+ _heldTiersBitmapWordOf[hook][owner].clearId(tierId);
1642
+ }
1477
1643
  }
@@ -70,7 +70,7 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
70
70
  /// @param directory A directory of terminals and controllers for projects.
71
71
  constructor(IJBDirectory directory) {
72
72
  DIRECTORY = directory;
73
- // Store the implementation address. Clones use their own address when they initialize.
73
+ // Store the implementation address shared by clones, since immutables are baked into the reference bytecode.
74
74
  METADATA_ID_TARGET = address(this);
75
75
  }
76
76
 
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ /// @notice How to match tier ownership for an account against a provided tier ID list.
5
+ enum JB721TierOwnerMatch {
6
+ Any,
7
+ All
8
+ }
@@ -4,6 +4,7 @@ pragma solidity ^0.8.0;
4
4
  import {IJBActiveVotes} from "@bananapus/core-v6/src/interfaces/IJBActiveVotes.sol";
5
5
  import {IERC5805} from "@openzeppelin/contracts/interfaces/IERC5805.sol";
6
6
 
7
+ import {JB721TierOwnerMatch} from "../enums/JB721TierOwnerMatch.sol";
7
8
  import {IJB721TiersHookStore} from "./IJB721TiersHookStore.sol";
8
9
 
9
10
  /// @notice A checkpoint module that provides IVotes-compatible checkpointed voting power for a JB721TiersHook.
@@ -55,15 +56,32 @@ interface IJB721Checkpoints is IERC5805, IJBActiveVotes {
55
56
  /// @return activeVotes The tier's current delegated voting units.
56
57
  function getTotalTierActiveVotes(uint256 tierId) external view returns (uint256 activeVotes);
57
58
 
59
+ /// @notice Whether an owner held any or all of the provided tier IDs at a block.
60
+ /// @dev Empty arrays return `false`. `blockNumber` may be the current block, but not a future block.
61
+ /// @param account The account to check.
62
+ /// @param tierIds The tier IDs to check.
63
+ /// @param matchMode Whether to require any or all tier IDs to be held.
64
+ /// @param blockNumber The block number to look up.
65
+ /// @return hasTiers Whether the owner satisfies the requested tier match at `blockNumber`.
66
+ function hasTiersOfAt(
67
+ address account,
68
+ uint256[] calldata tierIds,
69
+ JB721TierOwnerMatch matchMode,
70
+ uint256 blockNumber
71
+ )
72
+ external
73
+ view
74
+ returns (bool hasTiers);
75
+
58
76
  /// @notice The hook that this module tracks voting power for.
59
77
  /// @return hookAddress The hook address.
60
78
  // forge-lint: disable-next-line(mixed-case-function)
61
79
  function hook() external view returns (address hookAddress);
62
80
 
63
- /// @notice The owner of an NFT at a past block.
81
+ /// @notice The owner of an NFT at a current or past block.
64
82
  /// @dev Returns `address(0)` if no ownership checkpoint exists or the query predates the first checkpoint.
65
83
  /// @param tokenId The token ID of the NFT to get the historical owner of.
66
- /// @param blockNumber The block number to look up.
84
+ /// @param blockNumber The current or past block number to look up.
67
85
  /// @return owner The owner of the token at `blockNumber`, or zero if no owner is proven at that block.
68
86
  function ownerOfAt(uint256 tokenId, uint256 blockNumber) external view returns (address owner);
69
87
 
@@ -56,6 +56,25 @@ interface IJB721TiersHookStore {
56
56
  /// @return The flags.
57
57
  function flagsOf(address hook) external view returns (JB721TiersHookFlags memory);
58
58
 
59
+ /// @notice The tier IDs whose balances are nonzero for the provided owner.
60
+ /// @param hook The 721 contract to get held tier IDs from.
61
+ /// @param owner The address to get held tier IDs for.
62
+ /// @return tierIds The tier IDs with a nonzero balance for the owner.
63
+ function heldTierIdsOf(address hook, address owner) external view returns (uint256[] memory tierIds);
64
+
65
+ /// @notice The tier IDs and voting units whose balances are nonzero for the provided account.
66
+ /// @param hook The 721 contract to get held tier IDs from.
67
+ /// @param account The address to get held tier IDs and voting units for.
68
+ /// @return tierIds The tier IDs with a nonzero balance for the account.
69
+ /// @return units The account's voting units for each returned tier ID.
70
+ function heldTierVotingUnitsOf(
71
+ address hook,
72
+ address account
73
+ )
74
+ external
75
+ view
76
+ returns (uint256[] memory tierIds, uint256[] memory units);
77
+
59
78
  /// @notice Check if the provided tier has been removed from the provided 721 contract.
60
79
  /// @param hook The 721 contract the tier belongs to.
61
80
  /// @param tierId The ID of the tier to check the removal status of.
@@ -43,12 +43,33 @@ library JBBitmap {
43
43
  return _retrieveDepth(index) != self.currentDepth;
44
44
  }
45
45
 
46
+ /// @notice Clear the bit at the given index.
47
+ /// @param self The bitmap to clear the bit from.
48
+ /// @param index The index of the bit to clear.
49
+ function clearId(mapping(uint256 => uint256) storage self, uint256 index) internal {
50
+ self[_retrieveDepth(index)] &= ~_retrieveBitMask(index);
51
+ }
52
+
46
53
  /// @notice Set the bit at the given index to true, indicating that the corresponding tier has been removed.
47
54
  /// @dev This is a one-way operation.
48
55
  function removeTier(mapping(uint256 => uint256) storage self, uint256 index) internal {
49
- uint256 depth = _retrieveDepth(index);
56
+ setId({self: self, index: index});
57
+ }
58
+
59
+ /// @notice Set the bit at the given index.
60
+ /// @param self The bitmap to set the bit in.
61
+ /// @param index The index of the bit to set.
62
+ function setId(mapping(uint256 => uint256) storage self, uint256 index) internal {
63
+ self[_retrieveDepth(index)] |= _retrieveBitMask(index);
64
+ }
65
+
66
+ /// @notice Return the bit mask of a given index within its bitmap row.
67
+ /// @param index The index to get a bit mask for.
68
+ /// @return mask The bit mask for `index`.
69
+ function _retrieveBitMask(uint256 index) internal pure returns (uint256 mask) {
70
+ // The modulo keeps the bit offset inside one 256-bit bitmap word.
50
71
  // forge-lint: disable-next-line(incorrect-shift)
51
- self[depth] |= uint256(1 << (index % 256));
72
+ return 1 << (index % 256);
52
73
  }
53
74
 
54
75
  /// @notice Return the line number (depth) of a given index within the bitmap matrix.
@@ -232,9 +232,20 @@ contract ForTest_JB721TiersHookStore is JB721TiersHookStore, IJB721TiersHookStor
232
232
 
233
233
  // forge-lint: disable-next-line(mixed-case-function)
234
234
  function ForTest_setBalanceOf(address hook, address holder, uint256 tier, uint256 balance) public override {
235
+ // Keep a reference to the manufactured balance before the override.
236
+ uint256 currentBalance = tierBalanceOf[address(hook)][holder][tier];
237
+
235
238
  // Keep the O(1) `balanceOf` aggregate in sync with the manufactured per-tier balance.
236
- balanceOf[address(hook)][holder] =
237
- balanceOf[address(hook)][holder] - tierBalanceOf[address(hook)][holder][tier] + balance;
239
+ balanceOf[address(hook)][holder] = balanceOf[address(hook)][holder] - currentBalance + balance;
240
+
241
+ // Keep the held-tier bitmap in sync with zero/nonzero balance transitions.
242
+ if (currentBalance == 0 && balance != 0) {
243
+ _addHeldTier({hook: address(hook), owner: holder, tierId: tier});
244
+ } else if (currentBalance != 0 && balance == 0) {
245
+ _removeHeldTier({hook: address(hook), owner: holder, tierId: tier});
246
+ }
247
+
248
+ // Store the manufactured per-tier balance.
238
249
  tierBalanceOf[address(hook)][holder][tier] = balance;
239
250
  }
240
251