@bananapus/721-hook-v6 0.0.13 → 0.0.15

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/ARCHITECTURE.md CHANGED
@@ -26,13 +26,18 @@ src/
26
26
  ### NFT Minting (via Payment)
27
27
  ```
28
28
  User → JBMultiTerminal.pay(metadata)
29
+ → beforePayRecordedWith()
30
+ → calculateSplitAmounts(): per-tier split amounts (in tier pricing denomination)
31
+ → convertSplitAmounts(): convert to payment token denomination (if currencies differ)
32
+ → Adjust weight down by split fraction
29
33
  → JBTerminalStore records payment
30
- JB721TiersHook.afterPayRecordedWith()
34
+ → afterPayRecordedWith()
31
35
  → Decode tier IDs from metadata
32
36
  → For each tier:
33
37
  → Validate: not removed, not paused, supply available
34
- → Check price (with optional discount)
38
+ → Check price (with optional discount, normalized to tier pricing currency)
35
39
  → Mint NFT to beneficiary
40
+ → Distribute split funds to tier split recipients
36
41
  → Leftover amount optionally mints best-available tiers
37
42
  ```
38
43
 
package/RISKS.md CHANGED
@@ -90,10 +90,10 @@ Deep implementation-level risk analysis covering all contracts in the 721 tiered
90
90
  ### R-9: Price Feed Dependency -- DoS Vector
91
91
 
92
92
  - **Severity**: MEDIUM
93
- - **Location**: `JB721TiersHookLib.sol` lines 121-138 (`normalizePaymentValue`)
94
- - **Description**: When the hook's pricing currency differs from the payment currency and a `JBPrices` contract is configured, the hook calls `prices.pricePerUnitOf()` to normalize the payment value. If the price feed reverts (e.g., stale Chainlink data, sequencer down on L2), all payments in non-native currencies will revert. This is a DoS vector but not a fund-loss vector.
95
- - **Tested**: NOT directly tested for the revert-on-stale-feed scenario.
96
- - **Mitigation**: If `address(prices) == address(0)`, payments in non-matching currencies silently return `(0, false)` and the hook skips minting (line 600). Projects using cross-currency pricing should monitor feed health.
93
+ - **Location**: `JB721TiersHookLib.sol` `normalizePaymentValue` and `convertSplitAmounts`
94
+ - **Description**: When the hook's pricing currency differs from the payment currency and a `JBPrices` contract is configured, the hook calls `prices.pricePerUnitOf()` in two places: (1) `normalizePaymentValue` to convert the payment amount for tier price comparison, and (2) `convertSplitAmounts` to convert per-tier split amounts back to the payment token denomination for forwarding. If the price feed reverts (e.g., stale Chainlink data, sequencer down on L2), all payments in non-native currencies will revert. This is a DoS vector but not a fund-loss vector.
95
+ - **Tested**: YES `test/unit/pay_CrossCurrency_Unit.t.sol` tests 8-9 verify reverting price feeds block both `normalizePaymentValue` and `convertSplitAmounts`. Live cross-currency split conversion tested in `deploy-all-v6/test/fork/CrossCurrencyFork.t.sol`.
96
+ - **Mitigation**: If `address(prices) == address(0)`, payments in non-matching currencies silently return `(0, false)` and the hook skips minting. `convertSplitAmounts` also returns early if no prices contract is set. Projects using cross-currency pricing should monitor feed health.
97
97
 
98
98
  ### R-10: Large Tier Array Gas Exhaustion
99
99
 
@@ -258,7 +258,7 @@ An attacker could observe a large cash-out and front-run it with their own cash-
258
258
  | R-6 Soft removal cash-out weight | YES (attacks t5) | YES (INV-721-2) | YES |
259
259
  | R-7 Pay credit payer/beneficiary | PARTIAL | YES (INV-721-3) | YES |
260
260
  | R-8 Reserve supply protection | YES (M6) | YES (INV-721-1,4) | YES |
261
- | R-9 Price feed DoS | NO | NO | NO |
261
+ | R-9 Price feed DoS | YES (cc t8-t9) | NO | NO |
262
262
  | R-10 Large tier array gas | PARTIAL | NO | NO |
263
263
  | R-11 Metadata decode silent skip | PARTIAL | NO | NO |
264
264
  | R-12 ERC-721 receiver DoS | NO | NO | NO |
@@ -285,7 +285,7 @@ An attacker could observe a large cash-out and front-run it with their own cash-
285
285
  ### Notable Coverage Gaps
286
286
 
287
287
  1. **No reentrancy test** for the split distribution `.call{value}` path.
288
- 2. **No price feed failure test** for cross-currency payment scenarios.
288
+ 2. **Price feed failure** is tested: `test/unit/pay_CrossCurrency_Unit.t.sol` tests 8-9 verify reverting price feeds block both payment normalization and split conversion. Live feed integration tested in `deploy-all-v6`.
289
289
  3. **No gas limit test** for operations with many tiers (hundreds+).
290
290
  4. **No test** for token URI resolver returning malicious/reverting data.
