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