@bananapus/721-hook-v6 0.0.31 → 0.0.33

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/RISKS.md +1 -1
  3. package/USER_JOURNEYS.md +11 -0
  4. package/package.json +3 -3
  5. package/script/Deploy.s.sol +53 -19
  6. package/src/JB721Checkpoints.sol +92 -0
  7. package/src/JB721CheckpointsDeployer.sol +45 -0
  8. package/src/JB721TiersHook.sol +109 -50
  9. package/src/JB721TiersHookProjectDeployer.sol +15 -3
  10. package/src/JB721TiersHookStore.sol +119 -21
  11. package/src/abstract/JB721Hook.sol +10 -1
  12. package/src/interfaces/IJB721Checkpoints.sol +34 -0
  13. package/src/interfaces/IJB721CheckpointsDeployer.sol +20 -0
  14. package/src/interfaces/IJB721TiersHook.sol +8 -0
  15. package/src/interfaces/IJB721TiersHookStore.sol +27 -0
  16. package/src/libraries/JB721TiersHookLib.sol +126 -32
  17. package/src/libraries/JBIpfsDecoder.sol +17 -4
  18. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +11 -1
  19. package/test/Fork.t.sol +11 -2
  20. package/test/TestAuditGaps.sol +1 -1
  21. package/test/TestCheckpoints.t.sol +329 -0
  22. package/test/audit/CodexNemesisRepoFindings.t.sol +270 -0
  23. package/test/audit/CodexRetroactiveReserveBeneficiaryDilution.t.sol +161 -0
  24. package/test/audit/CodexSplitCreditsMismatch.t.sol +2 -1
  25. package/test/audit/CrossCurrencySplitNoPrices.t.sol +1 -0
  26. package/test/audit/SameCurrencyDecimalMismatch.t.sol +249 -0
  27. package/test/audit/SplitFailureRedistribution.t.sol +2 -1
  28. package/test/fork/ERC20CashOutFork.t.sol +11 -2
  29. package/test/fork/ERC20TierSplitFork.t.sol +11 -2
  30. package/test/fork/IssueTokensForSplitsFork.t.sol +11 -2
  31. package/test/regression/BrokenTerminalDoesNotDos.t.sol +13 -37
  32. package/test/regression/SplitDistributionBugs.t.sol +5 -5
  33. package/test/regression/SplitNoBeneficiary.t.sol +1 -1
  34. package/test/unit/AuditFixes_Unit.t.sol +5 -5
  35. package/test/unit/pay_CrossCurrency_Unit.t.sol +1 -0
  36. package/test/unit/pay_Unit.t.sol +1 -0
  37. package/test/unit/redeem_Unit.t.sol +3 -3
  38. package/test/unit/splitHookDistribution_Unit.t.sol +6 -6
  39. package/test/unit/tierSplitRouting_Unit.t.sol +2 -2
package/CHANGELOG.md CHANGED
@@ -39,7 +39,7 @@ This file describes the verified change from `nana-721-hook-v5` to the current `
39
39
 
40
40
  ## Indexer impact
41
41
 
42
- - New events: `AddToBalanceReverted`, `SetName`, `SetSymbol`, `SplitPayoutReverted`.
42
+ - New events: `AddToBalanceReverted` (declared but no longer emitted -- replaced by `JB721TiersHookLib_SplitFallbackFailed` revert error), `SetName`, `SetSymbol`, `SplitPayoutReverted`.
43
43
  - Tier config decoding changed because `JB721TierConfig` is no longer v5-compatible.
44
44
  - Collection metadata can now change after deployment, so one-time indexing of `name` and `symbol` is no longer sufficient.
45
45
 
@@ -58,7 +58,7 @@ This file describes the verified change from `nana-721-hook-v5` to the current `
58
58
  - `pricingContext()`
59
59
  - `setMetadata(...)`
60
60
  - Added events
61
- - `AddToBalanceReverted`
61
+ - `AddToBalanceReverted` (declared in interface but no longer emitted; the library now reverts with `JB721TiersHookLib_SplitFallbackFailed` instead)
62
62
  - `SetName`
63
63
  - `SetSymbol`
64
64
  - `SplitPayoutReverted`
