@bananapus/721-hook-v6 0.0.63 → 0.0.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.63",
3
+ "version": "0.0.65",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -335,7 +335,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
335
335
  /// for cash-out calculations — an NFT's share of the surplus is its weight divided by this total.
336
336
  /// @return weight The total cash-out weight.
337
337
  function totalCashOutWeight() public view virtual override returns (uint256) {
338
- return STORE.totalCashOutWeight(address(this));
338
+ return STORE.totalCashOutWeightOf(address(this));
339
339
  }
340
340
 
341
341
  //*********************************************************************//
@@ -61,6 +61,14 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
61
61
  // --------------------- public stored properties -------------------- //
62
62
  //*********************************************************************//
63
63
 
64
+ /// @notice How many NFTs an address owns from a hook, totaled across all tiers, as a running aggregate so this
65
+ /// is O(1) instead of O(maxTierId).
66
+ /// @dev Maintained in `recordTransferForTier` (a mint increments the receiver, a burn decrements the sender, a
67
+ /// transfer does both), so an attacker spamming empty tiers cannot make this read run out of gas.
68
+ /// @custom:param hook The 721 contract to check.
69
+ /// @custom:param owner The address to get the balance of.
70
+ mapping(address hook => mapping(address owner => uint256)) public override balanceOf;
71
+
64
72
  /// @notice Returns the default reserve beneficiary for the provided 721 contract.
65
73
  /// @dev If a tier has a reserve beneficiary set, it will override this value.
66
74
  /// @custom:param hook The 721 contract to get the default reserve beneficiary of.
@@ -102,6 +110,16 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
102
110
  /// @custom:param hook The 721 contract to get the custom token URI resolver of.
103
111
  mapping(address hook => IJB721TokenUriResolver) public override tokenUriResolverOf;
104
112
 
113
+ /// @notice The combined cash-out weight of all of a hook's NFTs, as a running aggregate so cash-out pricing is
114
+ /// O(1) instead of O(maxTierId).
115
+ /// @dev Maintained incrementally in `recordMint` (+ the tier's full price for the new outstanding NFT plus any
116
+ /// newly-accrued pending reserve) and `recordBurn` (- the tier's full price). It is invariant under everything
117
+ /// else: reserve mints are weight-neutral (a pending reserve becomes an outstanding NFT), removed tiers keep
118
+ /// their already-minted weight, and newly added (unminted) tiers contribute zero — so spamming empty tiers can
119
+ /// never grow this read or its cost. Uses the full tier price, not the discounted price (see `cashOutWeightOf`).
120
+ /// @custom:param hook The 721 contract to get the total cash-out weight of.
121
+ mapping(address hook => uint256) public override totalCashOutWeightOf;
122
+
105
123
  //*********************************************************************//
106
124
  // --------------------- internal stored properties ------------------ //
107
125
  //*********************************************************************//
@@ -467,26 +485,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
467
485
  // -------------------------- public views --------------------------- //
468
486
  //*********************************************************************//
469
487
 
470
- /// @notice How many NFTs an address owns from a hook, totaled across all tiers.
471
- /// @param hook The 721 hook contract to check.
472
- /// @param owner The address to check the balance of.
473
- /// @return balance The total number of NFTs the owner holds.
474
- function balanceOf(address hook, address owner) public view override returns (uint256 balance) {
475
- // Keep a reference to the greatest tier ID.
476
- uint256 maxTierId = maxTierIdOf[hook];
477
-
478
- // Loop through all tiers.
479
- for (uint256 i = maxTierId; i != 0;) {
480
- // Get a reference to the account's balance within this tier.
481
- balance += tierBalanceOf[hook][owner][i];
482
-
483
- unchecked {
484
- --i;
485
- }
486
- }
487
- }
488
-
489
- /// @notice The combined cash-out weight of specific NFTs. Divide by `totalCashOutWeight` to get the fraction of
488
+ /// @notice The combined cash-out weight of specific NFTs. Divide by `totalCashOutWeightOf` to get the fraction of
490
489
  /// the project's surplus that cashing out these NFTs would reclaim.
491
490
  /// @dev Weight is based on each NFT's original tier price (not the discounted price paid). Discounts are
492
491
  /// transient purchase incentives and don't affect an NFT's share of the cash-out pool.
@@ -547,48 +546,6 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
547
546
 
548
547
  /// @notice The total cash-out weight across all outstanding NFTs (including pending reserves). This is the
549
548
  /// denominator used to determine what fraction of a project's surplus each NFT can reclaim on cash out.