291
291
  5. **No test** for `initialize()` front-running on deterministic clones.
package/SKILLS.md CHANGED
@@ -59,7 +59,8 @@ Tiered ERC-721 NFT hook for Juicebox V6 that mints NFTs when a project is paid a
59
59
  | `cashOutWeightOf(hook, tokenIds)` | `JB721TiersHookStore` | Returns combined cash out weight for specific token IDs. Uses original tier price, not discounted. |
60
60
  | `votingUnitsOf(hook, account)` | `JB721TiersHookStore` | Returns total voting units for an address across all tiers. Uses custom `votingUnits` if `useVotingUnits` is set, otherwise uses tier price. |
61
61
  | `tierVotingUnitsOf(hook, account, tierId)` | `JB721TiersHookStore` | Returns voting units for an address within a specific tier. |
62
- | `calculateSplitAmounts(store, hook, metadataIdTarget, metadata)` | `JB721TiersHookLib` | Called in `beforePayRecordedWith`. Decodes tier IDs from payer metadata, looks up each tier's `splitPercent`, calculates `mulDiv(price, splitPercent, SPLITS_TOTAL_PERCENT)` per tier, returns `totalSplitAmount` (forwarded to hook as `amount`) and encoded `hookMetadata` (tier IDs + amounts). |
62
+ | `calculateSplitAmounts(store, hook, metadataIdTarget, metadata)` | `JB721TiersHookLib` | Called in `beforePayRecordedWith`. Decodes tier IDs from payer metadata, looks up each tier's `splitPercent`, calculates `mulDiv(price, splitPercent, SPLITS_TOTAL_PERCENT)` per tier, returns `totalSplitAmount` and encoded `hookMetadata` (tier IDs + amounts). Amounts are in the tier pricing denomination — call `convertSplitAmounts` afterward when the payment currency differs. |
63
+ | `convertSplitAmounts(totalSplitAmount, splitMetadata, packedPricingContext, projectId, amountCurrency, amountDecimals)` | `JB721TiersHookLib` | Converts per-tier split amounts from tier pricing denomination to payment token denomination using `JBPrices.pricePerUnitOf`. Called automatically by `beforePayRecordedWith` when `totalSplitAmount != 0`. Returns early (no-op) when currencies match or no prices contract is configured. |
63
64
  | `distributeAll(directory, splits, projectId, hookAddress, token, encodedSplitData)` | `JB721TiersHookLib` | Called in `afterPayRecordedWith`. Decodes per-tier amounts, looks up each tier's splits from `JBSplits` by group ID (`hookAddress | (tierId << 160)`), distributes to split recipients. Leftover goes to project balance via `addToBalance`. |
64
65
  | `adjustTiersFor(store, splits, projectId, hookAddress, caller, tiersToAdd, tierIdsToRemove)` | `JB721TiersHookLib` | Called via DELEGATECALL from `adjustTiers`. Removes tiers, adds tiers, emits events, and registers any configured splits directly in `JBSplits`. |
65
66
  | `normalizePaymentValue(packedPricingContext, projectId, amountValue, amountCurrency, amountDecimals)` | `JB721TiersHookLib` | Converts a payment value to the hook's pricing currency using `JBPrices`. Returns `(0, false)` if currencies differ and no prices contract is set. |
@@ -89,7 +90,7 @@ Tiered ERC-721 NFT hook for Juicebox V6 that mints NFTs when a project is paid a
89
90
  | `JB721TiersHookFlags` | `bool noNewTiersWithReserves`, `bool noNewTiersWithVotes`, `bool noNewTiersWithOwnerMinting`, `bool preventOverspending`, `bool issueTokensForSplits` | `initialize`, `recordFlags` |
90
91
  | `JB721TiersRulesetMetadata` | `bool pauseTransfers`, `bool pauseMintPendingReserves` | Packed into `JBRulesetMetadata.metadata` per-ruleset (bit 0 = pauseTransfers, bit 1 = pauseMintPendingReserves) |
91
92
  | `JBPayDataHookRulesetConfig` | `uint48 mustStartAtOrAfter`, `uint32 duration`, `uint112 weight`, `uint32 weightCutPercent`, `IJBRulesetApprovalHook approvalHook`, `JBPayDataHookRulesetMetadata metadata`, `JBSplitGroup[] splitGroups`, `JBFundAccessLimitGroup[] fundAccessLimitGroups` | `JB721TiersHookProjectDeployer` -- wraps core ruleset config with `useDataHookForPay: true` hardcoded |
92
- | `JBPayDataHookRulesetMetadata` | Same as `JBRulesetMetadata` minus `allowSetCustomToken` (hardcoded false) and `useDataHookForPay` (hardcoded true) and `dataHook` (set to deployed hook). Includes `ownerMustSendPayouts`. | `launchProjectFor`, `launchRulesetsFor`, `queueRulesetsOf` |
93
+ | `JBPayDataHookRulesetMetadata` | Same as `JBRulesetMetadata` minus `useDataHookForPay` (hardcoded true) and `dataHook` (set to deployed hook). | `launchProjectFor`, `launchRulesetsFor`, `queueRulesetsOf` |
93
94
  | `JBLaunchProjectConfig` | `string projectUri`, `JBPayDataHookRulesetConfig[] rulesetConfigurations`, `JBTerminalConfig[] terminalConfigurations`, `string memo` | `launchProjectFor` |