package/RISKS.md CHANGED
@@ -46,7 +46,7 @@ This file focuses on the tiered-NFT accounting, reserve-mint, and cash-out risks
46
46
  - **Split hook callbacks (`processSplitWith`).** During `afterPayRecordedWith` -> `_processPayment` -> `distributeAll`, the library calls `split.hook.processSplitWith{value}()` for each split with a hook. This executes arbitrary code. At callback time: NFTs already minted, `payCreditsOf` updated, `remainingSupply` decremented in the store. Reentering `afterPayRecordedWith` requires terminal authentication and processes as an independent payment. All split hook and terminal calls are wrapped in try-catch to prevent a single reverting recipient from bricking all payments to the project. For native token hooks, a revert returns false (ETH stays in the contract and routes to project balance). For ERC20 hooks, tokens are transferred before the callback; a revert still returns true because the tokens have already left the contract. Tested: `TestAuditGaps_Reentrancy` confirms reentrancy is blocked by terminal check.
47
47
  - **Split beneficiary ETH sends.** `_sendPayoutToSplit` uses `beneficiary.call{value: amount}("")`. If the beneficiary reverts, the function returns `false` and the failed amount is accumulated separately, then routed to the project's balance after the distribution loop via `addToBalanceOf`. Later split recipients receive only their proportional share, not the failed recipient's share. Does not revert the entire payment.
48
48
  - **Terminal `.pay()` / `.addToBalanceOf()` during split distribution.** For project-targeted splits, the library calls the target project's primary terminal via try-catch. A reverting terminal returns false, routing the funds to the project's balance instead. For ERC20 terminal calls, approval is reset to zero on failure to prevent dangling approvals. The target terminal could call back into the hook, but the hook's state is fully settled (supply, credits, mint state). Reentrancy through this path cannot double-mint or corrupt state.
49
- - **`afterCashOutRecordedWith` execution order.** Burns tokens via `_burn()` -> `_update()` -> `STORE.recordTransferForTier()` in a loop, then calls `STORE.recordBurn()`. ERC721 `_update` triggers the store's tier balance decrement. Burns go to `address(0)`, so no `onERC721Received` callback.
49
+ - **`afterCashOutRecordedWith` execution order.** Burns tokens via `_burn()` -> `_update()` -> `STORE.tierTransferInfoOfTokenId()` in a loop, then calls `STORE.recordBurn()`. ERC721 `_update` triggers the store's tier balance decrement. Burns go to `address(0)`, so no `onERC721Received` callback.
50
50
  - **No `ReentrancyGuard`.** Protection relies on state ordering (all `STORE.record*` calls before external calls), terminal authentication checks, and try-catch wrapping of all external calls in `_sendPayoutToSplit`. `_mint()` uses the non-safe variant, avoiding `onERC721Received` callbacks during minting.
51
51
 
52
52
  ## 4. Gas/DoS Vectors
package/USER_JOURNEYS.md CHANGED
@@ -81,6 +81,17 @@
81
81
  2. Keep collection-specific behavior in the downstream repo while leaving pay, reserve, and cash-out semantics in this repo.
82
82
  3. Audit hook-store interactions here first, then audit the downstream resolver or wrapper.
83
83
 
84
+ ## Journey 7: Mint NFTs To The Correct Beneficiary During Cross-Chain Payments
85
+
86
+ **Starting state:** a sucker pays the project on behalf of a remote user via `payRemote`, and the 721 hook needs to mint NFTs and accrue credits to the real user instead of the sucker contract.
87
+
88
+ **Success:** NFTs mint to and pay credits accrue to the real remote user.
89
+
90
+ **Flow**
91
+ 1. The sucker calls `terminal.pay()` with itself as both payer and beneficiary, embedding the real user's address in the `JB_RELAY_BENEFICIARY` metadata key.
92
+ 2. `_mintAndUpdateCredits` detects that `payer == beneficiary` and finds relay-beneficiary metadata.
93
+ 3. All NFT minting and credit accounting uses the resolved relay beneficiary instead of the sucker address.
94
+
84
95
  ## Hand-Offs
85
96
 
86
97
  - Use [nana-core-v6](../nana-core-v6/USER_JOURNEYS.md) for the treasury, ruleset, and permission surfaces the hook plugs into.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,9 +18,9 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@bananapus/address-registry-v6": "^0.0.17",
21
- "@bananapus/core-v6": "^0.0.31",
21
+ "@bananapus/core-v6": "^0.0.34",
22
22
  "@bananapus/ownable-v6": "^0.0.17",
23
- "@bananapus/permission-ids-v6": "^0.0.15",
23
+ "@bananapus/permission-ids-v6": "^0.0.17",
24
24
  "@openzeppelin/contracts": "^5.6.1",
25
25
  "@prb/math": "^4.1.0",
26
26
  "solady": "^0.1.8"