550
- /// @param hook The 721 hook contract to get the total cash-out weight of.
551
- /// @return weight The total cash-out weight.
552
- // Changing defaultReserveBeneficiary retroactively affects totalCashOutWeight. By design —
553
- // cashOutWeight is calculated dynamically, not snapshotted. The defaultReserveBeneficiary determines which
554
- // tiers have pending reserves (via _numberOfPendingReservesFor), affecting the denominator. Changing it is
555
- // an admin action that naturally affects future cash out calculations. Snapshotting would add storage
556
- // overhead and complexity.
557
- function totalCashOutWeight(address hook) public view override returns (uint256 weight) {
558
- // Keep a reference to the greatest tier ID.
559
- uint256 maxTierId = maxTierIdOf[hook];
560
-
561
- // Add each 721's original price (from its tier) to the weight.
562
- // Uses the full tier price, not the discounted price — by design. See `cashOutWeightOf` for rationale.
563
- for (uint256 i = 1; i <= maxTierId;) {
564
- // Keep a reference to the stored tier.
565
- JBStored721Tier memory storedTier = _storedTierOf[hook][i];
566
-
567
- // Skip empty tiers (zero mints and zero burns) — they contribute zero weight.
568
- if (storedTier.initialSupply == storedTier.remainingSupply && numberOfBurnedFor[hook][i] == 0) {
569
- unchecked {
570
- ++i;
571
- }
572
- continue;
573
- }
574
-
575
- // Add the tier's price multiplied by the number of minted NFTs plus pending reserves.
576
- // Pending reserves are included by design — they represent committed obligations that will be
577
- // minted to the reserve beneficiary. Including them in the denominator ensures cash-out values
578
- // account for the full diluted supply, preventing early cashers from extracting more than their
579
- // fair share before reserves are minted.
580
- // Note: removed tiers are NOT skipped here because minted NFTs from removed tiers still carry
581
- // cash-out weight, and their pending reserves can still be minted.
582
- weight += storedTier.price
583
- * ((storedTier.initialSupply - (storedTier.remainingSupply + numberOfBurnedFor[hook][i]))
584
- + _numberOfPendingReservesFor({hook: hook, tierId: i, storedTier: storedTier}));
585
-
586
- unchecked {
587
- ++i;
588
- }
589
- }
590
- }
591
-
592
549
  //*********************************************************************//
593
550
  // -------------------------- internal views ------------------------- //
594
551
  //*********************************************************************//
@@ -1189,6 +1146,12 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
1189
1146
  // Increment the number of NFTs burned from the tier.
1190
1147
  numberOfBurnedFor[msg.sender][tierId]++;
1191
1148
 
1149
+ // Maintain the running cash-out weight: a burn removes one outstanding NFT (weighted by the tier's full
1150
+ // price). Pending reserves are unaffected — they track non-reserve MINTS, which a burn does not change.
1151
+ unchecked {
1152
+ totalCashOutWeightOf[msg.sender] -= _storedTierOf[msg.sender][tierId].price;
1153
+ }
1154
+
1192
1155
  unchecked {
1193
1156
  ++i;
1194
1157
  }
@@ -1282,6 +1245,10 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
1282
1245
  // Make sure there's at least one NFT remaining to mint.
1283
1246
  if (storedTier.remainingSupply == 0) revert JB721TiersHookStore_InsufficientSupplyRemaining(tierId);
1284
1247
 
1248
+ // Pending reserves before this mint, captured to maintain the O(1) cash-out weight aggregate.
1249
+ uint256 pendingReservesBefore =
1250
+ _numberOfPendingReservesFor({hook: msg.sender, tierId: tierId, storedTier: storedTier});
1251
+
1285
1252
  // Mint the 721 — decrement remaining supply first so the reserve check below
1286
1253
  // sees the correct post-mint non-reserve-mint count.
1287
1254
  unchecked {
@@ -1292,14 +1259,24 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
1292
1259
  leftoverAmount = leftoverAmount - price;
1293
1260
  }
1294
1261
 
1262
+ // Pending reserves after this mint (also used for the supply check below). A non-reserve mint can only
1263
+ // increase pending reserves, by at most one, so the difference is 0 or 1.
1264
+ uint256 pendingReservesAfter =
1265
+ _numberOfPendingReservesFor({hook: msg.sender, tierId: tierId, storedTier: storedTier});
1266
+
1295
1267
  // Make sure there are still enough NFTs remaining to satisfy pending reserves.
