@bananapus/721-hook-v6 0.0.46 → 0.0.49

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.46",
3
+ "version": "0.0.49",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,10 +28,10 @@
28
28
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-721-hook-v6'"
29
29
  },
30
30
  "dependencies": {
31
- "@bananapus/address-registry-v6": "0.0.25",
32
- "@bananapus/core-v6": "0.0.44",
31
+ "@bananapus/address-registry-v6": "^0.0.25",
32
+ "@bananapus/core-v6": "^0.0.48",
33
33
  "@bananapus/ownable-v6": "^0.0.24",
34
- "@bananapus/permission-ids-v6": "0.0.22",
34
+ "@bananapus/permission-ids-v6": "^0.0.22",
35
35
  "@openzeppelin/contracts": "5.6.1",
36
36
  "@prb/math": "4.1.1",
37
37
  "solady": "0.1.26"
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity 0.8.28;
3
3
 
4
+ import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
4
5
  import {Votes} from "@openzeppelin/contracts/governance/utils/Votes.sol";
5
6
  import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
6
7
  import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol";
@@ -24,6 +25,7 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
24
25
  //*********************************************************************//
25
26
 
26
27
  error JB721Checkpoints_AlreadyInitialized(address hook);
28
+ error JB721Checkpoints_NotOwner(uint256 tokenId, address caller);
27
29
  error JB721Checkpoints_Unauthorized(address caller, address hook);
28
30
 
29
31
  //*********************************************************************//
@@ -44,7 +46,7 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
44
46
  // -------------------- internal stored properties ------------------- //
45
47
  //*********************************************************************//
46
48
 
47
- /// @notice Checkpointed token owners for historical reward eligibility after first transfer.
49
+ /// @notice Checkpointed token owners for historical reward eligibility. Written on enrollment or transfer.
48
50
  /// @custom:param tokenId The token ID to get historical owner checkpoints for.
49
51
  mapping(uint256 tokenId => Checkpoints.Trace160) internal _ownerCheckpointsOf;
50
52
 
@@ -64,6 +66,37 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
64
66
  // ---------------------- external transactions ---------------------- //
65
67
  //*********************************************************************//
66
68
 
69
+ /// @notice Delegates voting power and enrolls tokens for distribution eligibility.
70
+ /// @dev Writes per-token owner checkpoints so `ownerOfAt` can prove ownership at past blocks.
71
+ /// Only the current token owner can enroll. Tokens without checkpoints are ineligible for snapshot-based
72
+ /// distribution. The existing `delegate(address)` from OZ Votes still works for pure delegation without enrollment.
73
+ /// @param delegatee The address to delegate voting power to. Use your own address for self-delegation.
74
+ /// @param tokenIds The token IDs to enroll for distribution eligibility.
75
+ function delegate(address delegatee, uint256[] calldata tokenIds) external override {
76
+ // Delegate voting power (reuses OZ Votes internals).
77
+ _delegate({account: msg.sender, delegatee: delegatee});
78
+
79
+ // Write per-token owner checkpoints for distribution eligibility.
80
+ for (uint256 i; i < tokenIds.length;) {
81
+ uint256 tokenId = tokenIds[i];
82
+
83
+ // Only the current owner can enroll their tokens.
84
+ if (IERC721(HOOK).ownerOf(tokenId) != msg.sender) {
85
+ revert JB721Checkpoints_NotOwner({tokenId: tokenId, caller: msg.sender});
86
+ }
87
+
88
+ // Write an owner checkpoint if the token has none yet.
89
+ if (_ownerCheckpointsOf[tokenId].length() == 0) {
90
+ // forge-lint: disable-next-line(unsafe-typecast)
91
+ _ownerCheckpointsOf[tokenId].push({key: uint96(block.number), value: uint160(msg.sender)});
92
+ }
93
+
94
+ unchecked {
95
+ ++i;
96
+ }
97
+ }
98
+ }
99
+
67
100
  /// @notice Initializes a cloned module with its hook reference.
68
101
  /// @dev Can only be called once. Called by the deployer after cloning.
69
102
  /// @param hook The hook this module serves.
@@ -98,11 +131,11 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
98
131
  //*********************************************************************//
99
132
 
100
133
  /// @notice The owner of an NFT at a past block.
101
- /// @dev Mints do not write per-token checkpoint storage. Until a token's first non-mint transfer, ownership is
102
- /// inferred from the hook's `firstOwnerOf`.
134
+ /// @dev Returns `address(0)` for tokens that have never been enrolled (via `delegate(address, uint256[])`) or
135
+ /// transferred. Unenrolled tokens are ineligible for snapshot-based distribution.
103
136
  /// @param tokenId The token ID of the NFT to get the historical owner of.
104
137
  /// @param blockNumber The block number to look up.
105
- /// @return The owner of the token at `blockNumber`, or zero if the token has no known owner.
138
+ /// @return The owner of the token at `blockNumber`, or zero if the token is unenrolled or has no known owner.
106
139
  function ownerOfAt(uint256 tokenId, uint256 blockNumber) external view override returns (address) {
107
140
  // forge-lint: disable-next-line(unsafe-typecast)
108
141
  uint96 blockNumber96 = uint96(blockNumber);
@@ -110,10 +143,11 @@ contract JB721Checkpoints is Votes, IJB721Checkpoints {
110
143
  Checkpoints.Trace160 storage checkpoints = _ownerCheckpointsOf[tokenId];
111
144
  uint256 checkpointCount = checkpoints.length();
112
145
 
113
- // Before the first transfer/burn checkpoint, the mint owner is implicit in the hook's first-owner tracking.
114
- if (checkpointCount == 0 || checkpoints.at(0)._key > blockNumber96) {
115
- return IJB721TiersHook(HOOK).firstOwnerOf(tokenId);
116
- }
146
+ // No checkpoints = not enrolled and never transferred. Not eligible.
147
+ if (checkpointCount == 0) return address(0);
148
+
149
+ // Query is before the first checkpoint — token not yet enrolled/transferred at this block.
150
+ if (checkpoints.at(0)._key > blockNumber96) return address(0);
117
151
 
118
152
  return address(uint160(checkpoints.upperLookupRecent(blockNumber96)));
119
153
  }
@@ -14,11 +14,11 @@ interface IJB721Checkpoints is IERC5805 {
14
14
  function HOOK() external view returns (address);
15
15
 
16
16
  /// @notice The owner of an NFT at a past block.
17
- /// @dev Mints do not write per-token checkpoint storage. Until a token's first non-mint transfer, ownership is
18
- /// inferred from the hook's `firstOwnerOf`.
17
+ /// @dev Returns `address(0)` for tokens that have never been enrolled (via `delegate(address, uint256[])`) or
18
+ /// transferred. Unenrolled tokens are ineligible for snapshot-based distribution.
19
19
  /// @param tokenId The token ID of the NFT to get the historical owner of.
20
20
  /// @param blockNumber The block number to look up.
21
- /// @return The owner of the token at `blockNumber`, or zero if the token has no known owner.
21
+ /// @return The owner of the token at `blockNumber`, or zero if the token is unenrolled or has no known owner.
22
22
  function ownerOfAt(uint256 tokenId, uint256 blockNumber) external view returns (address);
23
23
 
24
24
  /// @notice The store that holds tier and voting data for the hook's NFTs.
@@ -26,6 +26,14 @@ interface IJB721Checkpoints is IERC5805 {
26
26
  // forge-lint: disable-next-line(mixed-case-function)
27
27
  function STORE() external view returns (IJB721TiersHookStore);
28
28
 
29
+ /// @notice Delegates voting power and enrolls tokens for distribution eligibility.
30
+ /// @dev Writes per-token owner checkpoints so `ownerOfAt` can prove ownership at past blocks.
31
+ /// Only the current token owner can enroll. Tokens without checkpoints are ineligible for snapshot-based
32
+ /// distribution.
33
+ /// @param delegatee The address to delegate voting power to. Use your own address for self-delegation.
34
+ /// @param tokenIds The token IDs to enroll for distribution eligibility.
35
+ function delegate(address delegatee, uint256[] calldata tokenIds) external;
36
+
29
37
  /// @notice Initializes a cloned module with its hook reference.
30
38
  /// @dev Can only be called once. Called by the deployer after cloning.
31
39
  /// @param hook The hook this module serves.
@@ -33,7 +41,6 @@ interface IJB721Checkpoints is IERC5805 {
33
41
 
34
42
  /// @notice Called by the hook after every NFT transfer to update checkpointed voting power.
35
43
  /// @dev Looks up the token's tier voting units from the store internally.
36
- /// Auto-self-delegates on first receive so checkpoints work without manual delegation.
37
44
  /// @param from The previous owner (address(0) on mint).
38
45
  /// @param to The new owner (address(0) on burn).
39
46
  /// @param tokenId The token ID to transfer (used to look up tier voting units).
@@ -691,12 +691,19 @@ library JB721TiersHookLib {
691
691
  //*********************************************************************//
692
692
 
693
693
  /// @notice Distributes funds for a single tier's split group.
694
- /// @dev Edge case: if both `_sendPayoutToSplit` returns false (reverting hook/terminal/beneficiary) AND the
695
- /// subsequent `addToBalanceOf` call also reverts for the leftover amount, native ETH will remain stranded in the
696
- /// hook contract with no recovery path. This requires two independent external call failures for the same split
697
- /// payout and is a pre-existing documented edge case. ERC-20 tokens are not affected because failed
698
- /// `addToBalanceOf` calls reset the approval. ERC-20 tokens remain in the hook contract. There is no built-in
699
- /// recovery mechanism.
694
+ /// @dev `_sendPayoutToSplit` returns the actual amount that left this contract — `payoutAmount` on full
695
+ /// success, `0` when the split fully fails (revert or missing recipient), or a partial value when an ERC-20
696
+ /// split hook pulled some but not all of its allowance. The unsent portion accumulates into `amount` and is
697
+ /// routed to the project's primary terminal via `addToBalanceOf` after the loop. If that fallback also
698
+ /// reverts and the token is native ETH, the unsent ETH is stuck in this contract with no recovery path.
699
+ /// For ERC-20 the approval is reset on `addToBalanceOf` failure so the terminal cannot pull later.
700
+ /// @param directory The directory used to resolve the primary terminal for the leftover fallback.
701
+ /// @param splitsContract The splits contract used to look up the tier's split group.
702
+ /// @param projectId The project ID whose primary terminal receives the leftover.
703
+ /// @param token The token being distributed. Use `JBConstants.NATIVE_TOKEN` for ETH.
704
+ /// @param groupId The split group ID identifying the tier whose splits to read.
705
+ /// @param amount The total amount available to distribute across the tier's splits.
706
+ /// @param decimals The decimals of `token`, forwarded into each split hook context.
700
707
  function _distributeSingleSplit(
701
708
  IJBDirectory directory,
702
709
  IJBSplits splitsContract,
@@ -722,23 +729,22 @@ library JB721TiersHookLib {
722
729
  unchecked {
723
730
  leftoverAmount -= payoutAmount;
724
731
  }
725
- // On failure, don't re-add to leftoverAmount this prevents inflating later recipients.
726
- // Failed amounts accumulate as the gap between `amount` and `leftoverAmount + total sent`.
727
- // After the loop, we re-add leftoverPercentage-based residual naturally.
728
- if (!_sendPayoutToSplit({
729
- directory: directory,
730
- split: tierSplits[j],
731
- token: token,
732
- amount: payoutAmount,
733
- projectId: projectId,
734
- groupId: groupId,
735
- decimals: decimals
736
- })) {
737
- // Payout failed — route to project balance by returning to leftover after the loop.
738
- // We add back to `amount` (parameter, no longer used for its original purpose).
739
- unchecked {
740
- amount += payoutAmount;
741
- }
732
+ // `_sendPayoutToSplit` returns the actual amount that left this contract (0 on full failure,
733
+ // a partial value when a split hook pulled some but not all of its allowance, or `payoutAmount`
734
+ // on full success). Add the unsent portion to `amount` so it routes to the project's balance
735
+ // after the loop. The subtraction is safe — the function invariantly returns at most
736
+ // `payoutAmount` (allowance bounded for ERC-20, value bounded for ETH, else all-or-nothing).
737
+ unchecked {
738
+ amount += payoutAmount
739
+ - _sendPayoutToSplit({
740
+ directory: directory,
741
+ split: tierSplits[j],
742
+ token: token,
743
+ amount: payoutAmount,
744
+ projectId: projectId,
745
+ groupId: groupId,
746
+ decimals: decimals
747
+ });
742
748
  }
743
749
  }
744
750
  unchecked {
@@ -793,9 +799,22 @@ library JB721TiersHookLib {
793
799
  }
794
800
  }
795
801
 
796
- /// @notice Sends a payout to a split recipient.
797
- /// @return sent Whether the funds were actually sent. Returns false if the split has no valid recipient
798
- /// (no hook, no projectId, and no beneficiary), so the caller can route the funds elsewhere.
802
+ /// @notice Sends a payout to a single split recipient.
803
+ /// @dev Recipient resolution order: (1) `split.hook` if set; (2) `split.projectId` (uses
804
+ /// `split.preferAddToBalance` to choose between `addToBalanceOf` and `pay` on the primary terminal);
805
+ /// (3) `split.beneficiary` for a direct transfer. All-or-nothing for terminal and beneficiary paths;
806
+ /// the split-hook path supports partial pulls via allowance for ERC-20 and balance-delta for ETH.
807
+ /// @param directory The directory used to resolve the primary terminal when the split targets a project.
808
+ /// @param split The split definition (recipient + percent + flags).
809
+ /// @param token The token being distributed. Use `JBConstants.NATIVE_TOKEN` for ETH.
810
+ /// @param amount The amount to send to this recipient.
811
+ /// @param projectId The project ID emitting the payout (used in events and the hook context).
812
+ /// @param groupId The split group ID forwarded into the split hook context.
813
+ /// @param decimals The decimals of `token`, forwarded into the split hook context.
814
+ /// @return sent The amount that actually left this contract. Equals `amount` on a fully successful payout,
815
+ /// `0` on full failure (revert, missing recipient, transfer rejected), and a partial value when a split
816
+ /// hook pulled some but not all of its ERC-20 allowance (or returned some native ETH back to this
817
+ /// contract). The caller routes `amount - sent` to the project's leftover balance.
799
818
  function _sendPayoutToSplit(
800
819
  IJBDirectory directory,
801
820
  JBSplit memory split,
@@ -806,7 +825,7 @@ library JB721TiersHookLib {
806
825
  uint256 decimals
807
826
  )
808
827
  private
809
- returns (bool sent)
828
+ returns (uint256 sent)
810
829
  {
811
830
  bool isNativeToken = token == JBConstants.NATIVE_TOKEN;
812
831
 
@@ -817,36 +836,47 @@ library JB721TiersHookLib {
817
836
  });
818
837
 
819
838
  if (isNativeToken) {
820
- // Wrap in try-catch so a reverting hook doesn't brick all project payments.
821
- // On revert, ETH stays with the caller and we return false.
822
- try split.hook.processSplitWith{value: amount}(context) {
823
- return true;
824
- } catch (bytes memory reason) {
839
+ // ETH is pushed via `{value: amount}`. On revert, the value transfer is rolled back along with
840
+ // the hook's frame so ETH stays here (balance unchanged return 0). On success the hook usually
841
+ // keeps the full amount, but if it sends some ETH back via low-level call the balance-delta
842
+ // reports the actual amount that left this contract — the unsent portion routes to the project.
843
+ uint256 ethBefore = address(this).balance;
844
+ try split.hook.processSplitWith{value: amount}(context) {}
845
+ catch (bytes memory reason) {
825
846
  emit SplitPayoutReverted({
826
847
  projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
827
848
  });
828
- return false;
829
849
  }
850
+ return ethBefore - address(this).balance;
830
851
  } else {
831
- // ERC20: transfer tokens first, then call the hook callback.
832
- // We must return true regardless of whether the callback reverts because the
833
- // tokens have already left this contract via safeTransfer. Returning false would
834
- // cause the caller to skip subtracting this amount from leftoverAmount, leading
835
- // to a double-spend when the leftover is later sent to the project's balance.
836
- SafeERC20.safeTransfer({token: IERC20(token), to: address(split.hook), value: amount});
852
+ // ERC20: grant the hook an allowance, then call the hook callback. The hook pulls tokens via
853
+ // `transferFrom` from inside `processSplitWith`. After the call we revoke any unconsumed
854
+ // allowance and report the actual amount pulled via balance-delta. Mirrors the controller's
855
+ // allowance pattern for reserved-token splits.
856
+ //
857
+ // Three outcomes:
858
+ // - Hook reverts (catch): balance unchanged → return 0. Caller routes the full `amount` to
859
+ // the project's balance.
860
+ // - Hook pulls partial `X < amount` and returns successfully: balance dropped by `X` →
861
+ // return `X`. Caller routes `amount - X` to the project's balance. The hook keeps `X`.
862
+ // - Hook pulls the full amount: return `amount`. Caller treats the split as fully distributed.
863
+ uint256 balanceBefore = IERC20(token).balanceOf(address(this));
864
+ SafeERC20.forceApprove({token: IERC20(token), spender: address(split.hook), value: amount});
837
865
  try split.hook.processSplitWith(context) {}
838
866
  catch (bytes memory reason) {
839
867
  emit SplitPayoutReverted({
840
868
  projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
841
869
  });
842
870
  }
843
- return true;
871
+ SafeERC20.forceApprove({token: IERC20(token), spender: address(split.hook), value: 0});
872
+ return balanceBefore - IERC20(token).balanceOf(address(this));
844
873
  }
845
874
  } else if (split.projectId != 0) {
846
875
  IJBTerminal terminal = directory.primaryTerminalOf({projectId: split.projectId, token: token});
847
- if (address(terminal) == address(0)) return false;
876
+ if (address(terminal) == address(0)) return 0;
848
877
 
849
- // Wrap terminal calls in try-catch to prevent a failing terminal from bricking payments.
878
+ // Terminal calls are all-or-nothing: the terminal pulls the full amount or the call reverts.
879
+ // Wrap in try-catch so a failing terminal does not brick the whole payment.
850
880
  if (split.preferAddToBalance) {
851
881
  if (isNativeToken) {
852
882
  try terminal.addToBalanceOf{value: amount}({
@@ -857,12 +887,12 @@ library JB721TiersHookLib {
857
887
  memo: "",
858
888
  metadata: bytes("")
859
889
  }) {
860
- return true;
890
+ return amount;
861
891
  } catch (bytes memory reason) {
862
892
  emit SplitPayoutReverted({
863
893
  projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
864
894
  });
865
- return false;
895
+ return 0;
866
896
  }
867
897
  } else {
868
898
  SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: amount});
@@ -874,14 +904,14 @@ library JB721TiersHookLib {
874
904
  memo: "",
875
905
  metadata: bytes("")
876
906
  }) {
877
- return true;
907
+ return amount;
878
908
  } catch (bytes memory reason) {
879
909
  // Reset approval on failure so tokens aren't left approved to the terminal.
880
910
  SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
881
911
  emit SplitPayoutReverted({
882
912
  projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
883
913
  });
884
- return false;
914
+ return 0;
885
915
  }
886
916
  }
887
917
  } else {
@@ -895,12 +925,12 @@ library JB721TiersHookLib {
895
925
  memo: "",
896
926
  metadata: bytes("")
897
927
  }) {
898
- return true;
928
+ return amount;
899
929
  } catch (bytes memory reason) {
900
930
  emit SplitPayoutReverted({
901
931
  projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
902
932
  });
903
- return false;
933
+ return 0;
904
934
  }
905
935
  } else {
906
936
  SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: amount});
@@ -913,37 +943,45 @@ library JB721TiersHookLib {
913
943
  memo: "",
914
944
  metadata: bytes("")
915
945
  }) {
916
- return true;
946
+ return amount;
917
947
  } catch (bytes memory reason) {
918
948
  // Reset approval on failure so tokens aren't left approved to the terminal.
919
949
  SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
920
950
  emit SplitPayoutReverted({
921
951
  projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
922
952
  });
923
- return false;
953
+ return 0;
924
954
  }
925
955
  }
926
956
  }
927
957
  } else if (split.beneficiary != address(0)) {
928
958
  if (isNativeToken) {
929
959
  (bool success,) = split.beneficiary.call{value: amount}("");
930
- if (!success) return false;
960
+ if (!success) return 0;
931
961
  } else {
932
962
  // Use the same low-level call + returndata check as SafeERC20.safeTransfer, but return
933
- // false on failure instead of reverting. This handles non-standard tokens (e.g. USDT)
963
+ // 0 on failure instead of reverting. This handles non-standard tokens (e.g. USDT)
934
964
  // that return void, while routing failed transfers to the project's balance instead
935
965
  // of bricking all payments.
936
966
  (bool callSuccess, bytes memory returndata) =
937
967
  address(token).call(abi.encodeCall(IERC20.transfer, (split.beneficiary, amount)));
938
- if (!callSuccess || (returndata.length != 0 && !abi.decode(returndata, (bool)))) return false;
968
+ if (!callSuccess || (returndata.length != 0 && !abi.decode(returndata, (bool)))) return 0;
939
969
  }
940
- return true;
970
+ return amount;
941
971
  }
942
- // No projectId and no beneficiary — return false so the funds go to the project's balance.
943
- return false;
972
+ // No projectId and no beneficiary — return 0 so the funds go to the project's balance.
973
+ return 0;
944
974
  }
945
975
 
946
976
  /// @notice Sets split groups in JBSplits for tiers that have splits configured.
977
+ /// @dev Walks `tiersToAdd` in parallel with `tierIdsAdded`. Tiers with an empty `splits` array are skipped,
978
+ /// so only tiers that opt into split distribution allocate a group. The group ID is packed as
979
+ /// `hookAddress | (tierId << 160)` so each tier owns a distinct group within the hook's namespace.
980
+ /// @param splits The splits contract that stores the per-tier groups.
981
+ /// @param projectId The project ID owning the splits.
982
+ /// @param hookAddress The 721 hook address; forms the low 160 bits of each group ID.
983
+ /// @param tiersToAdd The tier configurations being recorded; each may carry its own `splits` array.
984
+ /// @param tierIdsAdded The tier IDs assigned at recording time, in the same order as `tiersToAdd`.
947
985
  function _setSplitGroupsFor(
948
986
  IJBSplits splits,
949
987
  uint256 projectId,