@@ -10,6 +10,8 @@ import {
10
10
  import {Sphinx} from "@sphinx-labs/contracts/contracts/foundry/SphinxPlugin.sol";
11
11
  import {Script} from "forge-std/Script.sol";
12
12
 
13
+ import {JB721CheckpointsDeployer} from "../src/JB721CheckpointsDeployer.sol";
14
+ import {IJB721CheckpointsDeployer} from "../src/interfaces/IJB721CheckpointsDeployer.sol";
13
15
  import {JB721TiersHookDeployer} from "../src/JB721TiersHookDeployer.sol";
14
16
  import {JB721TiersHookProjectDeployer} from "../src/JB721TiersHookProjectDeployer.sol";
15
17
  import {JB721TiersHookStore} from "../src/JB721TiersHookStore.sol";
@@ -34,6 +36,8 @@ contract DeployScript is Script, Sphinx {
34
36
  bytes32 HOOK_STORE_SALT = "JB721TiersHookStoreV6_";
35
37
  // forge-lint: disable-next-line(mixed-case-variable)
36
38
  bytes32 PROJECT_DEPLOYER_SALT = "JB721TiersHookProjectDeployerV6";
39
+ // forge-lint: disable-next-line(mixed-case-variable)
40
+ bytes32 CHECKPOINTS_DEPLOYER_SALT = "JB721CheckpointsDeployerV6";
37
41
 
38
42
  function configureSphinx() public override {
39
43
  sphinxConfig.projectName = "nana-721-hook-v6";
@@ -69,28 +73,58 @@ contract DeployScript is Script, Sphinx {
69
73
  JB721TiersHookStore store;
70
74
  {
71
75
  // Perform the check for the store.
72
- (address _store, bool _storeIsDeployed) =
73
- _isDeployed(HOOK_STORE_SALT, type(JB721TiersHookStore).creationCode, "");
76
+ (address _store, bool _storeIsDeployed) = _isDeployed({
77
+ salt: HOOK_STORE_SALT, creationCode: type(JB721TiersHookStore).creationCode, arguments: ""
78
+ });
74
79
 
75
80
  // Deploy it if it has not been deployed yet.
76
81
  store = !_storeIsDeployed ? new JB721TiersHookStore{salt: HOOK_STORE_SALT}() : JB721TiersHookStore(_store);
77
82
  }
78
83
 
84
+ JB721CheckpointsDeployer checkpointsDeployer;
85
+ {
86
+ // Perform the check for the deployer.
87
+ (address _deployer, bool _deployerIsDeployed) = _isDeployed({
88
+ salt: CHECKPOINTS_DEPLOYER_SALT,
89
+ creationCode: type(JB721CheckpointsDeployer).creationCode,
90
+ arguments: ""
91
+ });
92
+
93
+ // Deploy it if it has not been deployed yet.
94
+ checkpointsDeployer = !_deployerIsDeployed
95
+ ? new JB721CheckpointsDeployer{salt: CHECKPOINTS_DEPLOYER_SALT}()
96
+ : JB721CheckpointsDeployer(_deployer);
97
+ }
98
+
79
99
  JB721TiersHook hook;
80
100
  {
81
101
  // Perform the check for the registry.
82
- (address _hook, bool _hookIsDeployed) = _isDeployed(
83
- HOOK_SALT,
84
- type(JB721TiersHook).creationCode,
85
- abi.encode(
86
- core.directory, core.permissions, core.prices, core.rulesets, store, core.splits, TRUSTED_FORWARDER
102
+ (address _hook, bool _hookIsDeployed) = _isDeployed({
103
+ salt: HOOK_SALT,
104
+ creationCode: type(JB721TiersHook).creationCode,
105
+ arguments: abi.encode(
106
+ core.directory,
107
+ core.permissions,
108
+ core.prices,
109
+ core.rulesets,
110
+ store,
111
+ core.splits,
112
+ checkpointsDeployer,
113
+ TRUSTED_FORWARDER
87
114
  )
88
- );
115
+ });
89
116
 
90
117
  // Deploy it if it has not been deployed yet.
91
118
  hook = !_hookIsDeployed
92
119
  ? new JB721TiersHook{salt: HOOK_SALT}(
93
- core.directory, core.permissions, core.prices, core.rulesets, store, core.splits, TRUSTED_FORWARDER
120
+ core.directory,
121
+ core.permissions,
122
+ core.prices,
123
+ core.rulesets,
124
+ store,
125
+ core.splits,
126
+ IJB721CheckpointsDeployer(address(checkpointsDeployer)),
127
+ TRUSTED_FORWARDER
94
128
  )
95
129
  : JB721TiersHook(_hook);
96
130
  }
@@ -98,11 +132,11 @@ contract DeployScript is Script, Sphinx {
98
132
  JB721TiersHookDeployer hookDeployer;
99
133
  {
100
134
  // Perform the check for the registry.
101
- (address _hookDeployer, bool _hookDeployerIsDeployed) = _isDeployed(
102
- HOOK_DEPLOYER_SALT,
103
- type(JB721TiersHookDeployer).creationCode,
104
- abi.encode(hook, store, registry.registry, TRUSTED_FORWARDER)
105
- );
135
+ (address _hookDeployer, bool _hookDeployerIsDeployed) = _isDeployed({
136
+ salt: HOOK_DEPLOYER_SALT,
137
+ creationCode: type(JB721TiersHookDeployer).creationCode,
138
+ arguments: abi.encode(hook, store, registry.registry, TRUSTED_FORWARDER)
139
+ });
106
140
 
107
141
  hookDeployer = !_hookDeployerIsDeployed
108
142
  ? new JB721TiersHookDeployer{salt: HOOK_DEPLOYER_SALT}(
@@ -114,11 +148,11 @@ contract DeployScript is Script, Sphinx {
114
148
  JB721TiersHookProjectDeployer projectDeployer;
115
149
  {
116
150
  // Perform the check for the registry.
117
- (address _projectDeployer, bool _projectDeployerIsdeployed) = _isDeployed(
118
- PROJECT_DEPLOYER_SALT,
119
- type(JB721TiersHookProjectDeployer).creationCode,
120
- abi.encode(core.directory, core.permissions, hookDeployer, TRUSTED_FORWARDER)
121
- );
151
+ (address _projectDeployer, bool _projectDeployerIsdeployed) = _isDeployed({
152
+ salt: PROJECT_DEPLOYER_SALT,
153
+ creationCode: type(JB721TiersHookProjectDeployer).creationCode,
154
+ arguments: abi.encode(core.directory, core.permissions, hookDeployer, TRUSTED_FORWARDER)
155
+ });
122
156
 
123
157
  projectDeployer = !_projectDeployerIsdeployed
124
158
  ? new JB721TiersHookProjectDeployer{salt: PROJECT_DEPLOYER_SALT}(
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
5
+ import {Votes} from "@openzeppelin/contracts/governance/utils/Votes.sol";
6
+ import {IJB721Checkpoints} from "./interfaces/IJB721Checkpoints.sol";
7
+ import {IJB721TiersHookStore} from "./interfaces/IJB721TiersHookStore.sol";
8
+
9
+ /// @title JB721Checkpoints
10
+ /// @notice Provides IVotes-compatible checkpointed voting power for a JB721TiersHook. Deployed as an EIP-1167 clone
11
+ /// via JB721CheckpointsDeployer — one module per hook. The hook calls `onTransfer` on every NFT transfer to
12
+ /// maintain accurate vote checkpoints.
13
+ /// @dev EIP712 on clones: OZ stores name/version as immutables (accessible via DELEGATECALL). The storage cache
14
+ /// (`_cachedThis`) is uninitialized on clones, so `domainSeparatorV4()` always rebuilds using the clone's
15
+ /// `address(this)` — correct behavior, tiny gas overhead.
16
+ contract JB721Checkpoints is Votes, IJB721Checkpoints {
17
+ //*********************************************************************//
18
+ // --------------------------- custom errors ------------------------- //
19
+ //*********************************************************************//
20
+
21
+ error JB721Checkpoints_AlreadyInitialized();
22
+ error JB721Checkpoints_Unauthorized();
23
+
24
+ //*********************************************************************//
25
+ // --------------------- private stored properties ------------------ //
26
+ //*********************************************************************//
27
+
28
+ /// @notice Whether this contract has been initialized.
29
+ bool private _initialized;
30
+
31
+ //*********************************************************************//
32
+ // ---------------------- public stored properties ------------------- //
33
+ //*********************************************************************//
34
+
35
+ /// @notice The hook that this module tracks voting power for.
36
+ address public override HOOK;
37
+
38
+ /// @notice The store that holds tier and voting data for the hook's NFTs.
39
+ IJB721TiersHookStore public override STORE;
40
+
41
+ //*********************************************************************//
42
+ // -------------------------- constructor ---------------------------- //
43
+ //*********************************************************************//
44
+
45
+ /// @dev Parameterless. The implementation contract is initialized in the constructor to prevent direct use.
46
+ /// Clones are initialized via `initialize()`.
47
+ constructor() EIP712("JB721Checkpoints", "1") {
48
+ _initialized = true;
49
+ }
50
+
51
+ //*********************************************************************//
52
+ // ---------------------- external transactions ---------------------- //
53
+ //*********************************************************************//
54
+
55
+ /// @notice Initializes a cloned module with its hook and store references.
56
+ /// @dev Can only be called once. Called by the deployer after cloning.
57
+ /// @param hook The hook this module serves.
58
+ /// @param store The store that holds tier data for the hook's NFTs.
59
+ function initialize(address hook, IJB721TiersHookStore store) external override {
60
+ if (_initialized) revert JB721Checkpoints_AlreadyInitialized();
61
+ _initialized = true;
62
+ HOOK = hook;
63
+ STORE = store;
64
+ }
65
+
66
+ /// @notice Called by the hook after every NFT transfer to update checkpointed voting power.
67
+ /// @dev Only callable by the HOOK. Looks up the token's tier voting units from the store.
68
+ /// @param from The previous owner (address(0) on mint).
69
+ /// @param to The new owner (address(0) on burn).
70
+ /// @param tokenId The token ID being transferred.
71
+ function onTransfer(address from, address to, uint256 tokenId) external override {
72
+ if (msg.sender != HOOK) revert JB721Checkpoints_Unauthorized();
73
+
74
+ // Look up this token's tier to get its voting units.
75
+ uint256 votingUnits = STORE.tierOfTokenId({hook: HOOK, tokenId: tokenId, includeResolvedUri: false}).votingUnits;
76
+
77
+ // Move checkpointed voting power from the previous owner to the new owner.
78
+ _transferVotingUnits({from: from, to: to, amount: votingUnits});
79
+ }
80
+
81
+ //*********************************************************************//
82
+ // ------------------------ internal functions ----------------------- //
83
+ //*********************************************************************//
84
+
85
+ /// @notice Returns the total voting units held by an account (across all tiers).
86
+ /// @dev Called by OZ Votes when re-delegating to compute the account's total voting units.
87
+ /// @param account The address to get the voting units of.
88
+ /// @return The total voting units the account holds.
89
+ function _getVotingUnits(address account) internal view override returns (uint256) {
90
+ return STORE.votingUnitsOf({hook: HOOK, account: account});
91
+ }
92
+ }
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {LibClone} from "solady/src/utils/LibClone.sol";
5
+ import {JB721Checkpoints} from "./JB721Checkpoints.sol";
6
+ import {IJB721Checkpoints} from "./interfaces/IJB721Checkpoints.sol";
7
+ import {IJB721CheckpointsDeployer} from "./interfaces/IJB721CheckpointsDeployer.sol";
8
+ import {IJB721TiersHookStore} from "./interfaces/IJB721TiersHookStore.sol";
9
+
10
+ /// @title JB721CheckpointsDeployer
11
+ /// @notice Deploys EIP-1167 clones of JB721Checkpoints for each JB721TiersHook instance.
12
+ /// @dev The implementation is deployed once in the constructor. Each `deploy()` call clones it (~45k gas) and
13
+ /// initializes the clone with the hook and store references.
14
+ contract JB721CheckpointsDeployer is IJB721CheckpointsDeployer {
15
+ //*********************************************************************//
16
+ // --------------- public immutable stored properties ---------------- //
17
+ //*********************************************************************//
18
+
19
+ /// @notice The checkpoint module implementation that clones delegate to.
20
+ address public immutable override IMPLEMENTATION;
21
+
22
+ //*********************************************************************//
23
+ // -------------------------- constructor ---------------------------- //
24
+ //*********************************************************************//
25
+
26
+ constructor() {
27
+ IMPLEMENTATION = address(new JB721Checkpoints());
28
+ }
29
+
30
+ //*********************************************************************//
31
+ // ---------------------- external transactions ---------------------- //
32
+ //*********************************************************************//
33
+
34
+ /// @notice Deploys a new deterministic checkpoint clone for the given hook.
35
+ /// @dev Uses CREATE2 with the hook address as salt so the clone address is the same across chains.
36
+ /// @param hook The hook address the module will serve.
37
+ /// @param store The store that holds tier data for the hook's NFTs.
38
+ /// @return module The newly deployed and initialized checkpoint module.
39
+ function deploy(address hook, IJB721TiersHookStore store) external override returns (IJB721Checkpoints module) {
40
+ module = IJB721Checkpoints(
41
+ LibClone.cloneDeterministic({implementation: IMPLEMENTATION, salt: bytes32(uint256(uint160(hook)))})
42
+ );
43
+ module.initialize({hook: hook, store: store});
44
+ }
45
+ }