@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.
- package/.github/pull_request_template.md +33 -0
- package/.github/workflows/lint.yml +19 -0
- package/.github/workflows/publish.yml +19 -0
- package/.github/workflows/slither.yml +23 -0
- package/.github/workflows/test.yml +26 -0
- package/.gitmodules +3 -0
- package/ADMINISTRATION.md +65 -0
- package/ARCHITECTURE.md +89 -0
- package/AUDIT_INSTRUCTIONS.md +52 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/RISKS.md +78 -0
- package/SKILLS.md +36 -0
- package/USER_JOURNEYS.md +120 -0
- package/foundry.toml +22 -0
- package/package.json +29 -0
- package/references/operations.md +25 -0
- package/references/runtime.md +36 -0
- package/remappings.txt +1 -0
- package/script/Deploy.s.sol +23 -0
- package/slither-ci.config.json +10 -0
- package/src/JB721Distributor.sol +180 -0
- package/src/JBDistributor.sol +563 -0
- package/src/JBTokenDistributor.sol +160 -0
- package/src/interfaces/IJB721Distributor.sol +15 -0
- package/src/interfaces/IJBDistributor.sol +138 -0
- package/src/interfaces/IJBTokenDistributor.sol +16 -0
- package/src/structs/JBTokenSnapshotData.sol +9 -0
- package/src/structs/JBVestingData.sol +11 -0
- package/test/JB721Distributor.t.sol +1985 -0
- package/test/JBTokenDistributor.t.sol +424 -0
- package/test/invariant/JB721DistributorInvariant.t.sol +410 -0
package/USER_JOURNEYS.md
ADDED
|
@@ -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
|
+
}
|