@bananapus/core-v6 0.0.62 → 0.0.64

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/core-v6",
3
- "version": "0.0.62",
3
+ "version": "0.0.64",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -62,6 +62,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
62
62
  error JBController_AddingPriceFeedNotAllowed(uint256 projectId);
63
63
  error JBController_CreditTransfersPaused(uint256 projectId, uint256 rulesetId);
64
64
  error JBController_InvalidCashOutTaxRate(uint256 rate, uint256 limit);
65
+ error JBController_InvalidCreationFee(uint256 value, uint256 requiredFee);
65
66
  error JBController_InvalidReservedPercent(uint256 percent, uint256 limit);
66
67
  error JBController_MintNotAllowedAndNotTerminalOrHook(address caller);
67
68
  error JBController_NoReservedTokens(uint256 projectId);
@@ -408,11 +409,18 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
408
409
  string calldata memo
409
410
  )
410
411
  external
412
+ payable
411
413
  override
412
414
  returns (uint256 projectId)
413
415
  {
416
+ // Forward the exact project creation fee to `JBProjects`.
417
+ uint256 creationFee = PROJECTS.creationFee();
418
+ if (msg.value != creationFee) {
419
+ revert JBController_InvalidCreationFee({value: msg.value, requiredFee: creationFee});
420
+ }
421
+
414
422
  // Mint the project ERC-721 into the owner's wallet.
415
- projectId = PROJECTS.createFor(owner);
423
+ projectId = PROJECTS.createFor{value: creationFee}(owner);
416
424
 
417
425
  // If provided, set the project's metadata URI.
418
426
  if (bytes(projectUri).length > 0) {
@@ -626,7 +626,11 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
626
626
  }
627
627
 
628
628
  // Transfer the balance minus the fee to the new terminal.
629
- uint256 migrationAmount = balance - feeAmount;
629
+ uint256 migrationAmount;
630
+ // `_takeFeeFrom` calculated `feeAmount` from `balance`, so it cannot exceed `balance`.
631
+ unchecked {
632
+ migrationAmount = balance - feeAmount;
633
+ }
630
634
 
631
635
  _externalAddToBalance({
632
636
  terminal: to, projectId: projectId, token: token, amount: migrationAmount, metadata: bytes("")
@@ -690,7 +694,10 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
690
694
 
691
695
  // Set the beneficiary token count.
692
696
  if (beneficiaryBalanceAfter > beneficiaryBalanceBefore) {
693
- beneficiaryTokenCount = beneficiaryBalanceAfter - beneficiaryBalanceBefore;
697
+ // Guarded by the comparison above.
698
+ unchecked {
699
+ beneficiaryTokenCount = beneficiaryBalanceAfter - beneficiaryBalanceBefore;
700
+ }
694
701
  }
695
702
 
696
703
  // The token count for the beneficiary must be greater than or equal to the specified minimum.
@@ -737,7 +744,10 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
737
744
  // reverting.
738
745
  // A `FeeReverted` event is emitted so the forgiveness is observable off-chain.
739
746
  delete _heldFeesOf[projectId][token][currentIndex];
740
- _nextHeldFeeIndexOf[projectId][token] = currentIndex + 1;
747
+ // `currentIndex` was proven to be within the held-fee array.
748
+ unchecked {
749
+ _nextHeldFeeIndexOf[projectId][token] = currentIndex + 1;
750
+ }
741
751
 
742
752
  // Restore the originating fee-paying call's referral project for the duration of this fee's processing
743
753
  // so the credit in `_processFee` attributes to the right (chain, project) pair. No save needed:
@@ -1879,6 +1889,10 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1879
1889
  });
1880
1890
 
1881
1891
  _recordAddedBalanceFor({projectId: projectId, token: token, amount: amount});
1892
+ // The store balance was credited first; this mirrors that bounded increase for fee recovery.
1893
+ unchecked {
1894
+ _feeFreeSurplusOf[projectId][token] += amount;
1895
+ }
1882
1896
  }
1883
1897
  }
1884
1898
 
@@ -1887,7 +1901,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1887
1901
  /// @param token The token to record the added balance for.
1888
1902
  /// @param amount The amount of the token to record, as a fixed point number with the same number of decimals as
1889
1903
  /// this terminal.
