@bananapus/distributor-v6 0.0.3 → 0.0.5

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.
@@ -0,0 +1,338 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+
6
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7
+ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
8
+ import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
9
+ import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
10
+
11
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
12
+ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
13
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
14
+ import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
15
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
16
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
17
+
18
+ import {JB721Distributor} from "../../src/JB721Distributor.sol";
19
+ import {JBDistributor} from "../../src/JBDistributor.sol";
20
+ import {IJBDistributor} from "../../src/interfaces/IJBDistributor.sol";
21
+
22
+ /// @notice Mock JB directory for H-26 tests.
23
+ contract H26MockDirectory {
24
+ mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
25
+ mapping(uint256 projectId => address controller) public controllers;
26
+
27
+ function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
28
+ terminals[projectId][terminal] = isTerminal;
29
+ }
30
+
31
+ function setController(uint256 projectId, address controller) external {
32
+ controllers[projectId] = controller;
33
+ }
34
+
35
+ function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
36
+ return terminals[projectId][address(terminal)];
37
+ }
38
+
39
+ function controllerOf(uint256 projectId) external view returns (IERC165) {
40
+ return IERC165(controllers[projectId]);
41
+ }
42
+ }
43
+
44
+ /// @notice Simple ERC20 reward token for H-26 tests.
45
+ contract H26MockRewardToken is ERC20 {
46
+ constructor() ERC20("Reward", "RWD") {}
47
+
48
+ function mint(address to, uint256 amount) external {
49
+ _mint(to, amount);
50
+ }
51
+ }
52
+
53
+ /// @notice Mock store that tracks tiers and token-to-tier mappings.
54
+ contract H26MockStore {
55
+ uint256 public maxTier;
56
+ mapping(uint256 tierId => JB721Tier) public tiers;
57
+ mapping(uint256 tierId => uint256) public burned;
58
+ mapping(uint256 tokenId => uint256 tierId) public tokenTiers;
59
+
60
+ function setMaxTierIdOf(uint256 maxTierId) external {
61
+ maxTier = maxTierId;
62
+ }
63
+
64
+ function maxTierIdOf(address) external view returns (uint256) {
65
+ return maxTier;
66
+ }
67
+
68
+ function setTier(uint256 tierId, JB721Tier memory tier) external {
69
+ tiers[tierId] = tier;
70
+ }
71
+
72
+ function tierOf(address, uint256 id, bool) external view returns (JB721Tier memory) {
73
+ return tiers[id];
74
+ }
75
+
76
+ function setTokenTier(uint256 tokenId, uint256 tierId) external {
77
+ tokenTiers[tokenId] = tierId;
78
+ }
79
+
80
+ function tierOfTokenId(address, uint256 tokenId, bool) external view returns (JB721Tier memory) {
81
+ return tiers[tokenTiers[tokenId]];
82
+ }
83
+
84
+ function setBurnedFor(uint256 tierId, uint256 count) external {
85
+ burned[tierId] = count;
86
+ }
87
+
88
+ function numberOfBurnedFor(address, uint256 tierId) external view returns (uint256) {
89
+ return burned[tierId];
90
+ }
91
+ }
92
+
93
+ /// @notice Mock checkpoints with explicit per-address vote overrides for H-26 testing.
94
+ /// @dev getPastTotalSupply computes from the store; getPastVotes uses explicit overrides.
95
+ contract H26MockCheckpoints {
96
+ H26MockStore public store;
97
+ address public hookAddr;
98
+
99
+ /// @dev Override: if non-zero, getPastTotalSupply returns this instead of computing from store.
100
+ uint256 public totalSupplyOverride;
101
+
102
+ /// @dev Per-address vote overrides. If set, getPastVotes returns this value.
103
+ mapping(address => uint256) public votesOverride;
104
+
105
+ /// @dev Tracks whether a per-address override was explicitly set (to allow setting 0).
106
+ mapping(address => bool) public votesOverrideSet;
107
+
108
+ constructor(H26MockStore _store, address _hook) {
109
+ store = _store;
110
+ hookAddr = _hook;
111
+ }
112
+
113
+ function setTotalSupplyOverride(uint256 value) external {
114
+ totalSupplyOverride = value;
115
+ }
116
+
117
+ function setVotesOverride(address account, uint256 value) external {
118
+ votesOverride[account] = value;
119
+ votesOverrideSet[account] = true;
120
+ }
121
+
122
+ function getPastTotalSupply(uint256) external view returns (uint256 total) {
123
+ if (totalSupplyOverride != 0) return totalSupplyOverride;
124
+ // Dynamically compute from store: sum over tiers of (minted - burned) * votingUnits.
125
+ uint256 maxTierCount = store.maxTier();
126
+ for (uint256 i = 1; i <= maxTierCount; i++) {
127
+ JB721Tier memory tier = store.tierOf(hookAddr, i, false);
128
+ if (tier.id == 0 || tier.initialSupply == 0) continue;
129
+ uint256 burnedCount = store.burned(i);
130
+ uint256 held = tier.initialSupply - tier.remainingSupply - burnedCount;
131
+ total += held * tier.votingUnits;
132
+ }
133
+ }
134
+
135
+ function getPastVotes(address account, uint256) external view returns (uint256) {
136
+ if (votesOverrideSet[account]) return votesOverride[account];
137
+ // Default: return max so min(votingUnits, pastVotes) = votingUnits for any holder.
138
+ return type(uint256).max;
139
+ }
140
+ }
141
+
142
+ /// @notice Mock 721 hook for H-26 tests.
143
+ contract H26MockHook {
144
+ H26MockStore public immutable _store;
145
+ H26MockCheckpoints public _checkpoints;
146
+
147
+ mapping(uint256 tokenId => address owner) public owners;
148
+
149
+ constructor(H26MockStore store_) {
150
+ _store = store_;
151
+ _checkpoints = new H26MockCheckpoints(store_, address(this));
152
+ }
153
+
154
+ function STORE() external view returns (H26MockStore) {
155
+ return _store;
156
+ }
157
+
158
+ function CHECKPOINTS() external view returns (H26MockCheckpoints) {
159
+ return _checkpoints;
160
+ }
161
+
162
+ function ownerOf(uint256 tokenId) external view returns (address) {
163
+ address owner = owners[tokenId];
164
+ require(owner != address(0), "ERC721: invalid token ID");
165
+ return owner;
166
+ }
167
+
168
+ function setOwner(uint256 tokenId, address owner) external {
169
+ owners[tokenId] = owner;
170
+ }
171
+
172
+ function burn(uint256 tokenId) external {
173
+ delete owners[tokenId];
174
+ }
175
+ }
176
+
177
+ /// @notice Tests for H-26: per-owner voting power cap in JB721Distributor.
178
+ /// @dev Verifies that an owner holding multiple NFTs cannot claim more rewards than their
179
+ /// historical voting power allows. The `_vestTokenIds` override in JB721Distributor
180
+ /// tracks consumed voting power per owner and caps each NFT's effective stake.
181
+ contract H26VotingPowerCapTest is Test {
182
+ JB721Distributor distributor;
183
+ H26MockRewardToken rewardToken;
184
+ H26MockHook hook;
185
+ H26MockStore store;
186
+ H26MockDirectory directory;
187
+
188
+ address alice = makeAddr("alice");
189
+ address bob = makeAddr("bob");
190
+
191
+ uint256 constant PROJECT_ID = 1;
192
+ uint256 constant ROUND_DURATION = 100;
193
+ uint256 constant VESTING_ROUNDS = 4;
194
+
195
+ function setUp() public {
196
+ store = new H26MockStore();
197
+ hook = new H26MockHook(store);
198
+ directory = new H26MockDirectory();
199
+
200
+ distributor = new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
201
+
202
+ // Register this test contract as a terminal for PROJECT_ID so processSplitWith works.
203
+ directory.setTerminal(PROJECT_ID, address(this), true);
204
+
205
+ rewardToken = new H26MockRewardToken();
206
+
207
+ JB721TierFlags memory flags;
208
+
209
+ // Tier 1: votingUnits = 50 each.
210
+ store.setMaxTierIdOf(1);
211
+ store.setTier(
212
+ 1,
213
+ JB721Tier({
214
+ id: 1,
215
+ price: 1 ether,
216
+ remainingSupply: 7,
217
+ initialSupply: 10,
218
+ votingUnits: 50,
219
+ reserveFrequency: 0,
220
+ reserveBeneficiary: address(0),
221
+ encodedIPFSUri: bytes32(0),
222
+ category: 0,
223
+ discountPercent: 0,
224
+ flags: flags,
225
+ splitPercent: 0,
226
+ resolvedUri: ""
227
+ })
228
+ );
229
+
230
+ // Alice owns 3 NFTs: tokens 1, 2, 3 — all tier 1 (50 voting units each).
231
+ store.setTokenTier(1, 1);
232
+ store.setTokenTier(2, 1);
233
+ store.setTokenTier(3, 1);
234
+ hook.setOwner(1, alice);
235
+ hook.setOwner(2, alice);
236
+ hook.setOwner(3, alice);
237
+ }
238
+
239
+ // =====================================================================
240
+ // Helpers
241
+ // =====================================================================
242
+
243
+ function _advanceToRound(uint256 round) internal {
244
+ uint256 targetTimestamp = distributor.roundStartTimestamp(round) + 1;
245
+ if (block.timestamp < targetTimestamp) {
246
+ vm.warp(targetTimestamp);
247
+ }
248
+ vm.roll(block.number + 1);
249
+ }
250
+
251
+ function _fundHook(uint256 amount) internal {
252
+ rewardToken.mint(address(this), amount);
253
+ rewardToken.approve(address(distributor), amount);
254
+ distributor.fund(address(hook), IERC20(address(rewardToken)), amount);
255
+ }
256
+
257
+ // =====================================================================
258
+ // H-26 Tests
259
+ // =====================================================================
260
+
261
+ /// @notice Owner has 3 NFTs (50 voting units each = 150 total) but only 100 past votes.
262
+ /// Should get rewards for only 100 votes total, not 150.
263
+ function test_h26_multipleNFTsCappedAtVotingPower() public {
264
+ // Alice has 3 NFTs x 50 voting units = 150 votingUnits total.
265
+ // But her pastVotes is only 100 — so she should be capped at 100.
266
+ hook._checkpoints().setVotesOverride(alice, 100);
267
+
268
+ // Total supply = 3 minted * 50 voting units = 150.
269
+ // (store has initialSupply=10, remainingSupply=7, so 3 minted)
270
+
271
+ _fundHook(1500 ether);
272
+
273
+ uint256[] memory tokenIds = new uint256[](3);
274
+ tokenIds[0] = 1;
275
+ tokenIds[1] = 2;
276
+ tokenIds[2] = 3;
277
+ IERC20[] memory tokens = new IERC20[](1);
278
+ tokens[0] = IERC20(address(rewardToken));
279
+
280
+ distributor.beginVesting(address(hook), tokenIds, tokens);
281
+
282
+ // Without the cap, Alice would get: mulDiv(1500, 50, 150) * 3 = 500 * 3 = 1500 ether.
283
+ // With the cap: Alice has 100 past votes across 3 NFTs (50 each).
284
+ // NFT 1: min(50, 100 remaining) = 50 -> consumed = 50
285
+ // NFT 2: min(50, 50 remaining) = 50 -> consumed = 100
286
+ // NFT 3: min(50, 0 remaining) = 0 -> skipped
287
+ // Total effective stake = 100 out of 150 total supply.
288
+ // Alice total = mulDiv(1500, 50, 150) + mulDiv(1500, 50, 150) = 500 + 500 = 1000 ether.
289
+ uint256 claimed1 = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
290
+ uint256 claimed2 = distributor.claimedFor(address(hook), 2, IERC20(address(rewardToken)));
291
+ uint256 claimed3 = distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
292
+
293
+ uint256 totalClaimed = claimed1 + claimed2 + claimed3;
294
+
295
+ // Each NFT gets mulDiv(1500, 50, 150) = 500 ether for 50 effective units,
296
+ // but NFT 3 gets 0 (remaining pastVotes exhausted).
297
+ assertEq(claimed1, 500 ether, "NFT 1: should get full 50-unit share");
298
+ assertEq(claimed2, 500 ether, "NFT 2: should get full 50-unit share");
299
+ assertEq(claimed3, 0, "NFT 3: should get 0 (voting power exhausted)");
300
+ assertEq(totalClaimed, 1000 ether, "Total claimed should be capped at 100/150 of distributable");
301
+
302
+ // Verify total vesting reflects the capped amount, not the full 150-unit amount.
303
+ assertEq(
304
+ distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))),
305
+ 1000 ether,
306
+ "Total vesting should reflect capped amount"
307
+ );
308
+ }
309
+
310
+ /// @notice An owner with 1 NFT and sufficient past votes gets the full reward (backward compatibility).
311
+ function test_h26_singleNFTUnaffected() public {
312
+ // Alice has 3 NFTs but we only vest 1 — pastVotes = 100 which is >= 50 voting units.
313
+ hook._checkpoints().setVotesOverride(alice, 100);
314
+
315
+ _fundHook(1500 ether);
316
+
317
+ // Only vest token 1.
318
+ uint256[] memory tokenIds = new uint256[](1);
319
+ tokenIds[0] = 1;
320
+ IERC20[] memory tokens = new IERC20[](1);
321
+ tokens[0] = IERC20(address(rewardToken));
322
+
323
+ distributor.beginVesting(address(hook), tokenIds, tokens);
324
+
325
+ // With 1 NFT at 50 voting units, pastVotes=100 is more than enough.
326
+ // Alice's NFT 1 stake = min(50, 100) = 50.
327
+ // Share = mulDiv(1500, 50, 150) = 500 ether.
328
+ uint256 claimed = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
329
+ assertEq(claimed, 500 ether, "Single NFT should get full reward when pastVotes >= votingUnits");
330
+
331
+ // Verify total vesting.
332
+ assertEq(
333
+ distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))),
334
+ 500 ether,
335
+ "Total vesting should equal single NFT share"
336
+ );
337
+ }
338
+ }