@bananapus/721-hook-v6 0.0.69 → 0.0.70

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
@@ -38,7 +38,7 @@ This repo does more than "mint NFTs on pay." It changes how payment value, tier
38
38
  | `JB721TiersHookDeployer` | Clone factory for deploying a hook for an existing project. |
39
39
  | `JB721TiersHookProjectDeployer` | Convenience deployer for launching a project with a hook already wired in. |
40
40
  | `JB721Hook` | Abstract base for 721 pay and cash-out hook behavior. |
41
- | `JB721Checkpoints` | Per-hook IVotes checkpoint module. Tracks historical owner checkpoints plus per-tier eligible voting units (`getPastTierVotingUnits`) for tier-scoped reward distribution. |
41
+ | `JB721Checkpoints` | Per-hook IVotes checkpoint module. Tracks historical owner checkpoints, per-tier eligible voting units (`getPastTierVotingUnits`) for tier-scoped reward distribution, and active delegated vote totals (`getPastTotalActiveVotes`). |
42
42
 
43
43
  ## Mental model
44
44
 
@@ -65,6 +65,7 @@ If a bug affects supply, reserve minting, or tier lookup, it usually lives in th
65
65
  - adding a 721 hook through a deployer is easy; carrying the right ruleset behavior forward is where mistakes happen
66
66
  - projects should be explicit about whether the hook affects pay, cash out, or only metadata-facing paths
67
67
  - 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
68
+ - active delegated vote totals are queryable via `getPastTotalActiveVotes(blockNumber)` and `getTotalActiveVotes()`. These totals include only voting units held by accounts with a nonzero delegate, so a token in undelegated custody does not count; this is a governance/reward-participation primitive, not the owner-based tier reward denominator.
68
69
 
69
70
  ## Where state lives
70
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.69",
3
+ "version": "0.0.70",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@bananapus/address-registry-v6": "^0.0.33",
29
- "@bananapus/core-v6": "^0.0.81",
29
+ "@bananapus/core-v6": "^0.0.85",
30
30
  "@bananapus/ownable-v6": "^0.0.36",
31
31
  "@bananapus/permission-ids-v6": "^0.0.29",
32
32
  "@openzeppelin/contracts": "5.6.1",
@@ -13,6 +13,7 @@
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
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.
16
+ - Also verify the active delegated vote trace (`_activeSupplyCheckpoints`, read via `getPastTotalActiveVotes` and `getTotalActiveVotes`) moves only when voting units enter or leave nonzero delegation. It is separate from owner-based tier reward eligibility.
16
17
  - If you touch permissions, verify the caller path and permission constants still line up with the downstream ecosystem package that defines them.
17
18
  - If you touch URI behavior, confirm whether the issue belongs in this repo or in a downstream resolver contract that the hook calls.
18
19
 
@@ -20,7 +20,7 @@
20
20
  - Reserve accounting: edits around `reserveFrequency`, pending reserves, or owner minting must preserve the store's supply protections.
21
21
  - Tier splits: split forwarding changes affect both payer economics and project treasury accounting. Check both `beforePayRecordedWith` and the distribution path.
22
22
  - Discount behavior: price discounts affect mint eligibility but cash-out weight still tracks the original tier price. Do not conflate the two.
23
- - Voting units: verify whether a tier uses explicit voting units or falls back to price-based voting power before changing governance-facing math.
23
+ - Voting units: verify whether a tier uses explicit voting units or falls back to price-based voting power before changing governance-facing math. Active vote totals count only units delegated to nonzero delegates and are separate from tier reward eligibility.
24
24
  - Tier removal and cleanup: removing tiers is not the same as cleaning the sorted tier list. Storage cleanup behavior matters.
25
25
  - Default reserve beneficiary changes: they affect which tiers count pending reserves unless a tier-specific beneficiary overrides it. That is an economic change, not just an admin update.
26
26
 
@@ -1,9 +1,10 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity 0.8.28;
3
3
 
4
- import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
5
4
  import {Votes} from "@openzeppelin/contracts/governance/utils/Votes.sol";
5
+ import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
6
6
  import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
7
+ import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
7
8
  import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol";
8
9
 
9
10
  import {IJB721Checkpoints} from "./interfaces/IJB721Checkpoints.sol";
@@ -19,6 +20,7 @@ import {IJB721TiersHookStore} from "./interfaces/IJB721TiersHookStore.sol";
19
20
  /// `address(this)` — correct behavior, tiny gas overhead.
