@bananapus/distributor-v6 0.0.3

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,120 @@
1
+ # User Journeys
2
+
3
+ ## Repo Purpose
4
+
5
+ This repo distributes already-owned assets over time. It snapshots stake, starts vesting rounds, and lets eligible recipients collect what has unlocked.
6
+
7
+ ## Primary Actors
8
+
9
+ - teams funding a distributor from a split or post-mint allocation
10
+ - token holders or NFT holders collecting vested rewards
11
+ - operators configuring round timing and deployment shape
12
+ - auditors reviewing snapshot timing and stake-accounting correctness
13
+
14
+ ## Key Surfaces
15
+
16
+ - `JBDistributor`: shared round and vesting engine
17
+ - `JBTokenDistributor`: ERC-20 distributor using `IVotes`
18
+ - `JB721Distributor`: NFT distributor using tier voting units
19
+
20
+ ## Journey 1: Fund A Distributor
21
+
22
+ **Actor:** project or payout flow.
23
+
24
+ **Intent:** move owned assets into a distributor that will vest them over time.
25
+
26
+ **Preconditions**
27
+ - the correct asset and distributor type are chosen
28
+ - the distributor actually receives the inventory it is expected to vest
29
+
30
+ **Main Flow**
31
+ 1. Fund the distributor directly or through a payout split.
32
+ 2. Confirm the tracked balance matches what the distributor received.
33
+ 3. Use the distributor as the vesting surface, not as the source of entitlement logic.
34
+
35
+ **Failure Modes**
36
+ - wrong asset funded
37
+ - underfunded distributor
38
+ - caller assumes funding alone starts vesting
39
+
40
+ **Postconditions**
41
+ - the distributor holds the asset inventory for future rounds
42
+
43
+ ## Journey 2: Start A Vesting Round
44
+
45
+ **Actor:** any caller.
46
+
47
+ **Intent:** snapshot the current round and begin vesting.
48
+
49
+ **Preconditions**
50
+ - the round timing and parameters are valid
51
+ - the stake source is usable and non-zero
52
+
53
+ **Main Flow**
54
+ 1. Call `beginVesting`.
55
+ 2. The distributor snapshots the relevant balance and stake source.
56
+ 3. Vesting entries become claimable over the configured schedule.
57
+
58
+ **Failure Modes**
59
+ - zero total stake
60
+ - bad deployment parameters such as zero round duration or zero vesting rounds
61
+ - stake snapshot is stale or surprising to operators
62
+
63
+ **Postconditions**
64
+ - a new vesting round exists with fixed snapshot assumptions
65
+
66
+ ## Journey 3: Collect Vested Rewards
67
+
68
+ **Actor:** eligible recipient.
69
+
70
+ **Intent:** collect the share that has unlocked for a round.
71
+
72
+ **Preconditions**
73
+ - the recipient is authorized under the distributor type
74
+ - some share has already vested
75
+
76
+ **Main Flow**
77
+ 1. Call the relevant claim function.
78
+ 2. The distributor checks authority and unlocked amount.
79
+ 3. The vested share transfers to the claimant.
80
+
81
+ **Failure Modes**
82
+ - invalid claimant
83
+ - claim batch includes invalid 721 token IDs
84
+ - reward token transfer fails
85
+
86
+ **Postconditions**
87
+ - vested rewards move to the claimant
88
+
89
+ ## Journey 4: Recycle Forfeited 721 Rewards
90
+
91
+ **Actor:** caller using the 721 distributor path.
92
+
93
+ **Intent:** release rewards tied to burned NFTs back into the future distribution pool.
94
+
95
+ **Preconditions**
96
+ - the distributor type is 721-based
97
+ - the relevant NFTs are burned or otherwise forfeited under the configured rules
98
+
99
+ **Main Flow**
100
+ 1. Call the forfeiture-release path.
101
+ 2. The distributor reduces current vesting obligations for those forfeited claims.
102
+ 3. The value remains in the distributor for future rounds instead of being destroyed.
103
+
104
+ **Failure Modes**
105
+ - caller expects the same behavior from the token distributor
106
+ - off-chain systems treat forfeited value as burned instead of recycled
107
+
108
+ **Postconditions**
109
+ - forfeited 721 rewards return to the future distributable pool
110
+
111
+ ## Trust Boundaries
112
+
113
+ - this repo trusts `JBDirectory` for authenticated split-hook caller checks
114
+ - `JBTokenDistributor` trusts `IVotes` checkpoints
115
+ - `JB721Distributor` trusts the 721 hook's `CHECKPOINTS()` module for historical voting power and the store for tier metadata
116
+
117
+ ## Hand-Offs
118
+
119
+ - Use the upstream repo that funded the distributor when the question is about why an allocation exists.
120
+ - Use [nana-721-hook-v6](../nana-721-hook-v6/USER_JOURNEYS.md) when the stake source is a tiered 721 hook.
package/foundry.toml ADDED
@@ -0,0 +1,22 @@
1
+ [profile.default]
2
+ solc = '0.8.28'
3
+ evm_version = 'cancun'
4
+ optimizer_runs = 200
5
+ libs = ["node_modules", "lib"]
6
+ fs_permissions = [{ access = "read-write", path = "./"}]
7
+
8
+ [fuzz]
9
+ runs = 4096
10
+
11
+ [invariant]
12
+ runs = 1024
13
+ depth = 100
14
+ fail_on_revert = false
15
+
16
+ [fmt]
17
+ number_underscore = "thousands"
18
+ multiline_func_header = "all"
19
+ wrap_comments = true
20
+
21
+ [rpc_endpoints]
22
+ ethereum = "${RPC_ETHEREUM_MAINNET}"
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@bananapus/distributor-v6",
3
+ "version": "0.0.3",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Bananapus/nana-distributor-v6"
8
+ },
9
+ "engines": {
10
+ "node": ">=20.0.0"
11
+ },
12
+ "scripts": {
13
+ "test": "forge test",
14
+ "test:fork": "forge test --fork-url $RPC_ETHEREUM_MAINNET",
15
+ "coverage": "forge coverage --match-path \"./src/*.sol\" --report lcov --report summary",
16
+ "deploy:mainnets": "source ./.env && npx sphinx propose ./script/Deploy.s.sol --networks mainnets",
17
+ "deploy:testnets": "source ./.env && npx sphinx propose ./script/Deploy.s.sol --networks testnets"
18
+ },
19
+ "dependencies": {
20
+ "@bananapus/721-hook-v6": "^0.0.36",
21
+ "@bananapus/core-v6": "^0.0.34",
22
+ "@bananapus/permission-ids-v6": "^0.0.17",
23
+ "@openzeppelin/contracts": "^5.6.1",
24
+ "@prb/math": "^4.1.0"
25
+ },
26
+ "devDependencies": {
27
+ "@sphinx-labs/plugins": "^0.33.2"
28
+ }
29
+ }
@@ -0,0 +1,25 @@
1
+ # Operations
2
+
3
+ ## Change checklist
4
+
5
+ | If you're editing... | Verify... |
6
+ |---|---|
7
+ | `JBDistributor` vesting math | Claim totals, `totalVestingAmountOf`, and pool balances still reconcile across rounds |
8
+ | `JBTokenDistributor` checkpoint logic | `getPastVotes` and `getPastTotalSupply` are read at the intended round-start block |
9
+ | `JB721Distributor` stake math | Minted, remaining, and burned supply still produce the intended tier-weighted total stake |
10
+ | `processSplitWith` | Terminal allowance flow and controller pre-funding flow both preserve actual received balances |
11
+ | Deployment inputs | `DIRECTORY_ADDRESS`, `ROUND_DURATION`, and `VESTING_ROUNDS` match the intended chain and operator plan |
12
+
13
+ ## Common failure modes
14
+
15
+ | Symptom | Likely cause |
16
+ |---|---|
17
+ | A holder gets no rewards in the token distributor | They never delegated, so `getPastVotes` returned zero |
18
+ | Rewards appear stuck in the distributor | Supply was undelegated, vesting never began for the target token IDs, or the round boundary assumption is wrong |
19
+ | 721 reward shares look diluted | Burned supply was not excluded correctly or token-to-tier mapping is wrong |
20
+ | Split-hook funding credits the wrong amount | The caller path was misclassified between allowance-pull and pre-funded controller flow |
21
+
22
+ ## Read Next
23
+
24
+ - [`script/Deploy.s.sol`](../script/Deploy.s.sol) when the failure might be deployment config rather than distributor math.
25
+ - [`test/invariant/JB721DistributorInvariant.t.sol`](../test/invariant/JB721DistributorInvariant.t.sol) when a local patch looks safe but may have broken a longer-lived accounting invariant.
@@ -0,0 +1,36 @@
1
+ # Runtime
2
+
3
+ ## Core role
4
+
5
+ `JBDistributor` tracks balances per `(hook, rewardToken)`, allocates a round's claimable amount when vesting begins, and releases rewards over fixed vesting rounds.
6
+
7
+ `JBTokenDistributor` uses `IVotes` checkpoints. Each `tokenId` encodes a staker address, and stake is `getPastVotes(encodedAddress, roundStartBlock(currentRound()))`.
8
+
9
+ `JB721Distributor` uses the 721 hook store. Stake is derived from each token's tier `votingUnits`, while total stake sums minted-minus-burned supply across all tiers.
10
+
11
+ ## High-risk areas
12
+
13
+ ### Round and checkpoint semantics
14
+
15
+ The token distributor depends on checkpointed voting power at the round start block. Holders must delegate for `getPastVotes` to count them, and undelegated supply can leave rewards stranded in the pool for later rounds.
16
+
17
+ ### Funding path split
18
+
19
+ `processSplitWith` supports two funding patterns:
20
+
21
+ - Terminal path: pull tokens via allowance and credit the actual received amount.
22
+ - Controller path: assume tokens were transferred before the hook call and credit `context.amount`.
23
+
24
+ Mixing these assumptions causes under- or over-accounting.
25
+
26
+ ### 721 burned-token behavior
27
+
28
+ The 721 distributor excludes burned NFTs from total stake and treats `ownerOf` failure as burn evidence. Changes to burn detection or tier accounting can change reward shares retroactively.
29
+
30
+ ## Tests to trust first
31
+
32
+ | Test file | What it covers |
33
+ |---|---|
34
+ | [`test/JBTokenDistributor.t.sol`](../test/JBTokenDistributor.t.sol) | Checkpointed vote allocation, non-delegated supply behavior, vesting flow, split-hook funding |
35
+ | [`test/JB721Distributor.t.sol`](../test/JB721Distributor.t.sol) | Tier-based share math, burned token handling, split-hook funding, vesting collection |
36
+ | [`test/invariant/JB721DistributorInvariant.t.sol`](../test/invariant/JB721DistributorInvariant.t.sol) | Longer-lived 721 accounting relationships that are easier to break than unit tests suggest |
package/remappings.txt ADDED
@@ -0,0 +1 @@
1
+ forge-std/=lib/forge-std/src/
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Script} from "forge-std/Script.sol";
5
+
6
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
+
8
+ import {JB721Distributor} from "../src/JB721Distributor.sol";
9
+
10
+ contract Deploy is Script {
11
+ function run() public {
12
+ vm.startBroadcast();
13
+
14
+ // Configure these values before deploying.
15
+ IJBDirectory directory = IJBDirectory(vm.envAddress("DIRECTORY_ADDRESS"));
16
+ uint256 roundDuration = vm.envUint("ROUND_DURATION");
17
+ uint256 vestingRounds = vm.envUint("VESTING_ROUNDS");
18
+
19
+ new JB721Distributor(directory, roundDuration, vestingRounds);
20
+
21
+ vm.stopBroadcast();
22
+ }
23
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "detectors_to_exclude": "timestamp,uninitialized-local,naming-convention,solc-version,shadowing-local",
3
+ "exclude_informational": true,
4
+ "exclude_low": false,
5
+ "exclude_medium": false,
6
+ "exclude_high": false,
7
+ "disable_color": false,
8
+ "filter_paths": "(mocks/|test/|node_modules/|lib/)",
9
+ "legacy_ast": false
10
+ }
@@ -0,0 +1,180 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {IJB721Checkpoints} from "@bananapus/721-hook-v6/src/interfaces/IJB721Checkpoints.sol";
5
+ import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
6
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
+ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
8
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
9
+ import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
10
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
11
+ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
12
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
13
+ import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
14
+ import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
15
+
16
+ import {IJB721Distributor} from "./interfaces/IJB721Distributor.sol";
17
+ import {JBDistributor} from "./JBDistributor.sol";
18
+
19
+ /// @notice A singleton distributor that distributes ERC-20 rewards to JB 721 NFT stakers with linear vesting.
20
+ /// @dev Any project can use this distributor by configuring a payout split with
21
+ /// `hook = this contract` and `beneficiary = address(their 721 hook)`.
22
+ /// @dev The stake weight of each NFT is its tier's `votingUnits`. Burned NFTs are excluded from the total stake
23
+ /// calculation and their unvested rewards can be reclaimed via `releaseForfeitedRewards`.
24
+ /// @dev Implements `IJBSplitHook` so it can receive tokens directly from Juicebox project payout splits.
25
+ contract JB721Distributor is JBDistributor, IJB721Distributor {
26
+ using SafeERC20 for IERC20;
27
+
28
+ //*********************************************************************//
29
+ // --------------------------- custom errors ------------------------- //
30
+ //*********************************************************************//
31
+
32
+ /// @notice Thrown when the caller is not a terminal or controller for the project.
33
+ error JB721Distributor_Unauthorized();
34
+
35
+ //*********************************************************************//
36
+ // ---------------- public immutable stored properties --------------- //
37
+ //*********************************************************************//
38
+
39
+ /// @notice The JB directory used to verify terminal/controller callers.
40
+ IJBDirectory public immutable DIRECTORY;
41
+
42
+ //*********************************************************************//
43
+ // -------------------------- constructor ---------------------------- //
44
+ //*********************************************************************//
45
+
46
+ /// @param directory The JB directory used to verify terminal/controller callers.
47
+ /// @param roundDuration_ The minimum amount of time stakers have to claim rewards, specified in blocks.
48
+ /// @param vestingRounds_ The number of rounds until tokens are fully vested.
49
+ constructor(
50
+ IJBDirectory directory,
51
+ uint256 roundDuration_,
52
+ uint256 vestingRounds_
53
+ )
54
+ JBDistributor(roundDuration_, vestingRounds_)
55
+ {
56
+ DIRECTORY = directory;
57
+ }
58
+
59
+ //*********************************************************************//
60
+ // ---------------------- receive ----------------------------------- //
61
+ //*********************************************************************//
62
+
63
+ /// @notice Allows the contract to receive native ETH (e.g. from payout splits).
64
+ receive() external payable {}
65
+
66
+ //*********************************************************************//
67
+ // ---------------------- external transactions ---------------------- //
68
+ //*********************************************************************//
69
+
70
+ /// @notice Receives tokens from a Juicebox payout split.
71
+ /// @dev Only callable by a terminal or controller for the project in the context.
72
+ /// @dev The hook address is read from `context.split.beneficiary`.
73
+ /// @dev The terminal grants an ERC-20 allowance before calling — we pull via `transferFrom`.
74
+ /// The controller sends tokens directly before calling — nothing to pull.
75
+ /// For native ETH, the terminal sends the amount as `msg.value`.
76
+ /// @param context The split hook context from the terminal or controller.
77
+ function processSplitWith(JBSplitHookContext calldata context) external payable override {
78
+ // Only terminals and controllers for the project can call this.
79
+ if (
80
+ !DIRECTORY.isTerminalOf(context.projectId, IJBTerminal(msg.sender))
81
+ && DIRECTORY.controllerOf(context.projectId) != IERC165(msg.sender)
82
+ ) revert JB721Distributor_Unauthorized();
83
+
84
+ // The target hook is the split's beneficiary.
85
+ address hook = address(context.split.beneficiary);
86
+
87
+ // If it's not a native-token transfer, check if the caller approved tokens (terminal pattern).
88
+ if (msg.value == 0 && context.amount != 0) {
89
+ uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
90
+ // Check if the caller has granted an allowance (terminal). If so, pull the tokens.
91
+ // The controller sends tokens before calling, so no pull is needed in that case.
92
+ uint256 allowance = IERC20(context.token).allowance(msg.sender, address(this));
93
+ if (allowance >= context.amount) {
94
+ // Terminal pattern: pull tokens via transferFrom.
95
+ IERC20(context.token).safeTransferFrom(msg.sender, address(this), context.amount);
96
+ }
97
+ // For both terminal and controller paths, credit actual received amount (handles fee-on-transfer).
98
+ _balanceOf[hook][IERC20(context.token)] += IERC20(context.token).balanceOf(address(this)) - balanceBefore;
99
+ } else if (msg.value != 0) {
100
+ // Native ETH: credit actual value received.
101
+ _balanceOf[hook][IERC20(context.token)] += msg.value;
102
+ }
103
+ }
104
+
105
+ //*********************************************************************//
106
+ // -------------------------- public views --------------------------- //
107
+ //*********************************************************************//
108
+
109
+ /// @notice Indicates whether this contract supports the given interface.
110
+ /// @param interfaceId The interface ID to check.
111
+ /// @return A flag indicating support.
112
+ function supportsInterface(bytes4 interfaceId) public pure override returns (bool) {
113
+ return interfaceId == type(IJB721Distributor).interfaceId || interfaceId == type(IJBSplitHook).interfaceId
114
+ || interfaceId == type(IERC165).interfaceId;
115
+ }
116
+
117
+ //*********************************************************************//
118
+ // ---------------------- internal transactions ---------------------- //
119
+ //*********************************************************************//
120
+
121
+ /// @notice Check if the account owns the given NFT token ID.
122
+ /// @param hook The hook the token belongs to.
123
+ /// @param tokenId The ID of the token to check.
124
+ /// @param account The account to check ownership for.
125
+ /// @return canClaim True if the account owns the token.
126
+ function _canClaim(address hook, uint256 tokenId, address account) internal view override returns (bool canClaim) {
127
+ canClaim = IERC721(hook).ownerOf(tokenId) == account;
128
+ }
129
+
130
+ /// @notice The stake weight of a given NFT token ID based on its tier's voting units, validated against historical
131
+ /// state.
132
+ /// @dev Returns 0 if the token's current owner had no checkpointed voting power at the round's start block,
133
+ /// preventing late mints from capturing pro-rata rewards within the current round.
134
+ /// @param hook The hook the token belongs to.
135
+ /// @param tokenId The ID of the token to get the stake weight of.
136
+ /// @return tokenStakeAmount The voting units of the token's tier (or 0 if ineligible).
137
+ function _tokenStake(address hook, uint256 tokenId) internal view override returns (uint256 tokenStakeAmount) {
138
+ uint256 votingUnits = IJB721TiersHook(hook).STORE().tierOfTokenId(hook, tokenId, false).votingUnits;
139
+
140
+ // Use the checkpoints module to verify the token's owner had voting power at the round's start block.
141
+ // If they had no voting power at that time, this token was minted or acquired after the round started
142
+ // and is not eligible for this round's rewards.
143
+ IJB721Checkpoints checkpoints = IJB721TiersHook(hook).CHECKPOINTS();
144
+ address owner = IERC721(hook).ownerOf(tokenId);
145
+ uint256 pastVotes = IVotes(address(checkpoints)).getPastVotes(owner, roundStartBlock(currentRound()));
146
+
147
+ // If the owner had no voting power at round start, the token is ineligible.
148
+ // slither-disable-next-line incorrect-equality
149
+ if (pastVotes == 0) return 0;
150
+
151
+ // Cap at the token's tier voting units — the owner's past votes may cover multiple tokens,
152
+ // but each individual token's stake is at most its tier's voting units.
153
+ tokenStakeAmount = votingUnits < pastVotes ? votingUnits : pastVotes;
154
+ }
155
+
156
+ /// @notice The total stake at a specific block, using the hook's checkpoints module for historical accuracy.
157
+ /// @dev Uses `IVotes.getPastTotalSupply` from the hook's CHECKPOINTS module. This ensures that only NFTs
158
+ /// that existed (and were delegated) at `blockNumber` are counted, preventing late mints from diluting or
159
+ /// capturing rewards within the current round.
160
+ /// @param hook The hook to get the total stake for.
161
+ /// @param blockNumber The block number to get the total staked amount at.
162
+ /// @return total The total checkpointed voting units at the given block.
163
+ function _totalStake(address hook, uint256 blockNumber) internal view override returns (uint256 total) {
164
+ IJB721Checkpoints checkpoints = IJB721TiersHook(hook).CHECKPOINTS();
165
+ total = IVotes(address(checkpoints)).getPastTotalSupply(blockNumber);
166
+ }
167
+
168
+ /// @notice Checks if the given token was burned.
169
+ /// @param hook The hook the token belongs to.
170
+ /// @param tokenId The tokenId to check.
171
+ /// @return tokenWasBurned True if the token was burned.
172
+ function _tokenBurned(address hook, uint256 tokenId) internal view override returns (bool tokenWasBurned) {
173
+ // slither-disable-next-line unused-return
174
+ try IERC721(hook).ownerOf(tokenId) returns (address) {
175
+ tokenWasBurned = false;
176
+ } catch {
177
+ tokenWasBurned = true;
178
+ }
179
+ }
180
+ }