@croptop/core-v6 0.0.60 → 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/README.md CHANGED
@@ -2,14 +2,19 @@
2
2
 
3
3
  Croptop turns a Juicebox project with a 721 hook into a permissioned publishing marketplace. Project owners define posting rules, then anyone who meets those rules can publish new NFT tiers and mint the first copy of each post.
4
4
 
5
- Docs: <https://docs.juicebox.money>
6
5
  Site: <https://croptop.eth.limo>
7
- Architecture: [ARCHITECTURE.md](./ARCHITECTURE.md)
8
- User journeys: [USER_JOURNEYS.md](./USER_JOURNEYS.md)
9
- Skills: [SKILLS.md](./SKILLS.md)
10
- Risks: [RISKS.md](./RISKS.md)
11
- Administration: [ADMINISTRATION.md](./ADMINISTRATION.md)
12
- Audit instructions: [AUDIT_INSTRUCTIONS.md](./AUDIT_INSTRUCTIONS.md)
6
+
7
+ ## Documentation
8
+
9
+ - [ARCHITECTURE.md](./ARCHITECTURE.md) — system overview, contract roles, data flow
10
+ - [INVARIANTS.md](./INVARIANTS.md) — scoped guarantees that must hold across users, owners, deployers, and integrators
11
+ - [RISKS.md](./RISKS.md) — runtime, admin, deployment, and integration risks
12
+ - [USER_JOURNEYS.md](./USER_JOURNEYS.md) — end-to-end flows for posters, owners, and deployers
13
+ - [ADMINISTRATION.md](./ADMINISTRATION.md) — owner and operator playbook
14
+ - [AUDIT_INSTRUCTIONS.md](./AUDIT_INSTRUCTIONS.md) — what auditors should focus on
15
+ - [SKILLS.md](./SKILLS.md) — domain knowledge for working in this repo
16
+ - [STYLE_GUIDE.md](./STYLE_GUIDE.md) — code-style conventions
17
+ - [CHANGELOG.md](./CHANGELOG.md) — release notes
13
18
 
14
19
  ## Overview
15
20
 
@@ -19,7 +24,7 @@ Croptop is built around three ideas:
19
24
  - publishers call `mintFrom` to create or reuse 721 tiers that represent their post
20
25
  - a one-click deployer can create a full Juicebox project, its 721 hook config, and its posting rules in one transaction
21
26
 
22
- Every mint collects a 5% Croptop fee unless the target project is itself the fee project. If the fee terminal rejects that fee payment, Croptop refunds the fee portion to `_msgSender()` and still lets the publish continue. If `_msgSender()` cannot receive ETH, the mint reverts.
27
+ Every mint collects a 5% Croptop fee unless the target project is itself the fee project. `CTPublisher.mintFrom` takes a terminal `token` and `amount`, like a Juicebox terminal payment: native-token mints use `msg.value`, while ERC-20 mints pull `amount` from `_msgSender()` using either direct approval or a publisher-targeted Permit2 metadata entry. Croptop converts the hook's tier price into the selected terminal token's accounting units, using the hook's `PRICES` oracle when the payment currency differs from the hook pricing currency. The project terminal still decides whether the token is accepted, and the transaction reverts if the project payment does not mint the requested NFTs to the beneficiary. If the fee terminal is missing or rejects the fee payment, Croptop refunds the fee portion to `_msgSender()` and still lets the publish continue. Native refunds can still revert if `_msgSender()` cannot receive ETH.
23
28
 
24
29
  Use this repo when the product is permissioned publishing on top of a Juicebox project. Do not use it for plain 721 tier sales.
25
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@croptop/core-v6",
3
- "version": "0.0.60",
3
+ "version": "0.0.64",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,7 +33,8 @@
33
33
  "@bananapus/router-terminal-v6": "^0.0.55",
34
34
  "@bananapus/suckers-v6": "^0.0.60",
35
35
  "@openzeppelin/contracts": "5.6.1",
36
- "@rev-net/core-v6": "^0.0.75"
36
+ "@rev-net/core-v6": "^0.0.75",
37
+ "@uniswap/permit2": "github:Uniswap/permit2#cc56ad0f3439c502c246fc5cfcc3db92bb8b7219"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@bananapus/address-registry-v6": "^0.0.29",
@@ -6,6 +6,7 @@ import {SuckerDeployment, SuckerDeploymentLib} from "@bananapus/suckers-v6/scrip
6
6
 
7
7
  import {Sphinx} from "@sphinx-labs/contracts/contracts/foundry/SphinxPlugin.sol";
8
8
  import {Script} from "forge-std/Script.sol";
9
+ import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
9
10
 
10
11
  import {CTDeployer} from "./../src/CTDeployer.sol";
11
12
  import {CTProjectOwner} from "./../src/CTProjectOwner.sol";