94
95
  | `JBLaunchRulesetsConfig` | `uint56 projectId`, `JBPayDataHookRulesetConfig[] rulesetConfigurations`, `JBTerminalConfig[] terminalConfigurations`, `string memo` | `launchRulesetsFor` |
95
96
  | `JBQueueRulesetsConfig` | `uint56 projectId`, `JBPayDataHookRulesetConfig[] rulesetConfigurations`, `string memo` | `queueRulesetsOf` |
@@ -138,7 +139,7 @@ Each tier has configurable voting power:
138
139
  - Each tier can route a percentage of its mint price to configured split recipients. The `splitPercent` field (out of `JBConstants.SPLITS_TOTAL_PERCENT` = 1,000,000,000) determines how much of the price is forwarded.
139
140
  - Split recipients are stored in `JBSplits` using group IDs computed as `uint256(uint160(hookAddress)) | (uint256(tierId) << 160)`.
140
141
  - Splits are registered in `JBSplits` both during `initialize()` (for tiers included at launch) and during `adjustTiers()` (for tiers added later), using the hook's `SPLITS` immutable directly.
141
- - In `beforePayRecordedWith`, `calculateSplitAmounts` decodes tier IDs from payer metadata, computes `mulDiv(price, splitPercent, SPLITS_TOTAL_PERCENT)` per tier, and returns the total to be forwarded to the hook. The weight is adjusted down proportionally unless the `issueTokensForSplits` flag is set, in which case the full `context.weight` is returned.
142
+ - In `beforePayRecordedWith`, `calculateSplitAmounts` decodes tier IDs from payer metadata, computes `mulDiv(price, splitPercent, SPLITS_TOTAL_PERCENT)` per tier, and returns the total to be forwarded to the hook. If the payment currency differs from the tier pricing currency, `convertSplitAmounts` converts the amounts to the payment token denomination using the configured `JBPrices` contract. The weight is adjusted down proportionally unless the `issueTokensForSplits` flag is set, in which case the full `context.weight` is returned.
142
143
  - In `afterPayRecordedWith`, `distributeAll` distributes forwarded funds to each tier's split group recipients. Leftover after all splits goes back to the project's balance via `addToBalance`.
143
144
  - Split recipients can be projects (via `terminal.pay` or `terminal.addToBalance`) or plain addresses (direct ETH transfer or `SafeERC20.safeTransfer`). Splits with no `projectId` and no `beneficiary` are skipped -- their share stays in the leftover and is routed to the project's own balance via `addToBalanceOf`, preventing a misconfigured split from bricking the payout distribution.
144
145
 
@@ -156,7 +157,7 @@ Each tier has configurable voting power:
156
157
  - **Reserve minting is permissionless** but governed by ruleset metadata. Anyone can call `mintPendingReservesFor` as long as `mintPendingReservesPaused` is not set in the current ruleset's metadata.
157
158
  - **Reserve + owner-mint mutual exclusion**: Tiers with `allowOwnerMint: true` cannot have a `reserveFrequency`. The store rejects this combination during `recordAddTiers`.
158
159
  - `setMetadata` uses `address(this)` as the sentinel for "no change" on `tokenUriResolver` (not `address(0)`). Passing `address(0)` will clear the resolver.
159
- - `JBPayDataHookRulesetConfig` hardcodes `allowSetCustomToken: false` and `useDataHookForPay: true` when wiring rulesets through the project deployer.
160
+ - `JBPayDataHookRulesetConfig` hardcodes `useDataHookForPay: true` when wiring rulesets through the project deployer. All other metadata fields are passed through.
160
161
  - The `_update` override in `JB721TiersHook` checks `tier.transfersPausable` and consults the current ruleset's metadata for `transfersPaused`. Transfers to `address(0)` (burns) are never blocked.
161
162
  - **IERC2981 declared but not implemented**: `supportsInterface` returns `true` for `IERC2981`, but no `royaltyInfo` function is implemented. Callers querying `royaltyInfo` will get a revert. This appears intentional -- the interface is declared for future extension or to signal capability to marketplaces that may override behavior.
162
163
  - **Tier splits**: Each tier can route a percentage of its mint price to configured split recipients. `splitPercent` is out of `JBConstants.SPLITS_TOTAL_PERCENT` (1,000,000,000). Split group IDs are `uint256(uint160(hookAddress)) | (uint256(tierId) << 160)`.
package/STYLE_GUIDE.md CHANGED
@@ -17,8 +17,6 @@ src/
17
17
 
18
18
  One contract/interface/struct/enum per file. Name the file after the type it contains.
19
19
 
20
- **Structs, enums, libraries, and interfaces always go in their subdirectories** (`src/structs/`, `src/enums/`, `src/libraries/`, `src/interfaces/`) — never inline in contract files or placed in `src/` root. This keeps type definitions discoverable and import paths consistent across repos.
21
-
22
20
  ## Pragma Versions