20
21
  contract JB721Checkpoints is Votes, IJB721Checkpoints {
21
22
  using Checkpoints for Checkpoints.Trace160;
23
+ using Checkpoints for Checkpoints.Trace208;
22
24
 
23
25
  //*********************************************************************//
24
26
  // --------------------------- custom errors ------------------------- //
@@ -61,6 +63,13 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
61
63
  /// @custom:param tierId The tier to get the historical eligible voting units for.
62
64
  mapping(uint256 tierId => Checkpoints.Trace160) internal _tierEligibleUnitsOf;
63
65
 
66
+ //*********************************************************************//
67
+ // -------------------- private stored properties -------------------- //
68
+ //*********************************************************************//
69
+
70
+ /// @notice The total voting units currently delegated to nonzero delegates.
71
+ Checkpoints.Trace208 private _activeSupplyCheckpoints;
72
+
64
73
  //*********************************************************************//
65
74
  // -------------------------- constructor ---------------------------- //
66
75
  //*********************************************************************//
@@ -172,6 +181,21 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
172
181
  return _tierEligibleUnitsOf[tierId].upperLookupRecent(uint96(_validateTimepoint(blockNumber)));
173
182
  }
174
183
 
184
+ /// @notice The total delegated voting units at a past block.
185
+ /// @dev This tracks delegated vote participation and is separate from tier reward eligibility.
186
+ /// @param timepoint The past block number to look up.
187
+ /// @return activeVotes The total voting units delegated to nonzero delegates at `timepoint`.
188
+ function getPastTotalActiveVotes(uint256 timepoint) external view override returns (uint256 activeVotes) {
189
+ activeVotes = _activeSupplyCheckpoints.upperLookupRecent(_validateTimepoint(timepoint));
190
+ }
191
+
192
+ /// @notice The current total delegated voting units.
193
+ /// @dev This tracks delegated vote participation and is separate from tier reward eligibility.
194
+ /// @return activeVotes The current total voting units delegated to nonzero delegates.
195
+ function getTotalActiveVotes() external view override returns (uint256 activeVotes) {
196
+ activeVotes = _activeSupplyCheckpoints.latest();
197
+ }
198
+
175
199
  /// @notice The owner of an NFT at a past block.
176
200
  /// @dev Returns `address(0)` for tokens that have never been enrolled (via `delegate(address, uint256[])`) or
177
201
  /// transferred. Unenrolled tokens are ineligible for snapshot-based distribution.
@@ -194,6 +218,60 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
194
218
  return address(uint160(checkpoints.upperLookupRecent(blockNumber96)));
195
219
  }
196
220
 
221
+ //*********************************************************************//
222
+ // ---------------------- internal transactions ---------------------- //
223
+ //*********************************************************************//
224
+
225
+ /// @notice Track active-vote-total changes when an account changes its delegate.
226
+ /// @dev Delegating to a nonzero address makes all of `account`'s voting units active. Clearing delegation removes
227
+ /// all of `account`'s voting units from the active total. Redelegating between two nonzero delegates only moves
228
+ /// votes inside OZ `Votes`, so the active total does not change.
229
+ /// @param account The account whose delegation is changing.
230
+ /// @param delegatee The new delegate. Use `address(0)` to clear delegation.
231
+ function _delegate(address account, address delegatee) internal virtual override {
232
+ // Read the current delegate before OZ mutates the delegate mapping.
233
+ address oldDelegate = delegates(account);
234
+
235
+ // Read the account's current voting units so any active-total delta matches the units OZ moves.
236
+ uint256 votingUnits = _getVotingUnits(account);
237
+
238
+ // Let OZ update the delegate mapping and the per-delegate vote checkpoints.
239
+ super._delegate({account: account, delegatee: delegatee});
240
+
241
+ // If the account had no delegate and now has one, its voting units just became active.
242
+ if (oldDelegate == address(0) && delegatee != address(0)) {
243
+ // Add the account's voting units to the checkpointed active total.
244
+ _updateActiveVotes({amount: votingUnits, increase: true});
245
+ } else if (oldDelegate != address(0) && delegatee == address(0)) {
246
+ // If the account had a delegate and now has none, its voting units just became inactive.
247
+ _updateActiveVotes({amount: votingUnits, increase: false});
248
+ }
249
+ }
250
+
251
+ /// @notice Track active-vote-total changes when voting units move between accounts.
252
+ /// @dev Moving voting units between two accounts with the same delegation status does not change the active total.
253
+ /// Moving voting units out of a delegated account and into an undelegated account decreases the active total, while
254
+ /// moving voting units out of an undelegated account and into a delegated account increases it.
255
+ /// @param from The account whose voting units are leaving. `address(0)` means the units are being minted.
256
+ /// @param to The account whose voting units are arriving. `address(0)` means the units are being burned.
257
+ /// @param amount The voting units moving between `from` and `to`.
258
+ function _transferVotingUnits(address from, address to, uint256 amount) internal virtual override {
259
+ // The active total decreases if units leave an account that already has a nonzero delegate.
260
+ bool decreaseActiveVotes = from != address(0) && delegates(from) != address(0);
261
+
262
+ // The active total increases if units arrive at an account that already has a nonzero delegate.
263
+ bool increaseActiveVotes = to != address(0) && delegates(to) != address(0);
264
+
265
+ // Let OZ update total voting-unit supply and per-delegate checkpoints first.
266
+ super._transferVotingUnits({from: from, to: to, amount: amount});
267
+
268
+ // If both sides are delegated or both sides are undelegated, the active total is unchanged.
269
+ if (decreaseActiveVotes == increaseActiveVotes) return;
270
+
271
+ // Otherwise apply the one-sided active-total delta implied by the receiver's delegated status.
272
+ _updateActiveVotes({amount: amount, increase: increaseActiveVotes});
273
+ }
274
+
197
275
  //*********************************************************************//
