@bananapus/721-hook-v6 0.0.62 → 0.0.64

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.62",
3
+ "version": "0.0.64",
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 voting units (lightweight getter — avoids full tier struct construction).
123
- uint256 votingUnits = STORE.tierVotingUnitsOfTokenId({hook: hook, tokenId: tokenId});
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
  }
@@ -222,7 +222,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
222
222
  });
223
223
  }
224
224
 
225
- /// @notice The combined cash-out weight of specific NFTs. Divide by `totalCashOutWeight()` to get the fraction of
225
+ /// @notice The combined cash-out weight of specific NFTs. Divide by `totalCashOutWeightOf()` to get the fraction of
226
226
  /// surplus these NFTs can reclaim. Weight is based on the original tier price, not any discount paid.
227
227
  /// @param tokenIds The token IDs of the NFTs to get the combined cash-out weight of.
228
228
  /// @return weight The combined cash-out weight.
@@ -334,8 +334,8 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
334
334
  /// @notice The total cash-out weight across all outstanding NFTs and pending reserves. This is the denominator
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
- function totalCashOutWeight() public view virtual override returns (uint256) {
338
- return STORE.totalCashOutWeight(address(this));
337
+ function totalCashOutWeightOf() public view virtual override returns (uint256) {
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
  }
@@ -117,7 +117,7 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
117
117
  cashOutCount = cashOutWeightOf(decodedTokenIds);
118
118
 
119
119
  // Use the total cash out weight of the 721s.
120
- totalSupply = totalCashOutWeight();
120
+ totalSupply = totalCashOutWeightOf();
121
121
 
122
122
  // Use the surplus from the context.
123
123
  effectiveSurplusValue = context.surplus.value;
@@ -156,7 +156,7 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
156
156
  //*********************************************************************//
157
157
 
158
158
  /// @notice Returns the cumulative cash out weight of the specified token IDs relative to the
159
- /// `totalCashOutWeight`.
159
+ /// `totalCashOutWeightOf`.
160
160
  /// @param tokenIds The NFT token IDs to calculate the cumulative cash out weight of.
161
161
  /// @return The cumulative cash out weight of the specified token IDs.
162
162
  function cashOutWeightOf(uint256[] memory tokenIds) public view virtual returns (uint256) {
@@ -176,7 +176,7 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
176
176
 
177
177
  /// @notice Calculates the cumulative cash out weight of all NFT token IDs.
178
178
  /// @return The total cumulative cash out weight of all NFT token IDs.
179
- function totalCashOutWeight() public view virtual returns (uint256) {
179
+ function totalCashOutWeightOf() public view virtual returns (uint256) {
180
180
  return 0;
181
181
  }
182
182
 
@@ -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)
@@ -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)