23
21
 
24
22
  ```solidity
@@ -106,14 +104,6 @@ contract JBExample is JBPermissioned, IJBExample {
106
104
  // -------------------------- constructor ---------------------------- //
107
105
  //*********************************************************************//
108
106
 
109
- //*********************************************************************//
110
- // ---------------------- receive / fallback ------------------------- //
111
- //*********************************************************************//
112
-
113
- //*********************************************************************//
114
- // --------------------------- modifiers ----------------------------- //
115
- //*********************************************************************//
116
-
117
107
  //*********************************************************************//
118
108
  // ---------------------- external transactions ---------------------- //
119
109
  //*********************************************************************//
@@ -141,28 +131,23 @@ contract JBExample is JBPermissioned, IJBExample {
141
131
  ```
142
132
 
143
133
  **Section order:**
144
- 1. `using` declarations
145
- 2. Custom errors
146
- 3. Public constants
147
- 4. Internal constants
148
- 5. Public immutable stored properties
149
- 6. Internal immutable stored properties
150
- 7. Public stored properties
151
- 8. Internal stored properties
152
- 9. Constructor
153
- 10. `receive` / `fallback`
154
- 11. Modifiers
155
- 12. External transactions
156
- 13. External views
157
- 14. Public transactions
158
- 15. Internal helpers
159
- 16. Internal views
160
- 17. Private helpers
134
+ 1. Custom errors
135
+ 2. Public constants
136
+ 3. Internal constants
137
+ 4. Public immutable stored properties
138
+ 5. Internal immutable stored properties
139
+ 6. Public stored properties
140
+ 7. Internal stored properties
141
+ 8. Constructor
142
+ 9. External transactions
143
+ 10. External views
144
+ 11. Public transactions
145
+ 12. Internal helpers
146
+ 13. Internal views
147
+ 14. Private helpers
161
148
 
162
149
  Functions are alphabetized within each section.
163
150
 
164
- **Events:** Events are declared in interfaces only, never in implementation contracts. Implementations inherit events from their interface and emit them unqualified. This keeps the ABI definition in one place and allows tests to use interface-qualified event expectations (e.g., `emit IJBController.LaunchProject(...)`).
165
-
166
151
  ## Interface Structure
167
152
 
168
153
  ```solidity
@@ -334,9 +319,6 @@ optimizer_runs = 200
334
319
  libs = ["node_modules", "lib"]
335
320
  fs_permissions = [{ access = "read-write", path = "./"}]
336
321
 
337
- [profile.ci_sizes]
338
- optimizer_runs = 200
339
-
340
322
  [fuzz]
341
323
  runs = 4096
342
324
 
@@ -351,12 +333,14 @@ multiline_func_header = "all"
351
333
  wrap_comments = true
352
334
  ```
353
335
 
354
- **Variations:**
355
- - `evm_version = 'cancun'` for repos using transient storage (buyback-hook, router-terminal, univ4-router)
356
- - `via_ir = true` for repos hitting stack-too-deep (buyback-hook, banny-retail, univ4-lp-split-hook, deploy-all)
357
- - `optimizer = false` only for deploy-all-v6 (stack-too-deep with optimization)
336
+ **Optional sections (add only when needed):**
337
+ - `[rpc_endpoints]` repos with fork tests. Maps named endpoints to env vars (e.g. `ethereum = "${RPC_ETHEREUM_MAINNET}"`).
338
+ - `[profile.ci_sizes]` only when CI needs different optimizer settings than defaults for the size check step (e.g. `optimizer_runs = 200` when the default profile uses a lower value).
358
339
 
359
- This repo uses the standard config with no deviations: `cancun`, `optimizer_runs=200`, no `via_ir`.
340
+ **Common variations:**
341
+ - `via_ir = true` when hitting stack-too-deep
342
+ - `optimizer = false` when optimization causes stack-too-deep
343
+ - `optimizer_runs` reduced when deep struct nesting causes stack-too-deep at 200 runs
360
344
 
361
345
  ### CI Workflows
362
346
 
@@ -386,8 +370,10 @@ jobs:
386
370
  uses: foundry-rs/foundry-toolchain@v1
387
371
  - name: Run tests
388
372
  run: forge test --fail-fast --summary --detailed --skip "*/script/**"
373
+ env:
374
+ RPC_ETHEREUM_MAINNET: ${{ secrets.RPC_ETHEREUM_MAINNET }}
389
375
  - name: Check contract sizes
390
- run: FOUNDRY_PROFILE=ci_sizes forge build --sizes --skip "*/test/**" --skip "*/script/**" --skip SphinxUtils
376
+ run: forge build --sizes --skip "*/test/**" --skip "*/script/**" --skip SphinxUtils
391
377
  ```
392
378
 
393
379
  **lint.yml:**
@@ -409,11 +395,60 @@ jobs:
409
395
  run: forge fmt --check
410
396
  ```
411
397
 