@@ -27,6 +28,7 @@ contract DeployScript is Script, Sphinx {
27
28
  bytes32 private constant _PUBLISHER_SALT = "_PUBLISHER_SALTV6_";
28
29
  bytes32 private constant _DEPLOYER_SALT = "_DEPLOYER_SALTV6_";
29
30
  bytes32 private constant _PROJECT_OWNER_SALT = "_PROJECT_OWNER_SALTV6_";
31
+ IPermit2 private constant _PERMIT2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
30
32
  address private trustedForwarder;
31
33
 
32
34
  function configureSphinx() public override {
@@ -69,7 +71,7 @@ contract DeployScript is Script, Sphinx {
69
71
  (address _publisher, bool _publisherIsDeployed) = _isDeployed({
70
72
  salt: _PUBLISHER_SALT,
71
73
  creationCode: type(CTPublisher).creationCode,
72
- arguments: abi.encode(core.directory, core.permissions, feeProjectId, trustedForwarder)
74
+ arguments: abi.encode(core.directory, core.permissions, feeProjectId, _PERMIT2, trustedForwarder)
73
75
  });
74
76
 
75
77
  // Deploy it if it has not been deployed yet.
@@ -78,6 +80,7 @@ contract DeployScript is Script, Sphinx {
78
80
  directory: core.directory,
79
81
  permissions: core.permissions,
80
82
  feeProjectId: feeProjectId,
83
+ permit2: _PERMIT2,
81
84
  trustedForwarder: trustedForwarder
82
85
  })
83
86
  : CTPublisher(_publisher);
@@ -9,13 +9,22 @@ import {JB721TierConfigFlags} from "@bananapus/721-hook-v6/src/structs/JB721Tier
9
9
  import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
10
10
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
11
11
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
12
+ import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
12
13
  import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
13
14
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
15
+ import {JBCurrencyIds} from "@bananapus/core-v6/src/libraries/JBCurrencyIds.sol";
14
16
  import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
17
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
18
+ import {JBSingleAllowance} from "@bananapus/core-v6/src/structs/JBSingleAllowance.sol";
15
19
  import {JBOwnable} from "@bananapus/ownable-v6/src/JBOwnable.sol";
16
20
  import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
17
21
  import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
22
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
23
+ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
18
24
  import {Context} from "@openzeppelin/contracts/utils/Context.sol";
25
+ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
26
+ import {IAllowanceTransfer} from "@uniswap/permit2/src/interfaces/IAllowanceTransfer.sol";
27
+ import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
19
28
 
20
29
  import {ICTPublisher} from "./interfaces/ICTPublisher.sol";
21
30
  import {CTAllowedPost} from "./structs/CTAllowedPost.sol";
@@ -27,23 +36,40 @@ import {CTPost} from "./structs/CTPost.sol";
27
36
  /// remainder into the project's terminal. Duplicate IPFS URIs are tracked so subsequent mints of the same content reuse
28
37
  /// the existing tier rather than creating a new one.
29
38
  contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
39
+ // A library that handles optional-return ERC-20 operations.
40
+ using SafeERC20 for IERC20;
41
+
30
42
  //*********************************************************************//
31
43
  // --------------------------- custom errors ------------------------- //
32
44
  //*********************************************************************//
33
45
 
46
+ error CTPublisher_DuplicatePayMetadata(bytes4 payMetadataId);
34
47
  error CTPublisher_DuplicatePost(bytes32 encodedIpfsUri);
35
48
  error CTPublisher_EmptyEncodedIpfsUri(uint256 postIndex);
49
+ error CTPublisher_FeePaymentFailed(uint256 feeAmount);
36
50
  error CTPublisher_InsufficientEthSent(uint256 expected, uint256 sent);
51
+ error CTPublisher_InsufficientPayment(uint256 expected, uint256 sent);
52
+ error CTPublisher_InvalidFeeBeneficiary();
53
+ error CTPublisher_InvalidPaymentTokenContext(
54
+ address token, uint256 tokenCurrency, uint256 tokenDecimals, uint256 pricingCurrency, uint256 pricingDecimals
55
+ );
37
56
  error CTPublisher_MaxTotalSupplyLessThanMin(uint256 min, uint256 max);
57
+ error CTPublisher_MintNotDelivered(
58
+ address hook, address beneficiary, uint256 expectedBalance, uint256 actualBalance
59
+ );
60
+ error CTPublisher_MsgValueNotAllowed(uint256 value);
61
+ error CTPublisher_NativeTokenAmountMismatch(uint256 amount, uint256 msgValue);
62
+ error CTPublisher_NoPosts(address caller);
38
63
  error CTPublisher_NotInAllowList(address addr, address[] allowedAddresses);
64
+ error CTPublisher_OverflowAlert(uint256 value, uint256 limit);
65
+ error CTPublisher_PermitAllowanceNotEnough(uint256 amount, uint256 allowance);
66
+ error CTPublisher_PriceFeedUnavailable(uint256 paymentCurrency, uint256 pricingCurrency);
39
67
  error CTPublisher_PriceTooSmall(uint256 price, uint256 minimumPrice);
40
- error CTPublisher_DuplicatePayMetadata(bytes4 payMetadataId);
41
- error CTPublisher_FeePaymentFailed(uint256 feeAmount);
68
+ error CTPublisher_ReentrantTokenTransfer(address token);
42
69
  error CTPublisher_SplitPercentExceedsMaximum(uint256 splitPercent, uint256 maximumSplitPercent);
70
+ error CTPublisher_TemporaryAllowanceNotConsumed(address token, address spender, uint256 allowance);
43
71
  error CTPublisher_TotalSupplyTooBig(uint256 totalSupply, uint256 maximumTotalSupply);
44
72
  error CTPublisher_TotalSupplyTooSmall(uint256 totalSupply, uint256 minimumTotalSupply);
45
- error CTPublisher_NoPosts(address caller);
46
- error CTPublisher_InvalidFeeBeneficiary();
47
73
  error CTPublisher_UnauthorizedToPostInCategory(address hook, uint24 category);
48
74
  error CTPublisher_ZeroTotalSupply(address hook, uint24 category);
49
75
 
@@ -65,6 +91,9 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
65
91
  /// @notice The ID of the project to which fees will be routed.
66
92
  uint256 public immutable override FEE_PROJECT_ID;
67
93
 
94
+ /// @notice The Permit2 utility used to pull ERC-20 payments from posters.
95
+ IPermit2 public immutable override PERMIT2;
96
+
68
97
  //*********************************************************************//
69
98
  // --------------------- public stored properties -------------------- //
70
99
  //*********************************************************************//
@@ -89,6 +118,13 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
89
118
  /// @custom:param category The category for which the allowance applies.
90
119
  mapping(address hook => mapping(uint256 category => uint256)) internal _packedAllowanceFor;
91
120
 
121
+ //*********************************************************************//
122
+ // ------------------- transient stored properties ------------------- //
123
+ //*********************************************************************//
124
+
125
+ /// @notice Whether this publisher is currently measuring an incoming ERC-20 balance delta.
126
+ bool internal transient _acceptingToken;
127
+
92
128
  //*********************************************************************//
93
129
  // -------------------------- constructor ---------------------------- //
94
130
  //*********************************************************************//
@@ -96,11 +132,13 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
96
132
  /// @param directory The directory that contains the projects to post to.
97
133
  /// @param permissions A contract storing permissions.
98
134
  /// @param feeProjectId The ID of the project to which fees will be routed.
135
+ /// @param permit2 The Permit2 utility used to pull ERC-20 payments from posters.
99
136
  /// @param trustedForwarder The trusted forwarder for the ERC2771Context.
100
137
  constructor(
101
138
  IJBDirectory directory,
102
139
  IJBPermissions permissions,
103
140
  uint256 feeProjectId,
141
+ IPermit2 permit2,
104
142
  address trustedForwarder
105
143
  )
106
144
  JBPermissioned(permissions)
@@ -108,6 +146,7 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
108
146
  {
109
147
  DIRECTORY = directory;
110
148
  FEE_PROJECT_ID = feeProjectId;
149
+ PERMIT2 = permit2;
111
150
  }
112
151
 
113
152
  //*********************************************************************//
@@ -184,12 +223,17 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
184
223
  /// @dev Reverts if any post violates the category's configured allowance (price, supply, split, allowlist).
185
224
  /// @param hook The hook to mint from.
186
225
  /// @param posts An array of posts that should be published as NFTs to the specified project.
226
+ /// @param token The terminal token to pay with.
227
+ /// @param amount The total token amount supplied for the post payment and Croptop fee.
187
228
  /// @param nftBeneficiary The beneficiary of the NFT mints.
188
229
  /// @param feeBeneficiary The beneficiary of the fee project's token.
189
230
  /// @param additionalPayMetadata Metadata bytes to include in the payment after Croptop prepends NFT mint metadata.
231
+ /// Include a Permit2 entry targeted to this publisher to pay ERC-20s without a direct publisher approval.
190
232
  function mintFrom(
191
233
  IJB721TiersHook hook,
192
234
  CTPost[] calldata posts,
235
+ address token,
236
+ uint256 amount,
193
237
  address nftBeneficiary,
194
238
  address feeBeneficiary,
195
239
  bytes calldata additionalPayMetadata
@@ -197,6 +241,207 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
197
241
  external
198
242
  payable
199
243
  override
244
+ {
245
+ uint256 acceptedAmount = _acceptFundsFor({token: token, amount: amount, metadata: additionalPayMetadata});
246
+
247
+ _mintFrom({
248
+ hook: hook,
249
+ posts: posts,
250
+ token: token,
251
+ amount: acceptedAmount,
252
+ nftBeneficiary: nftBeneficiary,
253
+ feeBeneficiary: feeBeneficiary,
254
+ additionalPayMetadata: additionalPayMetadata
255
+ });
256
+ }
257
+
258
+ //*********************************************************************//
259
+ // ------------------------- external views -------------------------- //
260
+ //*********************************************************************//
261
+
262
+ /// @notice Get the tiers for the provided encoded IPFS URIs.
263
+ /// @dev The returned tier IDs may be stale if the corresponding tiers were removed externally via adjustTiers.
264
+ /// In that case, the store's tierOf call will return a tier with default/empty values. Callers should check
265
+ /// tier fields before relying on them.
266
+ /// @param hook The hook from which to get tiers.
267
+ /// @param encodedIpfsUris The URIs to get tiers of.
268
+ /// @return tiers The tiers that correspond to the provided encoded IPFS URIs.
269
+ function tiersFor(
270
+ address hook,
271
+ bytes32[] memory encodedIpfsUris
272
+ )
273
+ external
274
+ view
275
+ override
276
+ returns (JB721Tier[] memory tiers)
277
+ {
278
+ // Set the number of tiers.
279
+ tiers = new JB721Tier[](encodedIpfsUris.length);
280
+
281
+ // Get the hook's store.
282
+ IJB721TiersHookStore store = IJB721TiersHook(hook).STORE();
283
+
284
+ // For each encoded IPFS URI being checked.
285
+ for (uint256 i; i < encodedIpfsUris.length;) {
286
+ // Get the tier ID the URI is stored in.
287
+ uint256 tierId = tierIdForEncodedIpfsUriOf[hook][encodedIpfsUris[i]];
288
+
289
+ // Get the tier.
290
+ tiers[i] = store.tierOf({hook: hook, id: tierId, includeResolvedUri: true});
291
+
292
+ unchecked {
293
+ ++i;
294
+ }
295
+ }
296
+ }
297
+
298
+ //*********************************************************************//
299
+ // -------------------------- public views --------------------------- //
300
+ //*********************************************************************//
301
+
302
+ /// @notice Get the allowance for the provided hook and category.
303
+ /// @param hook The hook to get the allowance for.
304
+ /// @param category The category to get the allowance for.
305
+ /// @return minimumPrice The minimum price.
306
+ /// @return minimumTotalSupply The minimum total supply.
307
+ /// @return maximumTotalSupply The maximum total supply.
308
+ /// @return maximumSplitPercent The maximum split percent.
309
+ /// @return allowedAddresses The addresses that are allowed to post.
310
+ function allowanceFor(
311
+ address hook,
312
+ uint256 category
313
+ )
314
+ public
315
+ view
316
+ override
317
+ returns (
318
+ uint256 minimumPrice,
319
+ uint256 minimumTotalSupply,
320
+ uint256 maximumTotalSupply,
321
+ uint256 maximumSplitPercent,
322
+ address[] memory allowedAddresses
323
+ )
324
+ {
325
+ // Get a reference to the packed allowance for the hook and category.
326
+ uint256 packed = _packedAllowanceFor[hook][category];
327
+
328
+ // Get a reference to the minimum price.
329
+ minimumPrice = packed & ((1 << 104) - 1);
330
+ // Get a reference to the minimum total supply.
331
+ minimumTotalSupply = (packed >> 104) & ((1 << 32) - 1);
332
+ // Get a reference to the maximum total supply.
333
+ maximumTotalSupply = (packed >> 136) & ((1 << 32) - 1);
334
+ // Get a reference to the maximum split percent.
335
+ maximumSplitPercent = (packed >> 168) & ((1 << 32) - 1);
336
+
337
+ allowedAddresses = _allowedAddresses[hook][category];
338
+ }
339
+
340
+ //*********************************************************************//
341
+ // ---------------------- internal transactions ---------------------- //
342
+ //*********************************************************************//
343
+
344
+ /// @notice Accept incoming native or ERC-20 funds from the caller.
345
+ /// @param token The token to accept.
346
+ /// @param amount The number of tokens to accept.
347
+ /// @param metadata The metadata in which optional Permit2 context is provided.
348
+ /// @return acceptedAmount The number of tokens that were received.
349
+ function _acceptFundsFor(
350
+ address token,
351
+ uint256 amount,
352
+ bytes calldata metadata
353
+ )
354
+ internal
355
+ returns (uint256 acceptedAmount)
356
+ {
357
+ // Native-token payments arrive as msg.value, so they never need ERC-20 pulls or Permit2 handling.
358
+ if (token == JBConstants.NATIVE_TOKEN) {
359
+ // Keep the explicit `amount` aligned with msg.value because downstream fee math uses `amount`.
360
+ if (amount != msg.value) {
361
+ revert CTPublisher_NativeTokenAmountMismatch({amount: amount, msgValue: msg.value});
362
+ }
363
+
364
+ // ETH cannot be fee-on-transfer, so the accepted amount is exactly the supplied value.
365
+ return amount;
366
+ }
367
+
368
+ // ERC-20 payments must not also carry ETH, otherwise the native value would be trapped in this contract.
369
+ if (msg.value != 0) revert CTPublisher_MsgValueNotAllowed({value: msg.value});
370
+
371
+ // Permit2 data is optional and lives in the caller's pay metadata so `mintFrom` can keep one public signature.
372
+ (bool hasPermit2Allowance, bytes memory permit2Data) =
373
+ JBMetadataResolver.getDataFor({id: JBMetadataResolver.getId("permit2"), metadata: metadata});
374
+
375
+ if (hasPermit2Allowance) {
376
+ // Decode only when present so ordinary ERC-20 approvals and native payments do not need dummy structs.
377
+ JBSingleAllowance memory permit2Allowance = abi.decode(permit2Data, (JBSingleAllowance));
378
+ // The signed allowance must cover the requested pull; otherwise Permit2 could approve less than this
379
+ // publish path is about to try to collect.
380
+ if (amount > permit2Allowance.amount) {
381
+ revert CTPublisher_PermitAllowanceNotEnough({amount: amount, allowance: permit2Allowance.amount});
382
+ }
383
+
384
+ // Rebuild Permit2's expected struct with this token and this publisher as spender. This prevents metadata
385
+ // for a different token or spender from being replayed through the publisher.
386
+ IAllowanceTransfer.PermitSingle memory permitSingle = IAllowanceTransfer.PermitSingle({
387
+ details: IAllowanceTransfer.PermitDetails({
388
+ token: token,
389
+ amount: permit2Allowance.amount,
390
+ expiration: permit2Allowance.expiration,
391
+ nonce: permit2Allowance.nonce
392
+ }),
393
+ spender: address(this),
394
+ sigDeadline: permit2Allowance.sigDeadline
395
+ });
396
+
397
+ // Submit the Permit2 approval for the ERC-2771-aware caller before the pull. If it has already been used or
398
+ // otherwise fails, continue so an existing ERC-20 approval or Permit2 allowance can still satisfy the pull.
399
+ try PERMIT2.permit({
400
+ owner: _msgSender(), permitSingle: permitSingle, signature: permit2Allowance.signature
401
+ }) {}
402
+ catch (bytes memory reason) {
403
+ // Surface failed permit setup without masking the later transfer failure or success path.
404
+ emit Permit2AllowanceFailed({token: token, owner: _msgSender(), reason: reason});
405
+ }
406
+ }
407
+
408
+ // Measure the actual ERC-20 balance delta so fee-on-transfer or otherwise nonstandard tokens cannot overstate
409
+ // how much the publisher received.
410
+ uint256 balanceBefore = _balanceOf(token);
411
+
412
+ // Prevent callback-capable tokens from nesting another incoming ERC-20 transfer inside this balance-delta
413
+ // measurement.
414
+ if (_acceptingToken) revert CTPublisher_ReentrantTokenTransfer(token);
415
+ // The flag is transient, so it only protects the current transaction's balance-delta measurement.
416
+ _acceptingToken = true;
417
+
418
+ // Pull from the ERC-2771-aware caller using either direct ERC-20 approval or Permit2 fallback.
419
+ _transferFrom({from: _msgSender(), to: payable(address(this)), token: token, amount: amount});
420
+
421
+ // Return what arrived rather than what was requested; downstream pricing and fee math use the real receipt.
422
+ acceptedAmount = _balanceOf(token) - balanceBefore;
423
+ // Clear the transient guard once the balance-delta measurement has finished.
424
+ _acceptingToken = false;
425
+ }
426
+
427
+ /// @notice Shared implementation for native and ERC-20 publish payments.
428
+ /// @param hook The hook to mint from.
429
+ /// @param posts An array of posts that should be published as NFTs to the specified project.
430
+ /// @param token The terminal token to pay with.
431
+ /// @param amount The total token amount supplied for the post payment and Croptop fee.
432
+ /// @param nftBeneficiary The beneficiary of the NFT mints.
433
+ /// @param feeBeneficiary The beneficiary of the fee project's token.
434
+ /// @param additionalPayMetadata Metadata bytes to include in the payment after Croptop prepends NFT mint metadata.
435
+ function _mintFrom(
436
+ IJB721TiersHook hook,
437
+ CTPost[] calldata posts,
438
+ address token,
439
+ uint256 amount,
440
+ address nftBeneficiary,
441
+ address feeBeneficiary,
442
+ bytes calldata additionalPayMetadata
443
+ )
444
+ internal
200
445
  {
201
446
  // Reject empty posts to prevent fee-free metadata shadowing.
202
447
  if (posts.length == 0) revert CTPublisher_NoPosts(_msgSender());
@@ -204,8 +449,8 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
204
449
  // Reject address(0) as fee beneficiary to prevent burning fee project tokens.
205
450
  if (feeBeneficiary == address(0)) revert CTPublisher_InvalidFeeBeneficiary();
206
451
 
207
- // Keep a reference to the amount being paid, which is msg.value minus the fee.
208
- uint256 payValue = msg.value;
452
+ // Keep a reference to the amount being paid, which is `amount` minus the fee.
453
+ uint256 payValue = amount;
209
454
 
210
455
  // Keep a reference to the mint metadata.
211
456
  bytes memory mintMetadata;
@@ -213,11 +458,29 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
213
458
  // Keep a reference to the project's ID.
214
459
  uint256 projectId = hook.projectId();
215
460
 
461
+ (uint256 pricingCurrency, uint256 pricingDecimals) = hook.pricingContext();
462
+
463
+ // Get a reference to the project's current payment terminal.
464
+ IJBTerminal projectTerminal = DIRECTORY.primaryTerminalOf({projectId: projectId, token: token});
465
+
466
+ JBAccountingContext memory paymentContext =
467
+ _paymentContextOf({terminal: projectTerminal, projectId: projectId, token: token});
468
+
469
+ uint256 mintCount = posts.length;
216
470
  {
217
471
  // Setup the posts.
218
472
  (JB721TierConfig[] memory tiersToAdd, uint256[] memory tierIdsToMint, uint256 totalPrice) =
219
473
  _setupPosts({hook: hook, posts: posts});
220
474
 
475
+ totalPrice = _paymentAmountFromPricing({
476
+ hook: hook,
477
+ projectId: projectId,
478
+ paymentContext: paymentContext,
479
+ pricingCurrency: pricingCurrency,
480
+ pricingDecimals: pricingDecimals,
481
+ pricingAmount: totalPrice
482
+ });
483
+
221
484
  if (projectId != FEE_PROJECT_ID) {
222
485
  // Keep a reference to the fee that will be paid.
223
486
  // Note: integer division truncates, so the fee loses up to (FEE_DIVISOR - 1) wei of dust.
@@ -225,9 +488,9 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
225
488
  // This rounding is in the payer's favor and the loss is negligible for practical amounts.
226
489
  uint256 fee = totalPrice / FEE_DIVISOR;
227
490
 
228
- // Make sure enough ETH was sent to cover the fee.
491
+ // Make sure enough was sent to cover the fee.
229
492
  if (payValue < fee) {
230
- revert CTPublisher_InsufficientEthSent({expected: totalPrice + fee, sent: msg.value});
493
+ _revertInsufficientPayment({token: token, expected: totalPrice + fee, sent: amount});
231
494
  }
232
495
 
233
496
  payValue -= fee;
@@ -235,7 +498,7 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
235
498
 
236
499
  // Make sure the amount sent to this function is at least the specified price of the tier plus the fee.
237
500
  if (totalPrice > payValue) {
238
- revert CTPublisher_InsufficientEthSent({expected: totalPrice, sent: msg.value});
501
+ _revertInsufficientPayment({token: token, expected: totalPrice, sent: amount});
239
502
  }
240
503
 
241
504
  // Add the new tiers.
@@ -270,152 +533,291 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
270
533
  emit Mint({
271
534
  projectId: projectId,
272
535
  hook: hook,
536
+ token: token,
273
537
  nftBeneficiary: nftBeneficiary,
274
538
  feeBeneficiary: feeBeneficiary,
275
539
  posts: posts,
276
540
  postValue: payValue,
277
- txValue: msg.value,
541
+ amount: amount,
278
542
  caller: _msgSender()
279
543
  });
280
544
 
281
545
  {
282
- // Get a reference to the project's current ETH payment terminal.
283
- IJBTerminal projectTerminal =
284
- DIRECTORY.primaryTerminalOf({projectId: projectId, token: JBConstants.NATIVE_TOKEN});
546
+ uint256 balanceBefore = hook.STORE().balanceOf({hook: address(hook), owner: nftBeneficiary});
285
547
 
286
548
  // Make the payment.
287
- projectTerminal.pay{value: payValue}({
549
+ _prepareAllowanceFor({token: token, spender: address(projectTerminal), amount: payValue});
550
+ projectTerminal.pay{value: token == JBConstants.NATIVE_TOKEN ? payValue : 0}({
288
551
  projectId: projectId,
289
- token: JBConstants.NATIVE_TOKEN,
552
+ token: token,
290
553
  amount: payValue,
291
554
  beneficiary: nftBeneficiary,
292
555
  minReturnedTokens: 0,
293
556
  memo: "Minted from Croptop",
294
557
  metadata: mintMetadata
295
558
  });
559
+ _requireTemporaryAllowanceConsumed({token: token, spender: address(projectTerminal)});
560
+
561
+ uint256 expectedBalance = balanceBefore + mintCount;
562
+ uint256 balanceAfter = hook.STORE().balanceOf({hook: address(hook), owner: nftBeneficiary});
563
+ if (balanceAfter < expectedBalance) {
564
+ revert CTPublisher_MintNotDelivered({
565
+ hook: address(hook),
566
+ beneficiary: nftBeneficiary,
567
+ expectedBalance: expectedBalance,
568
+ actualBalance: balanceAfter
569
+ });
570
+ }
296
571
  }
297
572
 
298
573
  // Reuse payValue to hold the pre-computed fee amount, avoiding reliance on address(this).balance
299
- // after the external call above (which could be manipulated by reentrancy or force-sent ETH).
300
- payValue = msg.value - payValue;
574
+ // after the external call above (which could be manipulated by reentrancy or force-sent tokens).
575
+ payValue = amount - payValue;
301
576
 
302
577
  // Pay the fee if there is one.
303
578
  if (payValue != 0) {
304
- // Get a reference to the fee project's current ETH payment terminal.
305
- IJBTerminal feeTerminal =
306
- DIRECTORY.primaryTerminalOf({projectId: FEE_PROJECT_ID, token: JBConstants.NATIVE_TOKEN});
579
+ // Get a reference to the fee project's current payment terminal.
580
+ IJBTerminal feeTerminal = DIRECTORY.primaryTerminalOf({projectId: FEE_PROJECT_ID, token: token});
581
+ if (address(feeTerminal) == address(0)) {
582
+ _refundFee({token: token, amount: payValue});
583
+ return;
584
+ }
307
585
 
308
586
  // Make the fee payment. If the fee sink is unavailable, refund the fee to the caller
309
587
  // rather than trapping or silently redirecting protocol funds.
310
- try feeTerminal.pay{value: payValue}({
588
+ _prepareAllowanceFor({token: token, spender: address(feeTerminal), amount: payValue});
589
+ try feeTerminal.pay{value: token == JBConstants.NATIVE_TOKEN ? payValue : 0}({
311
590
  projectId: FEE_PROJECT_ID,
312
591
  amount: payValue,
313
- token: JBConstants.NATIVE_TOKEN,
592
+ token: token,
314
593
  beneficiary: feeBeneficiary,
315
594
  minReturnedTokens: 0,
316
595
  memo: "",
317
596
  metadata: ""
318
- }) {}
319
- catch {
320
- (bool success,) = _msgSender().call{value: payValue}("");
321
- if (!success) revert CTPublisher_FeePaymentFailed(payValue);
597
+ }) {
598
+ _requireTemporaryAllowanceConsumed({token: token, spender: address(feeTerminal)});
599
+ } catch {
600
+ _clearAllowanceFor({token: token, spender: address(feeTerminal)});
601
+ _refundFee({token: token, amount: payValue});
322
602
  }
323
603
  }
324
604
  }
325
605
 
326
606
  //*********************************************************************//
327
- // ------------------------- external views -------------------------- //
607
+ // ------------------------ internal helpers ------------------------- //
328
608
  //*********************************************************************//
329
609
 
330
- /// @notice Get the tiers for the provided encoded IPFS URIs.
331
- /// @dev The returned tier IDs may be stale if the corresponding tiers were removed externally via adjustTiers.
332
- /// In that case, the store's tierOf call will return a tier with default/empty values. Callers should check
333
- /// the returned tier's initialSupply or other fields to confirm the tier still exists.
334
- /// @param hook The hook from which to get tiers.
335
- /// @param encodedIpfsUris The URIs to get tiers of.
336
- /// @return tiers The tiers that correspond to the provided encoded IPFS URIs. If there's no tier yet, an empty tier
337
- /// is returned.
338
- function tiersFor(
339
- address hook,
340
- bytes32[] memory encodedIpfsUris
341
- )
342
- external
343
- view
344
- override
345
- returns (JB721Tier[] memory tiers)
346
- {
347
- uint256 numberOfEncodedIpfsUris = encodedIpfsUris.length;
610
+ /// @notice Return this contract's balance of a token.
611
+ /// @param token The token whose balance should be read.
612
+ /// @return balance The current balance.
613
+ function _balanceOf(address token) internal view returns (uint256 balance) {
614
+ if (token == JBConstants.NATIVE_TOKEN) return address(this).balance;
348
615
 
349
- // Initialize the tier array being returned.
350
- tiers = new JB721Tier[](numberOfEncodedIpfsUris);
616
+ return IERC20(token).balanceOf(address(this));
617
+ }
351
618
 
352
- // Get the tier for each provided encoded IPFS URI.
353
- for (uint256 i; i < numberOfEncodedIpfsUris;) {
354
- // Check if there's a tier ID stored for the encoded IPFS URI.
355
- uint256 tierId = tierIdForEncodedIpfsUriOf[hook][encodedIpfsUris[i]];
619
+ /// @notice Clear a temporary ERC-20 allowance granted by this publisher.
620
+ /// @param token The token whose allowance should be cleared.
621
+ /// @param spender The contract whose allowance should be cleared.
622
+ function _clearAllowanceFor(address token, address spender) internal {
623
+ if (token == JBConstants.NATIVE_TOKEN) return;
356
624
 
357
- // If there's a tier ID stored, resolve it.
358
- if (tierId != 0) {
359
- tiers[i] = IJB721TiersHook(hook).STORE().tierOf({hook: hook, id: tierId, includeResolvedUri: false});
360
- }
625
+ IERC20(token).forceApprove({spender: spender, value: 0});
626
+ }
361
627
 
628
+ /// @notice Check if an address is included in an allow list.
629
+ /// @dev Uses an O(n) linear scan over the `addresses` array. This is acceptable for typical allow list sizes
630
+ /// (fewer than ~100 addresses), where gas cost is negligible. For very large allow lists, a Merkle proof
631
+ /// pattern would scale better, but the added complexity is not warranted for the expected use case.
632
+ /// @param addrs The candidate address.
633
+ /// @param addresses An array of allowed addresses.
634
+ function _isAllowed(address addrs, address[] memory addresses) internal pure returns (bool) {
635
+ // Keep a reference to the number of addresses to check against.
636
+ uint256 numberOfAddresses = addresses.length;
637
+
638
+ // Check whether the address is included.
639
+ for (uint256 i; i < numberOfAddresses;) {
640
+ if (addrs == addresses[i]) return true;
362
641
  unchecked {
363
642
  ++i;
364
643
  }
365
644
  }
645
+
646
+ return false;
366
647
  }
367
648
 
368
- //*********************************************************************//
369
- // -------------------------- public views --------------------------- //
370
- //*********************************************************************//
649
+ /// @notice Check whether two currency IDs both represent native ETH accounting.
650
+ /// @param currency The first currency ID.
651
+ /// @param otherCurrency The second currency ID.
652
+ function _isNativeEthCurrencyPair(uint256 currency, uint256 otherCurrency) internal pure returns (bool) {
653
+ // forge-lint: disable-next-line(unsafe-typecast)
654
+ uint256 nativeTokenCurrency = uint32(uint160(JBConstants.NATIVE_TOKEN));
371
655
 
372
- /// @notice Post allowances for a particular category on a particular hook.
373
- /// @param hook The hook contract for which this allowance applies.
374
- /// @param category The category for which this allowance applies.
375
- /// @return minimumPrice The minimum price that a poster must pay to record a new NFT.
376
- /// @return minimumTotalSupply The minimum total number of available tokens that a minter must set to record a new
377
- /// NFT.
378
- /// @return maximumTotalSupply The max total supply of NFTs that can be made available when minting. 0 means
379
- /// unlimited.
380
- /// @return maximumSplitPercent The maximum split percent that a poster can set. 0 means splits are not allowed.
381
- /// @return allowedAddresses The addresses allowed to post. Returns empty if all addresses are allowed.
382
- function allowanceFor(
383
- address hook,
384
- uint256 category
656
+ return (currency == JBCurrencyIds.ETH || currency == nativeTokenCurrency)
657
+ && (otherCurrency == JBCurrencyIds.ETH || otherCurrency == nativeTokenCurrency);
658
+ }
659
+
660
+ /// @notice Convert a hook pricing amount into the selected terminal token's accounting units.
661
+ /// @param hook The hook whose pricing oracle should be used when currencies differ.
662
+ /// @param projectId The ID of the project being paid.
663
+ /// @param paymentContext The terminal accounting context for the selected payment token.
664
+ /// @param pricingCurrency The hook's pricing currency.
665
+ /// @param pricingDecimals The hook's pricing decimals.
666
+ /// @param pricingAmount The tier-price amount to convert.
667
+ /// @return paymentAmount The required amount denominated in `paymentContext` units.
668
+ function _paymentAmountFromPricing(
669
+ IJB721TiersHook hook,
670
+ uint256 projectId,
671
+ JBAccountingContext memory paymentContext,
672
+ uint256 pricingCurrency,
673
+ uint256 pricingDecimals,
674
+ uint256 pricingAmount
385
675
  )
386
- public
676
+ internal
387
677
  view
388
- override
389
- returns (
390
- uint256 minimumPrice,
391
- uint256 minimumTotalSupply,
392
- uint256 maximumTotalSupply,
393
- uint256 maximumSplitPercent,
394
- address[] memory allowedAddresses
395
- )
678
+ returns (uint256 paymentAmount)
396
679
  {
397
- // Get a reference to the packed values.
398
- uint256 packed = _packedAllowanceFor[hook][category];
680
+ if (pricingAmount == 0) return 0;
681
+
682
+ if (
683
+ paymentContext.currency == pricingCurrency
684
+ || _isNativeEthCurrencyPair({currency: paymentContext.currency, otherCurrency: pricingCurrency})
685
+ ) {
686
+ return _scaleAmount({
687
+ amount: pricingAmount, fromDecimals: pricingDecimals, toDecimals: paymentContext.decimals
688
+ });
689
+ }
399
690
 
400
- // minimum price in bits 0-103 (104 bits).
401
- // forge-lint: disable-next-line(unsafe-typecast)
402
- minimumPrice = uint256(uint104(packed));
403
- // minimum supply in bits 104-135 (32 bits).
404
- // forge-lint: disable-next-line(unsafe-typecast)
405
- minimumTotalSupply = uint256(uint32(packed >> 104));
406
- // maximum supply in bits 136-167 (32 bits).
407
- // forge-lint: disable-next-line(unsafe-typecast)
408
- maximumTotalSupply = uint256(uint32(packed >> 136));
409
- // maximum split percent in bits 168-199 (32 bits).
410
- // forge-lint: disable-next-line(unsafe-typecast)
411
- maximumSplitPercent = uint256(uint32(packed >> 168));
691
+ IJBPrices prices = hook.PRICES();
692
+ if (address(prices) == address(0)) {
693
+ revert CTPublisher_PriceFeedUnavailable({
694
+ paymentCurrency: paymentContext.currency, pricingCurrency: pricingCurrency
695
+ });
696
+ }
412
697
 
413
- allowedAddresses = _allowedAddresses[hook][category];
698
+ uint256 ratio = prices.pricePerUnitOf({
699
+ projectId: projectId,
700
+ pricingCurrency: paymentContext.currency,
701
+ unitCurrency: pricingCurrency,
702
+ decimals: paymentContext.decimals
703
+ });
704
+ if (ratio == 0) {
705
+ revert CTPublisher_PriceFeedUnavailable({
706
+ paymentCurrency: paymentContext.currency, pricingCurrency: pricingCurrency
707
+ });
708
+ }
709
+
710
+ return
711
+ Math.mulDiv({x: pricingAmount, y: ratio, denominator: 10 ** pricingDecimals, rounding: Math.Rounding.Ceil});
414
712
  }
415
713
 
416
- //*********************************************************************//
417
- // ------------------------ internal helpers ------------------------- //
418
- //*********************************************************************//
714
+ /// @notice Return the terminal accounting context for the selected payment token.
715
+ /// @param terminal The terminal being paid.
716
+ /// @param projectId The ID of the project being paid.
717
+ /// @param token The token being paid.
718
+ /// @return context The terminal's accounting context for `token`.
719
+ function _paymentContextOf(
720
+ IJBTerminal terminal,
721
+ uint256 projectId,
722
+ address token
723
+ )
724
+ internal
725
+ view
726
+ returns (JBAccountingContext memory context)
727
+ {
728
+ context = terminal.accountingContextForTokenOf({projectId: projectId, token: token});
729
+
730
+ if (context.token != token || context.currency == 0) {
731
+ revert CTPublisher_InvalidPaymentTokenContext({
732
+ token: token,
733
+ tokenCurrency: context.currency,
734
+ tokenDecimals: context.decimals,
735
+ pricingCurrency: 0,
736
+ pricingDecimals: 0
737
+ });
738
+ }
739
+ }
740
+
741
+ /// @notice Grant a temporary ERC-20 allowance for a downstream terminal payment.
742
+ /// @param token The token being paid.
743
+ /// @param spender The terminal expected to consume the allowance.
744
+ /// @param amount The exact allowance to grant.
745
+ function _prepareAllowanceFor(address token, address spender, uint256 amount) internal {
746
+ if (token == JBConstants.NATIVE_TOKEN) return;
747
+
748
+ IERC20(token).forceApprove({spender: spender, value: amount});
749
+ }
750
+
751
+ /// @notice Refund a fee payment when the fee terminal is unavailable.
752
+ /// @param token The token to refund.
753
+ /// @param amount The amount to refund.
754
+ function _refundFee(address token, uint256 amount) internal {
755
+ if (token == JBConstants.NATIVE_TOKEN) {
756
+ // Native fees are held as ETH, so refund the ERC-2771-aware caller with a raw value transfer.
757
+ (bool success,) = _msgSender().call{value: amount}("");
758
+ // If the caller cannot receive ETH, revert instead of leaving the fee stranded in this contract.
759
+ if (!success) revert CTPublisher_FeePaymentFailed(amount);
760
+
761
+ // The ERC-20 refund path below is only for tokenized terminal tokens.
762
+ return;
763
+ }
764
+
765
+ // ERC-20 fees are already held by this contract, so return the token balance directly to the caller.
766
+ IERC20(token).safeTransfer({to: _msgSender(), value: amount});
767
+ }
768
+
769
+ /// @notice Revert if a downstream terminal did not consume its exact-use ERC-20 allowance.
770
+ /// @param token The token whose allowance was temporarily granted.
771
+ /// @param spender The terminal expected to consume the allowance.
772
+ function _requireTemporaryAllowanceConsumed(address token, address spender) internal view {
773
+ // Native payments never grant ERC-20 allowance, so there is nothing to verify.
774
+ if (token == JBConstants.NATIVE_TOKEN) return;
775
+
776
+ // The publisher grants exact-use allowances before external terminal calls; any remainder would leave token
777
+ // spend authority live after the payment is complete.
778
+ uint256 allowance = IERC20(token).allowance({owner: address(this), spender: spender});
779
+ if (allowance != 0) {
780
+ revert CTPublisher_TemporaryAllowanceNotConsumed({token: token, spender: spender, allowance: allowance});
781
+ }
782
+ }
783
+
784
+ /// @notice Revert with the native-compatible error for ETH payments, or the token-generic error otherwise.
785
+ /// @param token The token being paid.
786
+ /// @param expected The minimum required amount.
787
+ /// @param sent The supplied amount.
788
+ function _revertInsufficientPayment(address token, uint256 expected, uint256 sent) internal pure {
789
+ if (token == JBConstants.NATIVE_TOKEN) {
790
+ revert CTPublisher_InsufficientEthSent({expected: expected, sent: sent});
791
+ }
792
+
793
+ revert CTPublisher_InsufficientPayment({expected: expected, sent: sent});
794
+ }
795
+
796
+ /// @notice Scale an amount between decimal domains, rounding up when precision is reduced.
797
+ /// @param amount The amount to scale.
798
+ /// @param fromDecimals The decimals currently used by `amount`.
799
+ /// @param toDecimals The decimals to scale into.
800
+ /// @return scaledAmount The scaled amount.
801
+ function _scaleAmount(
802
+ uint256 amount,
803
+ uint256 fromDecimals,
804
+ uint256 toDecimals
805
+ )
806
+ internal
807
+ pure
808
+ returns (uint256 scaledAmount)
809
+ {
810
+ // Equal decimal domains need no conversion, preserving the exact caller-supplied amount.
811
+ if (fromDecimals == toDecimals) return amount;
812
+
813
+ // Scaling into more decimals is exact: each source unit maps to a whole number of destination units.
814
+ if (fromDecimals < toDecimals) return amount * (10 ** (toDecimals - fromDecimals));
815
+
816
+ // Scaling into fewer decimals can leave a fractional destination unit; round up so the payer cannot underpay
817
+ // a tier by truncating precision.
818
+ uint256 denominator = 10 ** (fromDecimals - toDecimals);
819
+ return Math.mulDiv({x: amount, y: 1, denominator: denominator, rounding: Math.Rounding.Ceil});
820
+ }
419
821
 
420
822
  /// @notice Setup the posts.
421
823
  /// @dev `adjustTiers` expects newly added tiers to be sorted by ascending category, while the pay metadata must
@@ -641,25 +1043,23 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
641
1043
  }
642
1044
  }
643
1045
 
644
- /// @notice Check if an address is included in an allow list.
645
- /// @dev Uses an O(n) linear scan over the `addresses` array. This is acceptable for typical allow list sizes
646
- /// (fewer than ~100 addresses), where gas cost is negligible. For very large allow lists, a Merkle proof
647
- /// pattern would scale better, but the added complexity is not warranted for the expected use case.
648
- /// @param addrs The candidate address.
649
- /// @param addresses An array of allowed addresses.
650
- function _isAllowed(address addrs, address[] memory addresses) internal pure returns (bool) {
651
- // Keep a reference to the number of addresses to check against.
652
- uint256 numberOfAddresses = addresses.length;
653
-
654
- // Check whether the address is included.
655
- for (uint256 i; i < numberOfAddresses;) {
656
- if (addrs == addresses[i]) return true;
657
- unchecked {
658
- ++i;
659
- }
1046
+ /// @notice Pull tokens from `from`, preferring ERC-20 approvals and falling back to Permit2.
1047
+ /// @param from The address the transfer should originate from.
1048
+ /// @param to The address receiving tokens.
1049
+ /// @param token The token to transfer.
1050
+ /// @param amount The number of tokens to transfer.
1051
+ function _transferFrom(address from, address payable to, address token, uint256 amount) internal {
1052
+ // Prefer ordinary ERC-20 approval when present so existing approval flows do not need Permit2 metadata.
1053
+ if (IERC20(token).allowance({owner: from, spender: address(this)}) >= amount) {
1054
+ return IERC20(token).safeTransferFrom({from: from, to: to, value: amount});
660
1055
  }
661
1056
 
662
- return false;
1057
+ // Permit2 stores transfer amounts as uint160, so reject values that would truncate before calling it.
1058
+ if (amount > type(uint160).max) revert CTPublisher_OverflowAlert({value: amount, limit: type(uint160).max});
1059
+
1060
+ // Direct approval was insufficient; fall back to Permit2, which enforces the submitted allowance/signature.
1061
+ // forge-lint: disable-next-line(unsafe-typecast)
1062
+ PERMIT2.transferFrom({from: from, to: to, amount: uint160(amount), token: token});
663
1063
  }
664
1064
 
665
1065
  //*********************************************************************//
@@ -4,6 +4,7 @@ pragma solidity ^0.8.0;
4
4
  import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
5
5
  import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
6
6
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
+ import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
7
8
  import {CTAllowedPost} from "../structs/CTAllowedPost.sol";
8
9
  import {CTPost} from "../structs/CTPost.sol";
9
10
 
@@ -18,23 +19,31 @@ interface ICTPublisher {
18
19
  /// @notice Emitted when NFT posts are minted.
19
20
  /// @param projectId The ID of the project the posts belong to.
20
21
  /// @param hook The tiered ERC-721 hook the posts were minted from.
22
+ /// @param token The terminal token used to pay.
21
23
  /// @param nftBeneficiary The address that received the minted NFTs.
22
24
  /// @param feeBeneficiary The address that received fee project tokens.
23
25
  /// @param posts The posts that were minted.
24
- /// @param postValue The total value of the posts.
25
- /// @param txValue The total value sent with the transaction.
26
+ /// @param postValue The amount paid to the post's project after the Croptop fee.
27
+ /// @param amount The total token amount supplied for the post payment and Croptop fee.
26
28
  /// @param caller The address that minted the posts.
27
29
  event Mint(
28
30
  uint256 indexed projectId,
29
31
  IJB721TiersHook indexed hook,
32
+ address token,
30
33
  address indexed nftBeneficiary,
31
34
  address feeBeneficiary,
32
35
  CTPost[] posts,
33
36
  uint256 postValue,
34
- uint256 txValue,
37
+ uint256 amount,
35
38
  address caller
36
39
  );
37
40
 
41
+ /// @notice Emitted when Permit2 approval by signature fails before the token pull is attempted.
42
+ /// @param token The ERC-20 token being paid.
43
+ /// @param owner The payment owner whose signature was submitted.
44
+ /// @param reason The low-level revert reason returned by Permit2.
45
+ event Permit2AllowanceFailed(address indexed token, address indexed owner, bytes reason);
46
+
38
47
  /// @notice The post allowance for a particular category on a particular hook.
39
48
  /// @param hook The hook contract for which this allowance applies.
40
49
  /// @param category The category for which this allowance applies.
@@ -69,6 +78,10 @@ interface ICTPublisher {
69
78
  /// @return The fee project ID.
70
79
  function FEE_PROJECT_ID() external view returns (uint256);
71
80
 
81
+ /// @notice The Permit2 utility used to pull ERC-20 payments from posters.
82
+ /// @return The Permit2 contract.
83
+ function PERMIT2() external view returns (IPermit2);
84
+
72
85
  /// @notice The tier ID that an IPFS metadata URI has been saved to for a given hook.
73
86
  /// @param hook The hook for which the tier ID applies.
74
87
  /// @param encodedIpfsUri The encoded IPFS URI to look up.
@@ -90,12 +103,17 @@ interface ICTPublisher {
90
103
  /// @notice Publish NFT posts and mint a first copy of each. A fee is taken for the fee project.
91
104
  /// @param hook The hook to mint from.
92
105
  /// @param posts An array of posts to publish as NFTs.
106
+ /// @param token The terminal token to pay with.
107
+ /// @param amount The total token amount supplied for the post payment and Croptop fee.
93
108
  /// @param nftBeneficiary The beneficiary of the NFT mints.
94
109
  /// @param feeBeneficiary The beneficiary of the fee project's tokens.
95
- /// @param additionalPayMetadata Extra metadata bytes to include in the payment.
110
+ /// @param additionalPayMetadata Extra metadata bytes to include in the payment. Include a Permit2 entry targeted
111
+ /// to this publisher to pay ERC-20s without a direct publisher approval.
96
112
  function mintFrom(
97
113
  IJB721TiersHook hook,
98
114
  CTPost[] calldata posts,
115
+ address token,
116
+ uint256 amount,
99
117
  address nftBeneficiary,
100
118
  address feeBeneficiary,
101
119
  bytes calldata additionalPayMetadata