@bananapus/721-hook-v6 0.0.62 → 0.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,7 @@ This repo does more than "mint NFTs on pay." It changes how payment value, tier
34
34
  | `JB721TiersHookDeployer` | Clone factory for deploying a hook for an existing project. |
35
35
  | `JB721TiersHookProjectDeployer` | Convenience deployer for launching a project with a hook already wired in. |
36
36
  | `JB721Hook` | Abstract base for 721 pay and cash-out hook behavior. |
37
+ | `JB721Checkpoints` | Per-hook IVotes checkpoint module. Tracks historical owner checkpoints plus per-tier eligible voting units (`getPastTierVotingUnits`) for tier-scoped reward distribution. |
37
38
 
38
39
  ## Mental Model
39
40
 
@@ -59,6 +60,7 @@ If a bug affects supply, reserve minting, or tier lookup, it usually lives in th
59
60
  - custom token URI resolvers should be treated as part of the trusted surface
60
61
  - adding a 721 hook through a deployer is easy; carrying the right ruleset behavior forward is where mistakes happen
61
62
  - projects should be explicit about whether the hook affects pay, cash out, or only metadata-facing paths
63
+ - per-tier eligible voting units are queryable via `getPastTierVotingUnits(tierId, blockNumber)` for tier-scoped reward denominators, but minting alone does not enroll a token: a token only counts toward that total once it is enrolled (`delegate(address, uint256[])`) or transferred for the first time, and stops counting when burned
62
64
 
63
65
  ## Where State Lives
64
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.62",
3
+ "version": "0.0.63",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,6 +12,7 @@
12
12
  - If you edit tier config or metadata behavior, inspect the corresponding structs and interfaces in `src/structs/` and `src/interfaces/`.
13
13
  - If you edit reserve behavior, verify pending reserve counts, default reserve beneficiary semantics, and cash-out denominator effects together.
14
14
  - If you edit discount behavior, verify mint price and cash-out weight separately. They are intentionally not the same quantity.
15
+ - If you touch checkpoint, `onTransfer`, or `delegate` behavior, verify the per-tier eligible-voting-units trace (`_tierEligibleUnitsOf`, read via `getPastTierVotingUnits`) still moves only on eligibility changes: increment on enrollment or a token's first transfer, decrement on burn, and nothing on mint (so the mint path keeps its zero added checkpoint gas). Keep it in lockstep with `ownerOfAt` eligibility.
15
16
  - If you touch permissions, verify the caller path and permission constants still line up with the downstream ecosystem package that defines them.
16
17
  - If you touch URI behavior, confirm whether the issue belongs in this repo or in a downstream resolver contract that the hook calls.
17
18
 
@@ -50,6 +50,12 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
50
50
  /// @custom:param tokenId The token ID to get historical owner checkpoints for.
51
51
  mapping(uint256 tokenId => Checkpoints.Trace160) internal _ownerCheckpointsOf;
52
52
 
53
+ /// @notice Checkpointed total eligible voting units per tier. A token contributes its tier voting units from the
54
+ /// block it first gains an owner checkpoint (enrollment or first transfer) until it is burned. Mints write
55
+ /// nothing, mirroring `_ownerCheckpointsOf` eligibility. Distributors read this as the tier-scoped denominator.
56
+ /// @custom:param tierId The tier to get the historical eligible voting units for.
57
+ mapping(uint256 tierId => Checkpoints.Trace160) internal _tierEligibleUnitsOf;
58
+
53
59
  //*********************************************************************//
54
60
  // -------------------------- constructor ---------------------------- //
55
61
  //*********************************************************************//
@@ -85,10 +91,15 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
85
91
  revert JB721Checkpoints_NotOwner({tokenId: tokenId, caller: msg.sender});
86
92
  }
87
93
 
88
- // Write an owner checkpoint if the token has none yet.
94
+ // Write an owner checkpoint if the token has none yet, and enroll its tier voting units.
89
95
  if (_ownerCheckpointsOf[tokenId].length() == 0) {
90
96
  // forge-lint: disable-next-line(unsafe-typecast)
91
97
  _ownerCheckpointsOf[tokenId].push({key: uint96(block.number), value: uint160(msg.sender)});
98
+ _updateTierEligibleUnits({
99
+ tierId: STORE.tierIdOfToken(tokenId),
100
+ amount: STORE.tierVotingUnitsOfTokenId({hook: hook, tokenId: tokenId}),
101
+ increase: true
102
+ });
92
103
  }
93
104
 
94
105
  unchecked {
@@ -114,14 +125,31 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
114
125
  function onTransfer(address from, address to, uint256 tokenId) external override {
115
126
  if (msg.sender != hook) revert JB721Checkpoints_Unauthorized({caller: msg.sender, hook: hook});
116
127
 
128
+ // Look up this token's tier voting units (lightweight getter — avoids full tier struct construction).
129
+ uint256 votingUnits = STORE.tierVotingUnitsOfTokenId({hook: hook, tokenId: tokenId});
130
+
131
+ // On mint (`from == 0`) nothing is checkpointed: the token is ineligible until enrolled or transferred,
132
+ // so neither the owner trace nor the per-tier eligible-units trace is written.
117
133
  if (from != address(0)) {
134
+ Checkpoints.Trace160 storage ownerTrace = _ownerCheckpointsOf[tokenId];
135
+ bool wasEligible = ownerTrace.length() != 0;
136
+
118
137
  // forge-lint: disable-next-line(unsafe-typecast)
119
- _ownerCheckpointsOf[tokenId].push({key: uint96(block.number), value: uint160(to)});
138
+ ownerTrace.push({key: uint96(block.number), value: uint160(to)});
139
+
140
+ if (to == address(0)) {
141
+ // Burn: remove the tier's units only if the token was already eligible.
142
+ if (wasEligible) {
143
+ _updateTierEligibleUnits({
144
+ tierId: STORE.tierIdOfToken(tokenId), amount: votingUnits, increase: false
145
+ });
146
+ }
147
+ } else if (!wasEligible) {
148
+ // First transfer of a never-enrolled token makes it eligible: add the tier's units.
149
+ _updateTierEligibleUnits({tierId: STORE.tierIdOfToken(tokenId), amount: votingUnits, increase: true});
150
+ }
120
151
  }
121
152
 
122
- // Look up this token's tier 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
  }
@@ -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)