198
276
  // ----------------------- internal views ---------------------------- //
199
277
  //*********************************************************************//
@@ -210,6 +288,23 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
210
288
  // ------------------------ private helpers -------------------------- //
211
289
  //*********************************************************************//
212
290
 
291
+ /// @notice Update the checkpointed total of delegated voting units.
292
+ /// @dev Writes at most one active-total checkpoint at the current OZ clock. A zero amount is ignored so zero-value
293
+ /// delegation or transfer hooks do not create empty checkpoints.
294
+ /// @param amount The amount of voting units to add or remove.
295
+ /// @param increase Whether to add `amount`; if false, `amount` is removed.
296
+ function _updateActiveVotes(uint256 amount, bool increase) private {
297
+ // Ignore zero-unit updates because they do not change the active total.
298
+ if (amount == 0) return;
299
+
300
+ // Calculate the next active total by adding or subtracting from the latest checkpointed value.
301
+ uint256 updated =
302
+ increase ? _activeSupplyCheckpoints.latest() + amount : _activeSupplyCheckpoints.latest() - amount;
303
+
304
+ // Write the new active total at the current ERC-6372 clock using the same uint208 width as OZ `Votes`.
305
+ _activeSupplyCheckpoints.push({key: clock(), value: SafeCast.toUint208(updated)});
306
+ }
307
+
213
308
  /// @notice Add or remove units from a tier's eligible-voting-units checkpoint at the current block.
214
309
  /// @param tierId The tier whose eligible-voting-units trace to update.
215
310
  /// @param amount The voting units to add or remove.
@@ -1,13 +1,19 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity ^0.8.0;
3
3
 
4
+ import {IJBActiveVotes} from "@bananapus/core-v6/src/interfaces/IJBActiveVotes.sol";
4
5
  import {IERC5805} from "@openzeppelin/contracts/interfaces/IERC5805.sol";
5
6
  import {IJB721TiersHookStore} from "./IJB721TiersHookStore.sol";
6
7
 
7
8
  /// @notice A checkpoint module that provides IVotes-compatible checkpointed voting power for a JB721TiersHook.
8
9
  /// @dev Deployed as a clone via JB721CheckpointsDeployer during hook initialization. One module per hook.
9
10
  /// Pass this address to JBTokenDistributor as the IVotes token.
10
- interface IJB721Checkpoints is IERC5805 {
11
+ interface IJB721Checkpoints is IERC5805, IJBActiveVotes {
12
+ /// @notice The store that holds tier and voting data for the hook's NFTs.
13
+ /// @return The store contract.
14
+ // forge-lint: disable-next-line(mixed-case-function)
15
+ function STORE() external view returns (IJB721TiersHookStore);
16
+
11
17
  /// @notice The total eligible voting units of a tier at a past block.
12
18
  /// @dev "Eligible" means a token has an owner checkpoint: it was enrolled via `delegate(address,uint256[])` or
13
19
  /// transferred at least once. Minted-but-unenrolled tokens are excluded, mirroring `ownerOfAt` eligibility, and
@@ -30,11 +36,6 @@ interface IJB721Checkpoints is IERC5805 {
30
36
  /// @return The owner of the token at `blockNumber`, or zero if the token is unenrolled or has no known owner.
31
37
  function ownerOfAt(uint256 tokenId, uint256 blockNumber) external view returns (address);
32
38
 
33
- /// @notice The store that holds tier and voting data for the hook's NFTs.
34
- /// @return The store contract.
35
- // forge-lint: disable-next-line(mixed-case-function)
36
- function STORE() external view returns (IJB721TiersHookStore);
37
-
38
39
  /// @notice Delegates voting power and enrolls tokens for distribution eligibility.
39
40
  /// @dev Writes per-token owner checkpoints so `ownerOfAt` can prove ownership at past blocks.
40
41
  /// Only the current token owner can enroll. Tokens without checkpoints are ineligible for snapshot-based