1296
- if (
1297
- storedTier.remainingSupply
1298
- < _numberOfPendingReservesFor({hook: msg.sender, tierId: tierId, storedTier: storedTier})
1299
- ) {
1268
+ if (storedTier.remainingSupply < pendingReservesAfter) {
1300
1269
  revert JB721TiersHookStore_InsufficientSupplyRemaining({tierId: tierId});
1301
1270
  }
1302
1271
 
1272
+ // Maintain the running cash-out weight: this mint adds one outstanding NFT plus any newly-accrued pending
1273
+ // reserve, each weighted by the tier's FULL price (the cash-out weight uses the full price, not the
1274
+ // discounted one). Keeps `totalCashOutWeightOf` O(1) regardless of the tier count.
1275
+ unchecked {
1276
+ totalCashOutWeightOf[msg.sender] += storedTier.price
1277
+ * (1 + (pendingReservesAfter - pendingReservesBefore));
1278
+ }
1279
+
1303
1280
  unchecked {
1304
1281
  ++i;
1305
1282
  }
@@ -1443,15 +1420,17 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
1443
1420
  function recordTransferForTier(uint256 tierId, address from, address to) external override {
1444
1421
  // If this is not a mint,
1445
1422
  if (from != address(0)) {
1446
- // then subtract the tier balance from the sender.
1423
+ // then subtract the tier balance from the sender, and the running per-owner balance read by `balanceOf`.
1447
1424
  --tierBalanceOf[msg.sender][from][tierId];
1425
+ --balanceOf[msg.sender][from];
1448
1426
  }
1449
1427
 
1450
1428
  // If this is not a burn,
1451
1429
  if (to != address(0)) {
1452
1430
  unchecked {
1453
- // then increase the tier balance for the receiver.
1431
+ // then increase the tier balance for the receiver, and the running per-owner balance.
1454
1432
  ++tierBalanceOf[msg.sender][to][tierId];
1433
+ ++balanceOf[msg.sender][to];
1455
1434
  }
1456
1435
  }
1457
1436
  }
@@ -190,7 +190,7 @@ interface IJB721TiersHookStore {
190
190
  /// @notice The combined cash out weight for all NFTs from the provided 721 contract.
191
191
  /// @param hook The 721 contract to get the total cash out weight of.
192
192
  /// @return weight The total cash out weight.
193
- function totalCashOutWeight(address hook) external view returns (uint256 weight);
193
+ function totalCashOutWeightOf(address hook) external view returns (uint256 weight);
194
194
 
195
195
  /// @notice The total number of NFTs minted from the provided 721 contract.
196
196
  /// @param hook The 721 contract to get a total supply of.
@@ -198,9 +198,31 @@ contract ForTest_JB721TiersHookStore is JB721TiersHookStore, IJB721TiersHookStor
198
198
  tierId = _lastSortedTierIdOf(nft);
199
199
  }
200
200
 
201
+ /// @dev The cash-out weight contribution of a single tier, using the same formula as the production aggregate, so
202
+ /// the `ForTest_*` manufacturing setters can keep the O(1) `totalCashOutWeightOf` aggregate consistent.
203
+ function _forTestTierWeight(
204
+ address hook,
205
+ uint256 index,
206
+ JBStored721Tier memory tier
207
+ )
208
+ internal
209
+ view
210
+ returns (uint256)
211
+ {
212
+ if (tier.initialSupply == tier.remainingSupply && numberOfBurnedFor[hook][index] == 0) return 0;
213
+ return tier.price
214
+ * ((tier.initialSupply - (tier.remainingSupply + numberOfBurnedFor[hook][index]))
215
+ + _numberOfPendingReservesFor({hook: hook, tierId: index, storedTier: tier}));
216
+ }
217
+
201
218
  // forge-lint: disable-next-line(mixed-case-function)
202
219
  function ForTest_setTier(address hook, uint256 index, JBStored721Tier calldata newTier) public override {
220
+ // Keep the O(1) `totalCashOutWeightOf` aggregate in sync with the manufactured tier state.
221
+ totalCashOutWeightOf[
222
+ address(hook)
223
+ ] -= _forTestTierWeight(address(hook), index, _storedTierOf[address(hook)][index]);
203
224
  _storedTierOf[address(hook)][index] = newTier;
225
+ totalCashOutWeightOf[address(hook)] += _forTestTierWeight(address(hook), index, newTier);
204
226
  }
205
227
 
206
228
  // forge-lint: disable-next-line(mixed-case-function)
@@ -210,12 +232,22 @@ contract ForTest_JB721TiersHookStore is JB721TiersHookStore, IJB721TiersHookStor
210
232
 
211
233
  // forge-lint: disable-next-line(mixed-case-function)
212
234
  function ForTest_setBalanceOf(address hook, address holder, uint256 tier, uint256 balance) public override {
235
+ // 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;
213
238
  tierBalanceOf[address(hook)][holder][tier] = balance;
214
239
  }
215
240
 
216
241
  // forge-lint: disable-next-line(mixed-case-function)
217
242
  function ForTest_setReservesMintedFor(address hook, uint256 tier, uint256 amount) public override {
243
+ // Reserve count changes pending reserves, which feed `totalCashOutWeightOf`; keep the aggregate in sync.
244
+ totalCashOutWeightOf[
245
+ address(hook)
246
+ ] -= _forTestTierWeight(address(hook), tier, _storedTierOf[address(hook)][tier]);
218
247
  numberOfReservesMintedFor[address(hook)][tier] = amount;
248
+ totalCashOutWeightOf[
249
+ address(hook)
250
+ ] += _forTestTierWeight(address(hook), tier, _storedTierOf[address(hook)][tier]);
219
251
  }
220
252
 
221
253
  // forge-lint: disable-next-line(mixed-case-function)