1890
- function _recordAddedBalanceFor(uint256 projectId, address token, uint256 amount) internal {
1904
+ function _recordAddedBalanceFor(uint256 projectId, address token, uint256 amount) private {
1891
1905
  STORE.recordAddedBalanceFor({projectId: projectId, token: token, amount: amount});
1892
1906
  }
1893
1907
 
package/src/JBPrices.sol CHANGED
@@ -2,7 +2,7 @@
2
2
  pragma solidity 0.8.28;
3
3
 
4
4
  import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
5
- import {ERC2771Context, Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
5
+ import {Context, ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
6
6
  import {mulDiv} from "@prb/math/src/Common.sol";
7
7
 
8
8
  import {JBControlled} from "./abstract/JBControlled.sol";
@@ -13,21 +13,19 @@ import {IJBPriceFeed} from "./interfaces/IJBPriceFeed.sol";
13
13
  import {IJBPrices} from "./interfaces/IJBPrices.sol";
14
14
  import {IJBProjects} from "./interfaces/IJBProjects.sol";
15
15
 
16
- /// @notice Provides currency conversion for the protocol. When a project's payout limits or surplus allowances are
17
- /// denominated in a currency different from the token held in its terminal (e.g. USD limits with ETH held), this
18
- /// contract resolves the exchange rate via registered price feeds (typically Chainlink oracles).
19
- /// @dev Price feeds are immutable once set they cannot be replaced or removed. This protects against oracle
20
- /// manipulation via admin-key attacks. If a feed is misconfigured, operations using that pair will revert (DoS, not
21
- /// fund loss). The inverse of any registered feed is auto-calculated. Projects can have their own feeds; project ID 0
22
- /// holds protocol-wide defaults.
16
+ /// @notice Resolves protocol currency conversions from append-only project and default price feeds.
17
+ /// @dev Each feed prices one unit of `unitCurrency` in `pricingCurrency`. Feeds cannot be changed or removed once
18
+ /// added. Later feeds for the same exact pair act as backups when earlier feeds revert or return zero. Project-specific
19
+ /// feeds are checked before project ID 0 defaults, and inverse prices are derived at read time when only the opposite
20
+ /// direction is configured. If no configured feed returns a non-zero price, the read reverts instead of guessing.
23
21
  contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBPrices {
24
22
  //*********************************************************************//
25
23
  // --------------------------- custom errors ------------------------- //
26
24
  //*********************************************************************//
27
25
 
28
- error JBPrices_PriceFeedAlreadyExists(IJBPriceFeed feed);
26
+ error JBPrices_PriceFeedAlreadyAdded(IJBPriceFeed feed);
29
27
  error JBPrices_PriceFeedNotFound(uint256 projectId, uint256 pricingCurrency, uint256 unitCurrency);
30
- error JBPrices_ZeroPrice(uint256 projectId, uint256 pricingCurrency, uint256 unitCurrency, IJBPriceFeed feed);
28
+ error JBPrices_ZeroPriceFeed();
31
29
  error JBPrices_ZeroPricingCurrency(uint256 projectId, uint256 pricingCurrency);
32
30
  error JBPrices_ZeroUnitCurrency(uint256 projectId, uint256 unitCurrency);
33
31
 
@@ -46,23 +44,23 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
46
44
  IJBProjects public immutable override PROJECTS;
47
45
 
48
46
  //*********************************************************************//
49
- // --------------------- public stored properties -------------------- //
47
+ // -------------------- internal stored properties ------------------- //
50
48
  //*********************************************************************//
51
49
 
52
- /// @notice The available price feeds.
53
- /// @dev The feed returns the `pricingCurrency` cost for one unit of the `unitCurrency`.
54
- /// @custom:param projectId The ID of the project the feed applies to. Feeds stored in ID 0 are used by default for
55
- /// all projects.
56
- /// @custom:param pricingCurrency The currency the feed's resulting price is in terms of.
57
- /// @custom:param unitCurrency The currency the feed prices.
58
- mapping(uint256 projectId => mapping(uint256 pricingCurrency => mapping(uint256 unitCurrency => IJBPriceFeed)))
59
- public
60
- override priceFeedFor;
50
+ /// @notice Price feeds available to each project for each exact currency pair.
51
+ /// @dev The array is append-only. Index 0 remains the primary feed, and later indexes are backups.
52
+ /// @custom:param projectId The ID of the project the feed applies to. Project ID 0 stores protocol defaults.
53
+ /// @custom:param pricingCurrency The currency the feed's returned price is denominated in.
54
+ /// @custom:param unitCurrency The currency whose unit is priced by the feed.
55
+ mapping(
56
+ uint256 projectId => mapping(uint256 pricingCurrency => mapping(uint256 unitCurrency => IJBPriceFeed[]))
57
+ ) internal _priceFeedsFor;
61
58
 
62
59
  //*********************************************************************//
63
60
  // ---------------------------- constructor -------------------------- //
64
61
  //*********************************************************************//
65
62
 
63
+ /// @notice Initializes the price registry and its permissioned dependencies.
66
64
  /// @param directory A contract storing directories of terminals and controllers for each project.
67
65
  /// @param permissions A contract storing permissions.
68
66
  /// @param projects A contract which mints ERC-721s that represent project ownership and transfers.
@@ -87,17 +85,16 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
87
85
  // ---------------------- external transactions ---------------------- //
88
86
  //*********************************************************************//
89
87
 
90
- /// @notice Register a price feed that provides the exchange rate between two currencies. For example, registering
91
- /// an ETH/USD feed allows payout limits denominated in USD to be enforced against ETH balances.
92
- /// @dev Price feeds are immutable once set for a currency pair, they cannot be replaced or removed. This
93
- /// prevents
94
- /// admin-key oracle manipulation. The inverse rate is auto-calculated, so registering A→B also provides B→A.
95
- /// @dev Pass `projectId` = 0 to set a protocol-wide default (owner only). Non-zero project IDs require controller
96
- /// authorization. A default feed for a pair blocks per-project overrides for that same pair.
97
- /// @param projectId The ID of the project to add a feed for. Pass 0 for a protocol-wide default.
98
- /// @param pricingCurrency The currency the feed's output price is in terms of.
99
- /// @param unitCurrency The currency the feed prices.
100
- /// @param feed The address of the price feed to add.
88
+ /// @notice Adds an append-only price feed for a project's exact currency pair.
89
+ /// @dev Project ID 0 stores protocol defaults and can only be configured by this contract's owner. Non-zero
90
+ /// project IDs can only be configured by that project's controller. The feed is stored only for the exact
91
+ /// `pricingCurrency`/`unitCurrency` direction; the opposite direction is derived by `pricePerUnitOf` when needed.
92
+ /// @dev Later feeds for the same exact pair are backups. The existing feeds remain preferred, and this function
93
+ /// rejects duplicate feed addresses for the exact pair.
94
+ /// @param projectId The ID of the project to add the feed for, or 0 to add a protocol default.
95
+ /// @param pricingCurrency The currency that the feed's returned price is denominated in.
96
+ /// @param unitCurrency The currency whose unit is priced by the feed.
97
+ /// @param feed The price feed to add.
101
98
  function addPriceFeedFor(
102
99
  uint256 projectId,
103
100
  uint256 pricingCurrency,
@@ -107,48 +104,26 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
107
104
  external
108
105
  override
109
106
  {
110
- // Ensure default price feeds can only be set by this contract's owner, and that other `projectId`s can only be
111
- // set by the controller
107
+ // Project 0 configures defaults for every project; each other project delegates feed configuration to its
108
+ // controller.
112
109
  projectId == DEFAULT_PROJECT_ID ? _checkOwner() : _onlyControllerOf(projectId);
113
110
 
114
- // Make sure the pricing currency isn't 0.
115
111
  if (pricingCurrency == 0) {
116
112
  revert JBPrices_ZeroPricingCurrency({projectId: projectId, pricingCurrency: pricingCurrency});
117
113
  }
118
114
 
119
- // Make sure the unit currency isn't 0.
120
115
  if (unitCurrency == 0) revert JBPrices_ZeroUnitCurrency({projectId: projectId, unitCurrency: unitCurrency});
121
116
 
122
- // Make sure there isn't already a default price feed for the pair or its inverse.
123
- if (
124
- priceFeedFor[DEFAULT_PROJECT_ID][pricingCurrency][unitCurrency] != IJBPriceFeed(address(0))
125
- || priceFeedFor[DEFAULT_PROJECT_ID][unitCurrency][pricingCurrency] != IJBPriceFeed(address(0))
126
- ) {
127
- revert JBPrices_PriceFeedAlreadyExists({
128
- feed: priceFeedFor[DEFAULT_PROJECT_ID][pricingCurrency][unitCurrency] != IJBPriceFeed(address(0))
129
- ? priceFeedFor[DEFAULT_PROJECT_ID][pricingCurrency][unitCurrency]
130
- : priceFeedFor[DEFAULT_PROJECT_ID][unitCurrency][pricingCurrency]
131
- });
132
- }
117
+ if (feed == IJBPriceFeed(address(0))) revert JBPrices_ZeroPriceFeed();
133
118
 
134
- // Make sure this project doesn't already have a price feed for the pair or its inverse.
135
- if (
136
- priceFeedFor[projectId][pricingCurrency][unitCurrency] != IJBPriceFeed(address(0))
137
- || priceFeedFor[projectId][unitCurrency][pricingCurrency] != IJBPriceFeed(address(0))
138
- ) {
139
- revert JBPrices_PriceFeedAlreadyExists({
140
- feed: priceFeedFor[projectId][pricingCurrency][unitCurrency] != IJBPriceFeed(address(0))
141
- ? priceFeedFor[projectId][pricingCurrency][unitCurrency]
142
- : priceFeedFor[projectId][unitCurrency][pricingCurrency]
143
- });
144
- }
119
+ // Only exact-direction duplicates are rejected. Opposite-direction feeds can coexist and are used when deriving
120
+ // inverse prices.
121
+ _requireNewPriceFeed({
122
+ projectId: projectId, pricingCurrency: pricingCurrency, unitCurrency: unitCurrency, feed: feed
123
+ });
145
124
 
146
- // Price feed immutability is by design to prevent admin-key attacks on price oracles.
147
- // If a feed fails, operations using that currency pair revert (DoS but not fund loss). Projects can use
148
- // alternative currency pairs. A default feed for a currency pair prevents per-project overrides to ensure
149
- // price consistency; projects should use unused currency IDs for custom pricing.
150
- // Store the feed.
151
- priceFeedFor[projectId][pricingCurrency][unitCurrency] = feed;
125
+ // Keep existing feeds immutable: appending preserves the primary feed and adds this feed as the last fallback.
126
+ _priceFeedsFor[projectId][pricingCurrency][unitCurrency].push(feed);
152
127
 
153
128
  emit AddPriceFeed({
154
129
  projectId: projectId,
@@ -159,21 +134,86 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
159
134
  });
160
135
  }
161
136
 
137
+ //*********************************************************************//
138
+ // ----------------------- external views ---------------------------- //
139
+ //*********************************************************************//
140
+
141
+ /// @notice Returns the feed stored at an exact pair's index.
142
+ /// @dev This view does not apply inverse or project-default fallback lookup. It reverts with Solidity's default
143
+ /// array bounds check if `index` is not configured.
144
+ /// @param projectId The ID of the project whose feed should be returned.
145
+ /// @param pricingCurrency The currency that the feed's returned price is denominated in.
146
+ /// @param unitCurrency The currency whose unit is priced by the feed.
147
+ /// @param index The index of the feed to return.
148
+ /// @return feed The configured price feed for the exact pair at `index`.
149
+ function priceFeedAt(
150
+ uint256 projectId,
151
+ uint256 pricingCurrency,
152
+ uint256 unitCurrency,
153
+ uint256 index
154
+ )
155
+ external
156
+ view
157
+ override
158
+ returns (IJBPriceFeed feed)
159
+ {
160
+ return _priceFeedsFor[projectId][pricingCurrency][unitCurrency][index];
161
+ }
162
+
163
+ /// @notice Returns the number of feeds configured for an exact currency pair.
164
+ /// @dev This count does not include feeds configured for the inverse direction or project ID 0 defaults.
165
+ /// @param projectId The ID of the project whose feed count should be returned.
166
+ /// @param pricingCurrency The currency that the feeds' returned prices are denominated in.
167
+ /// @param unitCurrency The currency whose unit is priced by the feeds.
168
+ /// @return count The number of configured price feeds for the exact pair.
169
+ function priceFeedCountFor(
170
+ uint256 projectId,
171
+ uint256 pricingCurrency,
172
+ uint256 unitCurrency
173
+ )
174
+ external
175
+ view
176
+ override
177
+ returns (uint256 count)
178
+ {
179
+ return _priceFeedsFor[projectId][pricingCurrency][unitCurrency].length;
180
+ }
181
+
182
+ /// @notice Returns the primary feed for an exact currency pair, or zero if none is configured.
183
+ /// @dev This view does not apply inverse or project-default fallback lookup. Use `pricePerUnitOf` to resolve a
184
+ /// usable price through the full backup path.
185
+ /// @param projectId The ID of the project whose primary feed should be returned.
186
+ /// @param pricingCurrency The currency that the feed's returned price is denominated in.
187
+ /// @param unitCurrency The currency whose unit is priced by the feed.
188
+ /// @return feed The first configured price feed for the exact pair, or the zero address if none exists.
189
+ function priceFeedFor(
190
+ uint256 projectId,
191
+ uint256 pricingCurrency,
192
+ uint256 unitCurrency
193
+ )
194
+ external
195
+ view
196
+ override
197
+ returns (IJBPriceFeed feed)
198
+ {
199
+ return _priceFeedsFor[projectId][pricingCurrency][unitCurrency].length == 0
200
+ ? IJBPriceFeed(address(0))
201
+ : _priceFeedsFor[projectId][pricingCurrency][unitCurrency][0];
202
+ }
203
+
162
204
  //*********************************************************************//
163
205
  // -------------------------- public views --------------------------- //
164
206
  //*********************************************************************//
165
207
 
166
- /// @notice Convert between currencies returns how much of `pricingCurrency` one unit of `unitCurrency` is
167
- /// worth.
168
- /// For example, `pricePerUnitOf(id, USD, ETH, 18)` returns the USD price of 1 ETH with 18 decimals.
169
- /// @dev Lookup order: project-specific feed inverse of project feed default feed (project 0) → inverse of
170
- /// default. Reverts with `JBPrices_PriceFeedNotFound` if no feed exists in any direction.
171
- /// @param projectId The ID of the project to check the feed for. Falls back to project 0 (protocol defaults).
172
- /// @param pricingCurrency The currency the result is denominated in.
173
- /// @param unitCurrency The currency to price.
174
- /// @param decimals The number of decimals the returned fixed point price should include.
175
- /// @return The `pricingCurrency` price of 1 `unitCurrency`, as a fixed point number with the specified number of
176
- /// decimals.
208
+ /// @notice Returns the price of one `unitCurrency` unit denominated in `pricingCurrency`.
209
+ /// @dev Lookup order is project direct feeds, project inverse feeds, default direct feeds, then default inverse
210
+ /// feeds. Each feed list is tried in registration order, skipping feeds that revert or return zero. Reverts with
211
+ /// `JBPrices_PriceFeedNotFound` if no configured feed in that lookup path returns a non-zero price.
212
+ /// @param projectId The ID of the project to check first. Project ID 0 feeds are used as defaults.
213
+ /// @param pricingCurrency The currency that the returned price is denominated in.
214
+ /// @param unitCurrency The currency whose unit is being priced.
215
+ /// @param decimals The number of decimals the returned fixed point price should use.
216
+ /// @return price The `pricingCurrency` price of one `unitCurrency`, using `decimals` fixed point precision.
177
217
  function pricePerUnitOf(
178
218
  uint256 projectId,
179
219
  uint256 pricingCurrency,
@@ -183,71 +223,84 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
183
223
  public
184
224
  view
185
225
  override
186
- returns (uint256)
226
+ returns (uint256 price)
187
227
  {
188
- // If the `pricingCurrency` is the `unitCurrency`, return 1 since they have the same price. Include the
189
- // desired number of decimals.
228
+ // Same-currency conversions are always 1 in the requested fixed-point precision.
190
229
  if (pricingCurrency == unitCurrency) return 10 ** decimals;
191
230
 
192
- // Get a reference to the price feed.
193
- IJBPriceFeed feed = priceFeedFor[projectId][pricingCurrency][unitCurrency];
231
+ bool found;
194
232
 
195
- // If the feed exists, return its non-zero price.
196
- if (feed != IJBPriceFeed(address(0))) {
197
- uint256 price = feed.currentUnitPrice(decimals);
198
- if (price == 0) {
199
- revert JBPrices_ZeroPrice({
200
- projectId: projectId, pricingCurrency: pricingCurrency, unitCurrency: unitCurrency, feed: feed
201
- });
202
- }
203
- return price;
204
- }
205
-
206
- // Try getting the inverse feed.
207
- feed = priceFeedFor[projectId][unitCurrency][pricingCurrency];
208
-
209
- // If it exists, return the inverse of its price.
210
- // @dev The inverse calculation `(10^d * 10^d) / price` has acceptable precision when the feed price
211
- // is in the range of ~1e9 to ~1e27 (for 18 decimals). Extreme prices outside this range may lose
212
- // significant precision due to fixed-point division truncation.
213
- if (feed != IJBPriceFeed(address(0))) {
214
- uint256 inversePrice = feed.currentUnitPrice(decimals);
215
- if (inversePrice == 0) {
216
- revert JBPrices_ZeroPrice({
217
- projectId: projectId, pricingCurrency: unitCurrency, unitCurrency: pricingCurrency, feed: feed
218
- });
219
- }
220
- return mulDiv({x: 10 ** decimals, y: 10 ** decimals, denominator: inversePrice});
221
- }
233
+ // Project-specific feeds take priority over defaults, including their configured backups.
234
+ (price, found) = _pricePerUnitOf({
235
+ projectId: projectId, pricingCurrency: pricingCurrency, unitCurrency: unitCurrency, decimals: decimals
236
+ });
237
+ if (found) return price;
222
238
 
223
- // Check for a default feed (project ID 0) if not found.
224
239
  if (projectId != DEFAULT_PROJECT_ID) {
225
- return pricePerUnitOf({
240
+ // Project 0 feeds are the shared defaults. Avoid checking them twice when project ID 0 was requested.
241
+ (price, found) = _pricePerUnitOf({
226
242
  projectId: DEFAULT_PROJECT_ID,
227
243
  pricingCurrency: pricingCurrency,
228
244
  unitCurrency: unitCurrency,
229
245
  decimals: decimals
230
246
  });
247
+ if (found) return price;
231
248
  }
232
249
 
233
- // No price feed available, revert.
234
250
  revert JBPrices_PriceFeedNotFound({
235
251
  projectId: projectId, pricingCurrency: pricingCurrency, unitCurrency: unitCurrency
236
252
  });
237
253
  }
238
254
 
239
255
  //*********************************************************************//
240
- // -------------------------- internal views ------------------------- //
256
+ // ----------------------- internal helpers -------------------------- //
241
257
  //*********************************************************************//
242
258
 
259
+ /// @notice Reverts if `feed` is already configured for an exact currency pair.
260
+ /// @param projectId The ID of the project whose pair should be checked.
261
+ /// @param pricingCurrency The currency that the feed's returned price is denominated in.
262
+ /// @param unitCurrency The currency whose unit is priced by the feed.
263
+ /// @param feed The price feed to check.
264
+ function _requireNewPriceFeed(
265
+ uint256 projectId,
266
+ uint256 pricingCurrency,
267
+ uint256 unitCurrency,
268
+ IJBPriceFeed feed
269
+ )
270
+ internal
271
+ view
272
+ {
273
+ IJBPriceFeed[] storage feeds = _priceFeedsFor[projectId][pricingCurrency][unitCurrency];
274
+ uint256 numberOfFeeds = feeds.length;
275
+
276
+ for (uint256 i; i < numberOfFeeds;) {
277
+ if (feeds[i] == feed) revert JBPrices_PriceFeedAlreadyAdded({feed: feed});
278
+
279
+ unchecked {
280
+ ++i;
281
+ }
282
+ }
283
+ }
284
+
285
+ //*********************************************************************//
286
+ // ----------------------- internal views ---------------------------- //
287
+ //*********************************************************************//
288
+
289
+ /// @notice Returns the ERC-2771 context suffix length.
243
290
  /// @dev `ERC-2771` specifies the context as being a single address (20 bytes).
244
- function _contextSuffixLength() internal view override(ERC2771Context, Context) returns (uint256) {
291
+ /// @return contextSuffixLength The context suffix length.
292
+ function _contextSuffixLength()
293
+ internal
294
+ view
295
+ override(ERC2771Context, Context)
296
+ returns (uint256 contextSuffixLength)
297
+ {
245
298
  return super._contextSuffixLength();
246
299
  }
247
300
 
248
301
  /// @notice The calldata. Preferred to use over `msg.data`.
249
- /// @return calldata The `msg.data` of this call.
250
- function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) {
302
+ /// @return data The `msg.data` of this call.
303
+ function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata data) {
251
304
  return ERC2771Context._msgData();
252
305
  }
253
306
 
@@ -256,4 +309,89 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
256
309
  function _msgSender() internal view override(ERC2771Context, Context) returns (address sender) {
257
310
  return ERC2771Context._msgSender();
258
311
  }
312
+
313
+ /// @notice Returns the first non-zero direct price from `feeds`.
314
+ /// @dev Feeds are tried in registration order. A feed that reverts or returns zero is treated as unavailable.
315
+ /// @param feeds The direct price feeds to try.
316
+ /// @param decimals The number of decimals the returned fixed point price should use.
317
+ /// @return price The first non-zero price returned by the feeds.
318
+ /// @return found Whether a usable price was found.
319
+ function _priceFrom(
320
+ IJBPriceFeed[] storage feeds,
321
+ uint256 decimals
322
+ )
323
+ internal
324
+ view
325
+ returns (uint256 price, bool found)
326
+ {
327
+ uint256 numberOfFeeds = feeds.length;
328
+ for (uint256 i; i < numberOfFeeds;) {
329
+ // Try each feed independently so one unavailable oracle does not block later backups.
330
+ try feeds[i].currentUnitPrice(decimals) returns (uint256 returnedPrice) {
331
+ if (returnedPrice != 0) return (returnedPrice, true);
332
+ } catch {}
333
+
334
+ unchecked {
335
+ ++i;
336
+ }
337
+ }
338
+ }
339
+
340
+ /// @notice Returns the first non-zero inverse price from `feeds`.
341
+ /// @dev Feeds are tried in registration order. A feed that reverts, returns zero, or inverts to zero at the
342
+ /// requested precision is treated as unavailable.
343
+ /// @param feeds The opposite-direction price feeds to invert.
344
+ /// @param decimals The number of decimals the returned fixed point price should use.
345
+ /// @return price The first non-zero inverse price returned by the feeds.
346
+ /// @return found Whether a usable inverse price was found.
347
+ function _priceFromInverse(
348
+ IJBPriceFeed[] storage feeds,
349
+ uint256 decimals
350
+ )
351
+ internal
352
+ view
353
+ returns (uint256 price, bool found)
354
+ {
355
+ uint256 numberOfFeeds = feeds.length;
356
+ for (uint256 i; i < numberOfFeeds;) {
357
+ // Each opposite-direction feed is optional; continue to backups if it cannot produce a usable price.
358
+ try feeds[i].currentUnitPrice(decimals) returns (uint256 inversePrice) {
359
+ if (inversePrice != 0) {
360
+ // Convert "unit per pricing" into "pricing per unit" using the same fixed-point precision.
361
+ uint256 invertedPrice = mulDiv({x: 10 ** decimals, y: 10 ** decimals, denominator: inversePrice});
362
+ if (invertedPrice != 0) return (invertedPrice, true);
363
+ }
364
+ } catch {}
365
+
366
+ unchecked {
367
+ ++i;
368
+ }
369
+ }
370
+ }
371
+
372
+ /// @notice Returns a non-zero price from a project's direct or inverse feeds.
373
+ /// @dev Direct feeds are preferred over inverse feeds for the same project and pair.
374
+ /// @param projectId The ID of the project whose feeds should be checked.
375
+ /// @param pricingCurrency The currency that the returned price should be denominated in.
376
+ /// @param unitCurrency The currency whose unit is being priced.
377
+ /// @param decimals The number of decimals the returned fixed point price should use.
378
+ /// @return price The first usable direct or inverse price.
379
+ /// @return found Whether a usable price was found.
380
+ function _pricePerUnitOf(
381
+ uint256 projectId,
382
+ uint256 pricingCurrency,
383
+ uint256 unitCurrency,
384
+ uint256 decimals
385
+ )
386
+ internal
387
+ view
388
+ returns (uint256 price, bool found)
389
+ {
390
+ (price, found) =
391
+ _priceFrom({feeds: _priceFeedsFor[projectId][pricingCurrency][unitCurrency], decimals: decimals});
392
+ if (found) return (price, true);
393
+
394
+ (price, found) =
395
+ _priceFromInverse({feeds: _priceFeedsFor[projectId][unitCurrency][pricingCurrency], decimals: decimals});
396
+ }
259
397
  }
@@ -4,6 +4,7 @@ pragma solidity 0.8.28;
4
4
  import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
5
5
  import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
6
6
  import {ERC721, Context} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
7
+ import {Address} from "@openzeppelin/contracts/utils/Address.sol";
7
8
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
8
9
 
9
10
  import {IJBProjects} from "./interfaces/IJBProjects.sol";
@@ -13,10 +14,23 @@ import {IJBTokenUriResolver} from "./interfaces/IJBTokenUriResolver.sol";
13
14
  /// rulesets, terminals, and permissions. Projects are created with `createFor` and the resulting token ID is used as
14
15
  /// the project's ID across the entire protocol.
15
16
  contract JBProjects is ERC721, ERC2771Context, Ownable, IJBProjects {
17
+ //*********************************************************************//
18
+ // --------------------------- custom errors ------------------------- //
19
+ //*********************************************************************//
20
+
21
+ error JBProjects_InvalidCreationFee(uint256 value, uint256 requiredFee);
22
+ error JBProjects_ZeroCreationFeeReceiver();
23
+
16
24
  //*********************************************************************//
17
25
  // --------------------- public stored properties -------------------- //
18
26
  //*********************************************************************//
19
27
 
28
+ /// @notice The native-token fee required to create a project.
29
+ uint256 public override creationFee;
30
+
31
+ /// @notice The address that receives project creation fees.
32
+ address payable public override creationFeeReceiver;
33
+
20
34
  /// @notice The number of projects that have been created using this contract.
21
35
  /// @dev The count is incremented with each new project created.
22
36
  /// @dev The resulting ERC-721 token ID for each project is the newly incremented count value.
@@ -51,6 +65,21 @@ contract JBProjects is ERC721, ERC2771Context, Ownable, IJBProjects {
51
65
  // ---------------------- external transactions ---------------------- //
52
66
  //*********************************************************************//
53
67
 
68
+ /// @notice Set the native-token fee required to create a project and the address that receives it.
69
+ /// @dev Only this contract's owner can change the fee. A non-zero fee requires a non-zero receiver.
70
+ /// @param fee The required creation fee. Set to 0 to disable creation fees.
71
+ /// @param receiver The address that receives project creation fees.
72
+ function setCreationFee(uint256 fee, address payable receiver) external override onlyOwner {
73
+ // Non-zero fees need somewhere to go.
74
+ if (fee != 0 && receiver == address(0)) revert JBProjects_ZeroCreationFeeReceiver();
75
+
76
+ // Store the fee configuration.
77
+ creationFee = fee;
78
+ creationFeeReceiver = receiver;
79
+
80
+ emit SetCreationFee({fee: fee, receiver: receiver, caller: _msgSender()});
81
+ }
82
+
54
83
  /// @notice Set the contract that resolves project NFT metadata (the `tokenURI`). This controls what artwork and
55
84
  /// JSON metadata is returned for each project's ERC-721 token.
56
85
  /// @dev Only this contract's owner can change the resolver.
@@ -68,9 +97,14 @@ contract JBProjects is ERC721, ERC2771Context, Ownable, IJBProjects {
68
97
 
69
98
  /// @notice Create a new project for the specified owner, which mints an NFT (ERC-721) into their wallet.
70
99
  /// @dev Anyone can create a project on an owner's behalf.
100
+ /// @dev Requires exactly `creationFee` native tokens. The fee is forwarded after the project NFT is minted.
71
101
  /// @param owner The address that will be the owner of the project.
72
102
  /// @return projectId The token ID of the newly created project.
73
- function createFor(address owner) public override returns (uint256 projectId) {
103
+ function createFor(address owner) public payable override returns (uint256 projectId) {
104
+ // Keep a reference to the fee. It must be paid exactly to avoid accidental overpayment.
105
+ uint256 fee = creationFee;
106
+ if (msg.value != fee) revert JBProjects_InvalidCreationFee({value: msg.value, requiredFee: fee});
107
+
74
108
  // Increment the count, which will be used as the ID.
75
109
  projectId = ++count;
76
110
 
@@ -78,6 +112,13 @@ contract JBProjects is ERC721, ERC2771Context, Ownable, IJBProjects {
78
112
 
79
113
  // Mint the project.
80
114
  _safeMint({to: owner, tokenId: projectId});
115
+
116
+ // Forward the fee if one is configured.
117
+ if (fee != 0) {
118
+ address payable receiver = creationFeeReceiver;
119
+ if (receiver == address(0)) revert JBProjects_ZeroCreationFeeReceiver();
120
+ Address.sendValue({recipient: receiver, amount: fee});
121
+ }
81
122
  }
82
123
 
83
124
  //*********************************************************************//
@@ -43,6 +43,7 @@ contract JBTerminalStore is IJBTerminalStore {
43
43
  error JBTerminalStore_AccountingContextDecimalsMismatch(
44
44
  address token, uint256 providedDecimals, uint256 expectedDecimals
45
45
  );
46
+ error JBTerminalStore_AccountingContextDecimalsOutOfRange(address token, uint256 decimals);
46
47
  error JBTerminalStore_AddingAccountingContextNotAllowed(uint256 projectId, uint256 rulesetId, address terminal);
47
48
  error JBTerminalStore_InadequateControllerAllowance(uint256 amount, uint256 allowance);
48
49
 
@@ -212,6 +213,12 @@ contract JBTerminalStore is IJBTerminalStore {
212
213
  revert JBTerminalStore_AccountingContextAlreadySet({token: context.token});
213
214
  }
214
215
 
216
+ if (context.decimals > 36) {
217
+ revert JBTerminalStore_AccountingContextDecimalsOutOfRange({
218
+ token: context.token, decimals: context.decimals
219
+ });
220
+ }
221
+
215
222
  // Keep track of a flag indicating if we know the provided decimals are incorrect.
216
223
  bool knownInvalidDecimals;
217
224
  uint256 expectedDecimals;
@@ -222,16 +229,22 @@ contract JBTerminalStore is IJBTerminalStore {
222
229
  expectedDecimals = 18;
223
230
  } else if (context.token != JBConstants.NATIVE_TOKEN && context.token.code.length > 0) {
224
231
  try IERC20Metadata(context.token).decimals() returns (uint8 decimals) {
225
- if (context.decimals != decimals) {
226
- knownInvalidDecimals = true;
227
- expectedDecimals = decimals;
228
- }
232
+ expectedDecimals = decimals;
229
233
  } catch {
230
234
  // The token didn't support `decimals`.
231
235
  // @dev Non-standard ERC20s that revert on `decimals()` will bypass decimal validation.
232
236
  // The caller is responsible for providing the correct decimals for such tokens.
233
237
  knownInvalidDecimals = false;
238
+ expectedDecimals = context.decimals;
234
239
  }
240
+
241
+ if (expectedDecimals > 36) {
242
+ revert JBTerminalStore_AccountingContextDecimalsOutOfRange({
243
+ token: context.token, decimals: expectedDecimals
244
+ });
245
+ }
246
+
247
+ if (context.decimals != expectedDecimals) knownInvalidDecimals = true;
235
248
  }
236
249
 
237
250
  // Make sure the decimals are correct.
@@ -306,6 +306,7 @@ interface IJBController is IERC165, IJBProjectUriRegistry, IJBDirectoryAccessCon
306
306
  string calldata memo
307
307
  )
308
308
  external
309
+ payable
309
310
  returns (uint256 projectId);
310
311
 
311
312
  /// @notice Queues a project's initial rulesets and sets up terminals for it.
@@ -4,14 +4,12 @@ pragma solidity ^0.8.0;
4
4
  import {IJBPriceFeed} from "./IJBPriceFeed.sol";
5
5
  import {IJBProjects} from "./IJBProjects.sol";
6
6
 
7
- /// @notice Interface for the price feed registry. Resolves exchange rates between currencies via immutable price feeds
8
- /// (typically Chainlink). Used when payout limits or surplus allowances are denominated in a different currency than
9
- /// the token held in the terminal.
7
+ /// @notice Resolves protocol currency conversions from append-only project and default price feeds.
10
8
  interface IJBPrices {
11
- /// @notice A price feed was added for a project's currency pair.
12
- /// @param projectId The ID of the project the price feed was added for.
13
- /// @param pricingCurrency The currency the feed's output price is in terms of.
14
- /// @param unitCurrency The currency the feed prices.
9
+ /// @notice Emitted when a price feed is added for an exact currency pair.
10
+ /// @param projectId The ID of the project the price feed was added for. Project ID 0 stores protocol defaults.
11
+ /// @param pricingCurrency The currency that the feed's returned price is denominated in.
12
+ /// @param unitCurrency The currency whose unit is priced by the feed.
15
13
  /// @param feed The price feed that was added.
16
14
  /// @param caller The address that added the price feed.
17
15
  event AddPriceFeed(
@@ -22,17 +20,54 @@ interface IJBPrices {
22
20
  address caller
23
21
  );
24
22
 
25
- /// @notice The project ID used as a fallback when no project-specific price feed is set.
26
- function DEFAULT_PROJECT_ID() external view returns (uint256);
23
+ /// @notice The project ID used to store protocol default price feeds.
24
+ /// @return projectId The project ID used to store protocol defaults.
25
+ function DEFAULT_PROJECT_ID() external view returns (uint256 projectId);
27
26
 
28
27
  /// @notice Mints ERC-721s that represent project ownership and transfers.
29
- function PROJECTS() external view returns (IJBProjects);
28
+ /// @return projects The project NFT contract.
29
+ function PROJECTS() external view returns (IJBProjects projects);
30
30
 
31
- /// @notice Returns the price feed for a project's currency pair.
32
- /// @param projectId The ID of the project to get the price feed of.
33
- /// @param pricingCurrency The currency the feed's output price is in terms of.
34
- /// @param unitCurrency The currency the feed prices.
35
- /// @return The price feed for the currency pair.
31
+ /// @notice Returns the feed stored at an exact pair's index.
32
+ /// @dev This view does not apply inverse or project-default fallback lookup. It reverts with Solidity's default
33
+ /// array bounds check if `index` is not configured.
34
+ /// @param projectId The ID of the project whose feed should be returned.
35
+ /// @param pricingCurrency The currency that the feed's returned price is denominated in.
36
+ /// @param unitCurrency The currency whose unit is priced by the feed.
37
+ /// @param index The index of the feed to return.
38
+ /// @return feed The configured price feed for the exact pair at `index`.
39
+ function priceFeedAt(
40
+ uint256 projectId,
41
+ uint256 pricingCurrency,
42
+ uint256 unitCurrency,
43
+ uint256 index
44
+ )
45
+ external
46
+ view
47
+ returns (IJBPriceFeed);
48
+
49
+ /// @notice Returns the number of feeds configured for an exact currency pair.
50
+ /// @dev This count does not include feeds configured for the inverse direction or project ID 0 defaults.
51
+ /// @param projectId The ID of the project whose feed count should be returned.
52
+ /// @param pricingCurrency The currency that the feeds' returned prices are denominated in.
53
+ /// @param unitCurrency The currency whose unit is priced by the feeds.
54
+ /// @return count The number of configured price feeds for the exact pair.
55
+ function priceFeedCountFor(
56
+ uint256 projectId,
57
+ uint256 pricingCurrency,
58
+ uint256 unitCurrency
59
+ )
60
+ external
61
+ view
62
+ returns (uint256 count);
63
+
64
+ /// @notice Returns the primary feed for an exact currency pair, or zero if none is configured.
65
+ /// @dev This view only returns the stored primary feed address. It does not call the feed, skip unavailable feeds,
66
+ /// derive inverse feeds, or fall back to project ID 0 defaults.
67
+ /// @param projectId The ID of the project whose primary feed should be returned.
68
+ /// @param pricingCurrency The currency that the feed's returned price is denominated in.
69
+ /// @param unitCurrency The currency whose unit is priced by the feed.
70
+ /// @return feed The first configured price feed for the exact pair, or the zero address if none exists.
36
71
  function priceFeedFor(
37
72
  uint256 projectId,
38
73
  uint256 pricingCurrency,
@@ -40,14 +75,16 @@ interface IJBPrices {
40
75
  )
41
76
  external
42
77
  view
43
- returns (IJBPriceFeed);
78
+ returns (IJBPriceFeed feed);
44
79
 
45
- /// @notice Returns the unit price for a currency pair.
46
- /// @param projectId The ID of the project to get the price for.
47
- /// @param pricingCurrency The currency the returned price is in terms of.
48
- /// @param unitCurrency The currency to price.
49
- /// @param decimals The number of decimals the returned price should use.
50
- /// @return The unit price.
80
+ /// @notice Returns the price of one `unitCurrency` unit denominated in `pricingCurrency`.
81
+ /// @dev Lookup order is project direct feeds, project inverse feeds, default direct feeds, then default inverse
82
+ /// feeds. Each feed list is tried in registration order, skipping feeds that revert or return zero.
83
+ /// @param projectId The ID of the project to check first. Project ID 0 feeds are used as defaults.
84
+ /// @param pricingCurrency The currency that the returned price is denominated in.
85
+ /// @param unitCurrency The currency whose unit is being priced.
86
+ /// @param decimals The number of decimals the returned fixed point price should use.
87
+ /// @return price The `pricingCurrency` price of one `unitCurrency`, using `decimals` fixed point precision.
51
88
  function pricePerUnitOf(
52
89
  uint256 projectId,
53
90
  uint256 pricingCurrency,
@@ -56,12 +93,14 @@ interface IJBPrices {
56
93
  )
57
94
  external
58
95
  view
59
- returns (uint256);
96
+ returns (uint256 price);
60
97
 
61
- /// @notice Adds a price feed for a project's currency pair.
62
- /// @param projectId The ID of the project to add the price feed for.
63
- /// @param pricingCurrency The currency the feed's output price is in terms of.
64
- /// @param unitCurrency The currency the feed prices.
98
+ /// @notice Adds an append-only price feed for a project's exact currency pair.
99
+ /// @dev Project ID 0 stores protocol defaults and can only be configured by this contract's owner. Non-zero
100
+ /// project IDs can only be configured by that project's controller.
101
+ /// @param projectId The ID of the project to add the feed for, or 0 to add a protocol default.
102
+ /// @param pricingCurrency The currency that the feed's returned price is denominated in.
103
+ /// @param unitCurrency The currency whose unit is priced by the feed.
65
104
  /// @param feed The price feed to add.
66
105
  function addPriceFeedFor(
67
106
  uint256 projectId,
@@ -13,11 +13,23 @@ interface IJBProjects is IERC721 {
13
13
  /// @param caller The address that created the project.
14
14
  event Create(uint256 indexed projectId, address indexed owner, address caller);
15
15
 
16
+ /// @notice The native-token fee required to create a project was set.
17
+ /// @param fee The required creation fee.
18
+ /// @param receiver The address that receives the fee.
19
+ /// @param caller The address that set the fee.
20
+ event SetCreationFee(uint256 fee, address payable receiver, address caller);
21
+
16
22
  /// @notice The token URI resolver was set.
17
23
  /// @param resolver The new token URI resolver.
18
24
  /// @param caller The address that set the resolver.
19
25
  event SetTokenUriResolver(IJBTokenUriResolver indexed resolver, address caller);
20
26
 
27
+ /// @notice Returns the native-token fee required to create a project.
28
+ function creationFee() external view returns (uint256);
29
+
30
+ /// @notice Returns the address that receives project creation fees.
31
+ function creationFeeReceiver() external view returns (address payable);
32
+
21
33
  /// @notice Returns the total number of projects that have been created.
22
34
  function count() external view returns (uint256);
23
35
 
@@ -27,7 +39,12 @@ interface IJBProjects is IERC721 {
27
39
  /// @notice Creates a new project and mints the project's ERC-721 to the specified owner.
28
40
  /// @param owner The address that will own the new project's ERC-721.
29
41
  /// @return projectId The ID of the newly created project.
30
- function createFor(address owner) external returns (uint256 projectId);
42
+ function createFor(address owner) external payable returns (uint256 projectId);
43
+
44
+ /// @notice Sets the native-token fee required to create a project and the address that receives it.
45
+ /// @param fee The required creation fee. Set to 0 to disable creation fees.
46
+ /// @param receiver The address that receives creation fees. Must be non-zero when `fee` is non-zero.
47
+ function setCreationFee(uint256 fee, address payable receiver) external;
31
48
 
32
49
  /// @notice Sets the token URI resolver used to retrieve project token URIs.
33
50
  /// @param resolver The new token URI resolver.