398
+ **slither.yml** (repos with `src/` contracts only):
399
+ ```yaml
400
+ name: slither
401
+ on:
402
+ pull_request:
403
+ branches:
404
+ - main
405
+ push:
406
+ branches:
407
+ - main
408
+ jobs:
409
+ analyze:
410
+ runs-on: ubuntu-latest
411
+ steps:
412
+ - uses: actions/checkout@v4
413
+ with:
414
+ submodules: recursive
415
+ - uses: actions/setup-node@v4
416
+ with:
417
+ node-version: latest
418
+ - name: Install npm dependencies
419
+ run: npm install --omit=dev
420
+ - name: Install Foundry
421
+ uses: foundry-rs/foundry-toolchain@v1
422
+ - name: Run slither
423
+ uses: crytic/slither-action@v0.3.1
424
+ with:
425
+ slither-config: slither-ci.config.json
426
+ fail-on: medium
427
+ ```
428
+
429
+ **slither-ci.config.json:**
430
+ ```json
431
+ {
432
+ "detectors_to_exclude": "timestamp,uninitialized-local,naming-convention,solc-version,shadowing-local",
433
+ "exclude_informational": true,
434
+ "exclude_low": false,
435
+ "exclude_medium": false,
436
+ "exclude_high": false,
437
+ "disable_color": false,
438
+ "filter_paths": "(mocks/|test/|node_modules/|lib/)",
439
+ "legacy_ast": false
440
+ }
441
+ ```
442
+
443
+ **Variations:**
444
+ - Deployer-only repos (no `src/`, only `script/`) skip slither entirely — the action's internal `forge build` skips `test/` and `script/` by default, leaving nothing to compile.
445
+ - Use inline `// slither-disable-next-line <detector>` to suppress known false positives rather than adding to `detectors_to_exclude` in the config. The comment must be on the line immediately before the flagged expression.
446
+
412
447
  ### package.json
413
448
 
