@bananapus/distributor-v6 0.0.27 → 0.0.29

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
@@ -37,11 +37,12 @@ If the issue is "where did the project's value come from?" start in `nana-core-v
37
37
 
38
38
  1. a project funds the distributor, often through a payout split
39
39
  2. accepted funding is assigned to the current reward round for the chosen token or 721 stake source
40
- 3. optional direct funding can set a claim duration; split funding and plain `fund` stay non-expiring
40
+ 3. the distributor's immutable claim duration decides whether funded reward rounds expire
41
41
  4. the encoded token staker or current NFT owner later claims completed past reward rounds into a fresh vesting entry
42
42
  5. anyone can burn expired unclaimed reward rounds after their deadline
43
43
  6. recipients collect their vested share as the configured vesting schedule unlocks
44
- 7. some unclaimable value can be reclaimed through explicit recovery paths, depending on the distributor type
44
+ 7. eligible claimants can borrow against vesting revnet rewards without bypassing the vesting schedule
45
+ 8. some unclaimable value can be burned through explicit cleanup paths, depending on the distributor type
45
46
 
46
47
  This repo does not explain why an allocation exists. It only defines how funded inventory is handed out.
47
48
 
@@ -57,11 +58,21 @@ This repo does not explain why an allocation exists. It only defines how funded
57
58
  - distribution correctness depends on the distributor actually holding the assets it is expected to vest
58
59
  - ERC-20 and ERC-721 distributions share historical reward-round accounting, but claim authority differs:
59
60
  token rewards are claimed by the encoded staker address, while 721 rewards are claimed by the current NFT owner
60
- - `fundWithClaimDuration` starts the claim window when the funded round first becomes claimable; incompatible
61
- same-round deadlines for the same hook and reward token revert
61
+ - `CLAIM_DURATION` is fixed at deployment; `0` means reward rounds do not expire, otherwise all funding paths use the
62
+ same deadline measured from when the funded round first becomes claimable
62
63
  - `burnExpiredRewards` is permissionless and only burns the unclaimed remainder; already-materialized vesting entries
63
64
  remain claimable on their normal vesting curve
64
- - `releaseForfeitedRewards` matters for 721 distributions; token-vote distributions do not have the same burned-token path
65
+ - expired and forfeited rewards are burned with `JBController.burnTokensOf`; rewards that are not registered project
66
+ tokens in the configured controller cannot use those burn paths
67
+ - revnet loan-backed vesting is opt-in at deployment; the reward token must be a REVOwner-owned revnet token, the
68
+ distributor keeps the loan NFT, and repayment restores the original vesting schedule instead of releasing all
69
+ collateral immediately
70
+ - if Revnet liquidates a distributor-held vesting loan, anyone can call `writeOffLiquidatedVestingLoan` to clear the
71
+ stale collection lock and forfeit only the vesting rewards that were collateralized by that loan
72
+ - distributors deployed with `VESTING_ROUNDS == 0` disable revnet vesting loans because rewards are immediately
73
+ collectible instead of locked in a vesting position
74
+ - `releaseForfeitedRewards` matters for 721 distributions; token-vote distributions do not have the same burned-token
75
+ forfeiture path
65
76
  - snapshot timing is part of the trusted surface
66
77
  - this repo settles distributions, but it does not prove the upstream entitlement math was correct
67
78
 
@@ -77,6 +88,7 @@ This repo does not explain why an allocation exists. It only defines how funded
77
88
  1. `test/JBTokenDistributor.t.sol`
78
89
  2. `test/JB721Distributor.t.sol`
79
90
  3. `test/invariant/JB721DistributorInvariant.t.sol`
91
+ 4. `test/regression/VestingLoanRegression.t.sol`
80
92
 
81
93
  ## Install
82
94
 
@@ -118,8 +130,8 @@ script/
118
130
  - distributors are only as trustworthy as the vesting parameters and funding they receive
119
131
  - operational mistakes often come from funding the wrong asset or underfunding the distributor
120
132
  - teams should review claim timing and snapshot assumptions with the same care they review the payout source
121
- - rewarders that set claim durations should choose a window long enough for expected claimants, because expired
122
- unclaimed rewards can be burned by anyone
133
+ - deployers that set a nonzero claim duration should choose a window long enough for expected claimants, because
134
+ expired unclaimed rewards can be burned by anyone
123
135
 
124
136
  ## For AI Agents
125
137
 
package/foundry.toml CHANGED
@@ -2,6 +2,7 @@
2
2
  solc = '0.8.28'
3
3
  bytecode_hash = "none"
4
4
  evm_version = 'cancun'
5
+ via_ir = true
5
6
  optimizer_runs = 200
6
7
  libs = ["node_modules", "lib"]
7
8
  fs_permissions = [{ access = "read-write", path = "./"}]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/distributor-v6",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,7 +28,8 @@
28
28
  "@bananapus/core-v6": "^0.0.60",
29
29
  "@bananapus/permission-ids-v6": "^0.0.27",
30
30
  "@openzeppelin/contracts": "5.6.1",
31
- "@prb/math": "4.1.1"
31
+ "@prb/math": "4.1.1",
32
+ "@rev-net/core-v6": "^0.0.74"
32
33
  },
33
34
  "devDependencies": {
34
35
  "@sphinx-labs/plugins": "0.33.3"
@@ -3,7 +3,10 @@ pragma solidity 0.8.28;
3
3
 
4
4
  import {Script} from "forge-std/Script.sol";
5
5
 
6
+ import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
6
7
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
8
+ import {IREVLoans} from "@rev-net/core-v6/src/interfaces/IREVLoans.sol";
9
+ import {IREVOwner} from "@rev-net/core-v6/src/interfaces/IREVOwner.sol";
7
10
 
8
11
  import {JB721Distributor} from "../src/JB721Distributor.sol";
9
12
 
@@ -13,11 +16,27 @@ contract Deploy is Script {
13
16
 
14
17
  // Configure these values before deploying.
15
18
  IJBDirectory directory = IJBDirectory(vm.envAddress("DIRECTORY_ADDRESS"));
19
+ IJBController controller = IJBController(vm.envAddress("CONTROLLER_ADDRESS"));
20
+ IREVLoans revLoans = IREVLoans(vm.envOr("REV_LOANS_ADDRESS", address(0)));
21
+ IREVOwner revOwner = IREVOwner(vm.envOr("REV_OWNER_ADDRESS", address(0)));
16
22
  uint256 roundDuration = vm.envUint("ROUND_DURATION");
17
23
  uint256 vestingRounds = vm.envUint("VESTING_ROUNDS");
24
+ uint256 rawClaimDuration = vm.envUint("CLAIM_DURATION");
25
+
26
+ require(rawClaimDuration <= type(uint48).max, "CLAIM_DURATION_TOO_LARGE");
27
+
28
+ // Safe because the explicit bound above rejects values larger than uint48.
29
+ // forge-lint: disable-next-line(unsafe-typecast)
30
+ uint48 claimDuration = uint48(rawClaimDuration);
18
31
 
19
32
  new JB721Distributor({
20
- directory: directory, initialRoundDuration: roundDuration, initialVestingRounds: vestingRounds
33
+ directory: directory,
34
+ controller: controller,
35
+ revLoans: revLoans,
36
+ revOwner: revOwner,
37
+ initialRoundDuration: roundDuration,
38
+ initialVestingRounds: vestingRounds,
39
+ initialClaimDuration: claimDuration
21
40
  });
22
41
 
23
42
  vm.stopBroadcast();
@@ -3,6 +3,7 @@ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJB721Checkpoints} from "@bananapus/721-hook-v6/src/interfaces/IJB721Checkpoints.sol";
5
5
  import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
6
+ import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
6
7
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
8
  import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
8
9
  import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
@@ -13,6 +14,8 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
13
14
  import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
14
15
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
15
16
  import {mulDiv} from "@prb/math/src/Common.sol";
17
+ import {IREVLoans} from "@rev-net/core-v6/src/interfaces/IREVLoans.sol";
18
+ import {IREVOwner} from "@rev-net/core-v6/src/interfaces/IREVOwner.sol";
16
19
 
17
20
  import {JBDistributor} from "./JBDistributor.sol";
18
21
  import {IJB721Distributor} from "./interfaces/IJB721Distributor.sol";
@@ -26,7 +29,7 @@ import {JBVestingData} from "./structs/JBVestingData.sol";
26
29
  /// @dev Any project can use this distributor by configuring a payout split with
27
30
  /// `hook = this contract` and `beneficiary = address(their 721 hook)`.
28
31
  /// @dev The stake weight of each NFT is its tier's `votingUnits`. Burned NFTs are excluded from the total stake
29
- /// calculation and their unvested rewards can be reclaimed via `releaseForfeitedRewards`.
32
+ /// calculation and their unlocked forfeited rewards can be burned via `releaseForfeitedRewards`.
30
33
  /// @dev Funded rewards are assigned to the funding round. NFT owners claim historical rounds lazily; all unclaimed
31
34
  /// past rewards begin vesting when the current NFT owner claims, not when the rewards were funded.
32
35
  /// @dev Implements `IJBSplitHook` so it can receive tokens directly from Juicebox project payout splits.
@@ -35,12 +38,12 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
35
38
  // --------------------------- custom errors ------------------------- //
36
39
  //*********************************************************************//
37
40
 
38
- /// @notice Thrown when a claim batch repeats an NFT token ID.
39
- error JB721Distributor_DuplicateTokenId(uint256 tokenId);
40
-
41
41
  /// @notice Thrown when native ETH does not match the split hook context amount.
42
42
  error JB721Distributor_NativeAmountMismatch(uint256 msgValue, uint256 contextAmount);
43
43
 
44
+ /// @notice Thrown when claim batch NFT token IDs are not strictly increasing.
45
+ error JB721Distributor_TokenIdsNotIncreasing(uint256 previousTokenId, uint256 tokenId);
46
+
44
47
  /// @notice Thrown when native ETH is sent but context.token is not NATIVE_TOKEN.
45
48
  error JB721Distributor_TokenMismatch(address token, address expectedToken, uint256 msgValue);
46
49
 
@@ -82,14 +85,22 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
82
85
  //*********************************************************************//
83
86
 
84
87
  /// @param directory The JB directory used to verify terminal/controller callers.
88
+ /// @param controller The JB controller used to burn expired or forfeited project-token rewards.
89
+ /// @param revLoans The Revnet loans contract used to borrow against vested revnet rewards.
90
+ /// @param revOwner The REVOwner contract that must own revnet reward token projects.
85
91
  /// @param initialRoundDuration The duration of each round, specified in seconds.
86
92
  /// @param initialVestingRounds The number of rounds until tokens are fully vested.
93
+ /// @param initialClaimDuration The number of seconds claimants have after each reward round becomes claimable.
87
94
  constructor(
88
95
  IJBDirectory directory,
96
+ IJBController controller,
97
+ IREVLoans revLoans,
98
+ IREVOwner revOwner,
89
99
  uint256 initialRoundDuration,
90
- uint256 initialVestingRounds
100
+ uint256 initialVestingRounds,
101
+ uint48 initialClaimDuration
91
102
  )
92
- JBDistributor(initialRoundDuration, initialVestingRounds)
103
+ JBDistributor(controller, revLoans, revOwner, initialRoundDuration, initialVestingRounds, initialClaimDuration)
93
104
  {
94
105
  DIRECTORY = directory;
95
106
  }
@@ -129,7 +140,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
129
140
 
130
141
  if (msg.value != 0) {
131
142
  // Assign native split proceeds to the current reward round for this 721 hook.
132
- _recordRewardFunding({hook: hook, token: IERC20(context.token), amount: msg.value, claimDuration: 0});
143
+ _recordRewardFunding({hook: hook, token: IERC20(context.token), amount: msg.value});
133
144
  }
134
145
  } else {
135
146
  // Validate that native ETH is not cross-booked under an ERC-20 token.
@@ -147,7 +158,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
147
158
  _acceptErc20FundsFrom({token: IERC20(context.token), from: msg.sender, amount: context.amount});
148
159
 
149
160
  // Assign only the amount actually received to this round's reward pot.
150
- _recordRewardFunding({hook: hook, token: IERC20(context.token), amount: delta, claimDuration: 0});
161
+ _recordRewardFunding({hook: hook, token: IERC20(context.token), amount: delta});
151
162
  }
152
163
  }
153
164
 
@@ -223,14 +234,14 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
223
234
  /// @param hook The 721 hook whose NFT owners are claiming.
224
235
  /// @param tokenIds The NFT token IDs to claim for.
225
236
  /// @param tokens The reward tokens to claim.
226
- function _claimPastRewards(address hook, uint256[] calldata tokenIds, IERC20[] calldata tokens) internal {
237
+ function _claimPastRewards(address hook, uint256[] calldata tokenIds, IERC20[] calldata tokens) internal override {
227
238
  // Round 0 has no completed reward rounds behind it, so nothing can be claimed yet.
228
239
  uint256 round = currentRound();
229
240
  if (round == 0) return;
230
241
 
231
242
  // Current-round funding is excluded. It becomes claimable only after a later round starts.
232
243
  JBClaimContext memory ctx =
233
- JBClaimContext({hook: hook, lastClaimableRound: round - 1, vestingReleaseRound: round + vestingRounds});
244
+ JBClaimContext({hook: hook, lastClaimableRound: round - 1, vestingReleaseRound: round + VESTING_ROUNDS});
234
245
 
235
246
  // Process each reward token independently because each token has its own round funding and claim cursor.
236
247
  for (uint256 i; i < tokens.length;) {
@@ -546,22 +557,17 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
546
557
  /// @notice Revert unless the caller is authorized to claim each NFT token ID.
547
558
  /// @param hook The 721 hook whose NFT owners are claiming.
548
559
  /// @param tokenIds The NFT token IDs to check.
549
- function _requireCanClaimTokenIds(address hook, uint256[] calldata tokenIds) internal view {
550
- // Each requested NFT must currently belong to msg.sender and appear only once in the batch.
560
+ function _requireCanClaimTokenIds(address hook, uint256[] calldata tokenIds) internal view override {
561
+ // Each requested NFT must currently belong to msg.sender and appear in strictly increasing order.
551
562
  for (uint256 i; i < tokenIds.length;) {
552
563
  uint256 tokenId = tokenIds[i];
553
564
 
554
- if (!_canClaim({hook: hook, tokenId: tokenId, account: msg.sender})) {
555
- revert JBDistributor_NoAccess({hook: hook, tokenId: tokenId, account: msg.sender});
565
+ if (i != 0 && tokenId <= tokenIds[i - 1]) {
566
+ revert JB721Distributor_TokenIdsNotIncreasing({previousTokenId: tokenIds[i - 1], tokenId: tokenId});
556
567
  }
557
568
 
558
- // Reject duplicates before reward accounting so one NFT cannot replay the same historical round.
559
- for (uint256 j = i + 1; j < tokenIds.length;) {
560
- if (tokenIds[j] == tokenId) revert JB721Distributor_DuplicateTokenId({tokenId: tokenId});
561
-
562
- unchecked {
563
- ++j;
564
- }
569
+ if (!_canClaim({hook: hook, tokenId: tokenId, account: msg.sender})) {
570
+ revert JBDistributor_NoAccess({hook: hook, tokenId: tokenId, account: msg.sender});
565
571
  }
566
572
 
567
573
  unchecked {