414
449
  ```json
415
450
  {
416
- "name": "@bananapus/721-hook-v6",
451
+ "name": "@bananapus/package-name-v6",
417
452
  "version": "x.x.x",
418
453
  "license": "MIT",
419
454
  "repository": { "type": "git", "url": "git+https://github.com/Org/repo.git" },
@@ -433,13 +468,62 @@ jobs:
433
468
 
434
469
  ### remappings.txt
435
470
 
436
- Every repo has a `remappings.txt`. Minimal content:
471
+ Every repo has a `remappings.txt` as the **single source of truth** for import remappings. Never add remappings to `foundry.toml`.
472
+
473
+ **Principle:** Import paths in Solidity source must match npm package names exactly. With `libs = ["node_modules", "lib"]`, Foundry auto-resolves `@scope/package/path/File.sol` → `node_modules/@scope/package/path/File.sol`. No remapping needed for packages installed as real directories.
474
+
475
+ **Note:** Auto-resolution does **not** work for symlinked packages (e.g. npm workspace links). Workspace repos like `deploy-all-v6` and `nana-cli-v6` need explicit `@scope/package/=node_modules/@scope/package/` remappings for each symlinked dependency.
476
+
477
+ **Minimal content** (most repos):
478
+
479
+ ```
480
+ forge-std/=lib/forge-std/src/
481
+ ```
482
+
483
+ Only add extra remappings for:
484
+ - **`forge-std`** — always needed (git submodule with `src/` subdirectory)
485
+ - **Repo-specific `lib/` submodules** that have no npm package (e.g., `hookmate/=lib/hookmate/src/`)
486
+ - **Symlinked npm packages** — need explicit `@scope/package/=node_modules/@scope/package/` entries
487
+ - **Nested transitive deps** — e.g., `@chainlink/contracts-ccip/` nested inside `@bananapus/suckers-v6/node_modules/`
488
+
489
+ **Never add remappings for:**
490
+ - npm packages that match their import path and are installed as real directories — they auto-resolve
491
+ - Short-form aliases (e.g., `@bananapus/core/` → `@bananapus/core-v6/src/`) — fix the import instead
492
+ - Packages available via npm that are also git submodules — remove the submodule, use npm
437
493
 
494
+ **Import path convention:**
495
+
496
+ | Package | Import path | Resolves to |
497
+ |---------|------------|-------------|
498
+ | `@bananapus/core-v6` | `@bananapus/core-v6/src/libraries/JBConstants.sol` | `node_modules/@bananapus/core-v6/src/...` |
499
+ | `@openzeppelin/contracts` | `@openzeppelin/contracts/token/ERC20/IERC20.sol` | `node_modules/@openzeppelin/contracts/...` |
500
+ | `@uniswap/v4-core` | `@uniswap/v4-core/src/interfaces/IPoolManager.sol` | `node_modules/@uniswap/v4-core/src/...` |
501
+
502
+ ### Linting
503
+
504
+ Solar (Foundry's built-in linter) runs automatically during `forge build`. It scans all `.sol` files in `libs` directories, including `node_modules`.
505
+
506
+ **All test helpers must use relative imports** (e.g. `../../src/structs/JBRuleset.sol`), not bare `src/` imports. This ensures solar can resolve paths when the helper is consumed via npm in downstream repos.
507
+
508
+ ### Fork Tests
509
+
510
+ Fork tests use named RPC endpoints defined in `[rpc_endpoints]` of `foundry.toml`. No skip guards — fork tests should hard-fail if the RPC endpoint is unavailable, making CI failures explicit.
511
+
512
+ ```solidity
513
+ function setUp() public {
514
+ vm.createSelectFork("ethereum");
515
+ // ... setup code
516
+ }
438
517
  ```
439
- @sphinx-labs/contracts/=lib/sphinx/packages/contracts/contracts/foundry
518
+
519
+ The endpoint name (e.g. `"ethereum"`) maps to an env var via `foundry.toml`:
520
+
521
+ ```toml
522
+ [rpc_endpoints]
523
+ ethereum = "${RPC_ETHEREUM_MAINNET}"
440
524
  ```
441
525
 
442
- Additional mappings as needed for repo-specific dependencies.
526
+ For multi-chain fork tests, add all needed endpoints.
443
527
 
444
528
  ### Formatting
445
529
 
@@ -467,4 +551,8 @@ CI checks formatting via `forge fmt --check`.
467
551
 
468
552
  ### Contract Size Checks
469
553
 
470
- CI runs `FOUNDRY_PROFILE=ci_sizes forge build --sizes` to catch contracts approaching the 24KB limit. The `ci_sizes` profile uses `optimizer_runs = 200` for realistic size measurement even when the default profile has different optimizer settings.
554
+ CI runs `forge build --sizes` to catch contracts approaching the 24KB limit. When the repo's default `optimizer_runs` differs from what you want for size checking, use `FOUNDRY_PROFILE=ci_sizes forge build --sizes` with a `[profile.ci_sizes]` section in `foundry.toml`.
555
+
556
+ ## Repo-Specific Deviations
557
+
558
+ None. This repo follows the standard configuration exactly.
package/foundry.toml CHANGED
@@ -5,9 +5,6 @@ optimizer_runs = 200
5
5
  libs = ["node_modules", "lib"]
6
6
  fs_permissions = [{ access = "read-write", path = "./"}]
7
7
 
8
- [profile.ci_sizes]
9
- optimizer_runs = 200
10
-
11
8
  [fuzz]
12
9
  runs = 4096
13
10
 
@@ -20,3 +17,6 @@ fail_on_revert = false
20
17
  number_underscore = "thousands"
21
18
  multiline_func_header = "all"
22
19
  wrap_comments = true
20
+
21
+ [rpc_endpoints]
22
+ ethereum = "${RPC_ETHEREUM_MAINNET}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,11 +17,11 @@
17
17
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-721-hook-v6'"
18
18
  },
19
19
  "dependencies": {
20
- "@bananapus/address-registry-v6": "^0.0.6",
21
- "@bananapus/core-v6": "^0.0.11",
20
+ "@bananapus/address-registry-v6": "^0.0.8",
21
+ "@bananapus/core-v6": "^0.0.15",
22
22
  "@bananapus/ownable-v6": "^0.0.7",
23
- "@bananapus/permission-ids-v6": "^0.0.6",
24
- "@openzeppelin/contracts": "5.2.0",
23
+ "@bananapus/permission-ids-v6": "^0.0.7",
24
+ "@openzeppelin/contracts": "^5.6.1",
25
25
  "@prb/math": "^4.1.0",
26
26
  "solady": "^0.1.8"
27
27
  },
package/remappings.txt CHANGED
@@ -1 +1 @@
1
- @sphinx-labs/contracts/=node_modules/@sphinx-labs/contracts/contracts/foundry
1
+ forge-std/=lib/forge-std/src/
@@ -4,7 +4,7 @@ pragma solidity 0.8.26;
4
4
  import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
5
5
  import "@bananapus/address-registry-v6/script/helpers/AddressRegistryDeploymentLib.sol";
6
6
 
7
- import {Sphinx} from "@sphinx-labs/contracts/SphinxPlugin.sol";
7
+ import {Sphinx} from "@sphinx-labs/contracts/contracts/foundry/SphinxPlugin.sol";
8
8
  import {Script} from "forge-std/Script.sol";
9
9
 
10
10
  import {JB721TiersHookDeployer} from "../src/JB721TiersHookDeployer.sol";
@@ -4,7 +4,7 @@ pragma solidity 0.8.26;
4
4
  import {stdJson} from "forge-std/Script.sol";
5
5
  import {Vm} from "forge-std/Vm.sol";
6
6
 
7
- import {SphinxConstants, NetworkInfo} from "@sphinx-labs/contracts/SphinxConstants.sol";
7
+ import {SphinxConstants, NetworkInfo} from "@sphinx-labs/contracts/contracts/foundry/SphinxConstants.sol";
8
8
 
9
9
  import {IJB721TiersHookDeployer} from "../../src/interfaces/IJB721TiersHookDeployer.sol";
10
10
  import {IJB721TiersHookProjectDeployer} from "../../src/interfaces/IJB721TiersHookProjectDeployer.sol";
@@ -7,6 +7,7 @@ import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
7
7
  import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
8
8
  import {IJBRulesets} from "@bananapus/core-v6/src/interfaces/IJBRulesets.sol";
9
9
  import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
10
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
10
11
  import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
11
12
  import {JBRulesetMetadataResolver} from "@bananapus/core-v6/src/libraries/JBRulesetMetadataResolver.sol";
12
13
  import {JBAfterPayRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterPayRecordedContext.sol";
@@ -16,6 +17,8 @@ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
16
17
  import {JBOwnable} from "@bananapus/ownable-v6/src/JBOwnable.sol";
17
18
  import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
18
19
  import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
20
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
21
+ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
19
22
  import {Context} from "@openzeppelin/contracts/utils/Context.sol";
20
23
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
21
24
  import {JB721Hook} from "./abstract/JB721Hook.sol";
@@ -38,6 +41,8 @@ import {JB721TiersSetDiscountPercentConfig} from "./structs/JB721TiersSetDiscoun
38
41
  /// information specified by the payer. The project's owner can enable NFT cash outs through this hook, allowing
39
42
  /// holders to burn their NFTs to reclaim funds from the project (in proportion to the NFT's price).
40
43
  contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook {
44
+ using SafeERC20 for IERC20;
45
+
41
46
  //*********************************************************************//
42
47
  // --------------------------- custom errors ------------------------- //
43
48
  //*********************************************************************//
@@ -186,6 +191,18 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
186
191
  (uint256 totalSplitAmount, bytes memory splitMetadata) =
187
192
  JB721TiersHookLib.calculateSplitAmounts(STORE, address(this), METADATA_ID_TARGET, context.metadata);
188
193
 
194
+ // Convert split amounts from tier pricing to payment token denomination if currencies differ.
195
+ if (totalSplitAmount != 0) {
196
+ (totalSplitAmount, splitMetadata) = JB721TiersHookLib.convertSplitAmounts(
197
+ totalSplitAmount,
198
+ splitMetadata,
199
+ _packedPricingContext,
200
+ context.projectId,
201
+ context.amount.currency,
202
+ context.amount.decimals
203
+ );
204
+ }
205
+
189
206
  // Adjust weight so the terminal mints tokens only for the amount that actually enters the project.
190
207
  if (totalSplitAmount == 0 || STORE.flagsOf(address(this)).issueTokensForSplits) {
191
208
  // No splits, or hook configured to give full token credit regardless — full weight.
@@ -675,6 +692,12 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
675
692
 
676
693
  // Distribute any forwarded funds to tier split groups.
677
694
  if (context.hookMetadata.length != 0 && context.forwardedAmount.value != 0) {
695
+ // For ERC20 tokens, pull from terminal using the allowance it granted via _beforeTransferTo.
696
+ if (context.forwardedAmount.token != JBConstants.NATIVE_TOKEN) {
697
+ IERC20(context.forwardedAmount.token)
698
+ .safeTransferFrom(msg.sender, address(this), context.forwardedAmount.value);
699
+ }
700
+
678
701
  JB721TiersHookLib.distributeAll(
679
702
  DIRECTORY, SPLITS, PROJECT_ID, address(this), context.forwardedAmount.token, context.hookMetadata
680
703
  );
@@ -256,7 +256,7 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
256
256
  pausePay: payDataRulesetConfig.metadata.pausePay,
257
257
  pauseCreditTransfers: payDataRulesetConfig.metadata.pauseCreditTransfers,
258
258
  allowOwnerMinting: payDataRulesetConfig.metadata.allowOwnerMinting,
259
- allowSetCustomToken: false,
259
+ allowSetCustomToken: payDataRulesetConfig.metadata.allowSetCustomToken,
260
260
  allowTerminalMigration: payDataRulesetConfig.metadata.allowTerminalMigration,
261
261
  allowSetTerminals: payDataRulesetConfig.metadata.allowSetTerminals,
262
262
  allowSetController: payDataRulesetConfig.metadata.allowSetController,
@@ -324,7 +324,7 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
324
324
  pausePay: payDataRulesetConfig.metadata.pausePay,
325
325
  pauseCreditTransfers: payDataRulesetConfig.metadata.pauseCreditTransfers,
326
326
  allowOwnerMinting: payDataRulesetConfig.metadata.allowOwnerMinting,
327
- allowSetCustomToken: false,
327
+ allowSetCustomToken: payDataRulesetConfig.metadata.allowSetCustomToken,
328
328
  allowTerminalMigration: payDataRulesetConfig.metadata.allowTerminalMigration,
329
329
  allowSetTerminals: payDataRulesetConfig.metadata.allowSetTerminals,
330
330
  allowSetController: payDataRulesetConfig.metadata.allowSetController,
@@ -390,7 +390,7 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
390
390
  pausePay: payDataRulesetConfig.metadata.pausePay,
391
391
  pauseCreditTransfers: payDataRulesetConfig.metadata.pauseCreditTransfers,
392
392
  allowOwnerMinting: payDataRulesetConfig.metadata.allowOwnerMinting,
393
- allowSetCustomToken: false,
393
+ allowSetCustomToken: payDataRulesetConfig.metadata.allowSetCustomToken,
394
394
  allowTerminalMigration: payDataRulesetConfig.metadata.allowTerminalMigration,
395
395
  allowSetTerminals: payDataRulesetConfig.metadata.allowSetTerminals,
396
396
  allowSetController: payDataRulesetConfig.metadata.allowSetController,
@@ -189,6 +189,50 @@ library JB721TiersHookLib {
189
189
  }
190
190
  }
191
191
 
192
+ /// @notice Converts split amounts from tier pricing denomination to payment token denomination.
193
+ /// @dev Called after `calculateSplitAmounts` when the payment currency differs from the tier pricing currency.
194
+ /// @param totalSplitAmount The total split amount in tier pricing denomination.
195
+ /// @param splitMetadata The encoded per-tier breakdown (tierIds, amounts) from calculateSplitAmounts.
196
+ /// @param packedPricingContext The packed pricing context (currency, decimals, prices address).
197
+ /// @param projectId The project ID.
198
+ /// @param amountCurrency The payment amount currency.
199
+ /// @param amountDecimals The payment amount decimals.
200
+ /// @return convertedTotal The total split amount converted to payment token denomination.
201
+ /// @return convertedMetadata The re-encoded per-tier breakdown with converted amounts.
202
+ function convertSplitAmounts(
203
+ uint256 totalSplitAmount,
204
+ bytes memory splitMetadata,
205
+ uint256 packedPricingContext,
206
+ uint256 projectId,
207
+ uint256 amountCurrency,
208
+ uint256 amountDecimals
209
+ )
210
+ external
211
+ view
212
+ returns (uint256 convertedTotal, bytes memory convertedMetadata)
213
+ {
214
+ uint256 pricingCurrency = uint256(uint32(packedPricingContext));
215
+ if (amountCurrency == pricingCurrency) return (totalSplitAmount, splitMetadata);
216
+
217
+ IJBPrices prices = IJBPrices(address(uint160(packedPricingContext >> 40)));
218
+ if (address(prices) == address(0)) return (totalSplitAmount, splitMetadata);
219
+
220
+ uint256 pricingDecimals = uint256(uint8(packedPricingContext >> 32));
221
+ uint256 ratio = prices.pricePerUnitOf({
222
+ projectId: projectId,
223
+ pricingCurrency: amountCurrency,
224
+ unitCurrency: pricingCurrency,
225
+ decimals: amountDecimals
226
+ });
227
+
228
+ (uint16[] memory tierIds, uint256[] memory amounts) = abi.decode(splitMetadata, (uint16[], uint256[]));
229
+ for (uint256 i; i < amounts.length; i++) {
230
+ amounts[i] = mulDiv(amounts[i], ratio, 10 ** pricingDecimals);
231
+ convertedTotal += amounts[i];
232
+ }
233
+ convertedMetadata = abi.encode(tierIds, amounts);
234
+ }
235
+
192
236
  /// @notice Sets split groups in JBSplits for tiers that have splits configured.
193
237
  function _setSplitGroupsFor(
194
238
  IJBSplits splits,
@@ -11,6 +11,8 @@ pragma solidity ^0.8.0;
11
11
  /// during the funding cycle.
12
12
  /// @custom:member allowOwnerMinting A flag indicating if the project owner or an operator with the `MINT_TOKENS`
13
13
  /// permission from the owner should be allowed to mint project tokens on demand during this ruleset.
14
+ /// @custom:member allowSetCustomToken A flag indicating if the project owner can set the project's token to a custom
15
+ /// ERC-20.
14
16
  /// @custom:member allowTerminalMigration A flag indicating if migrating terminals should be allowed during this
15
17
  /// ruleset.
16
18
  /// @custom:member allowSetTerminals A flag indicating if a project's terminals can be added or removed.
@@ -33,6 +35,7 @@ struct JBPayDataHookRulesetMetadata {
33
35
  bool pausePay;
34
36
  bool pauseCreditTransfers;
35
37
  bool allowOwnerMinting;
38
+ bool allowSetCustomToken;
36
39
  bool allowTerminalMigration;
37
40
  bool allowSetTerminals;
38
41
  bool allowSetController;
@@ -814,6 +814,7 @@ contract Test_TiersHook_E2E is TestBaseWorkflow {
814
814
  pausePay: false,
815
815
  pauseCreditTransfers: false,
816
816
  allowOwnerMinting: true,
817
+ allowSetCustomToken: false,
817
818
  allowTerminalMigration: false,
818
819
  allowSetTerminals: false,
819
820
  allowSetController: false,
@@ -908,6 +909,7 @@ contract Test_TiersHook_E2E is TestBaseWorkflow {
908
909
  pausePay: false,
909
910
  pauseCreditTransfers: false,
910
911
  allowOwnerMinting: true,
912
+ allowSetCustomToken: false,
911
913
  allowTerminalMigration: false,
912
914
  allowSetTerminals: false,
913
915
  allowSetController: false,