@towns-protocol/contracts 0.0.441 → 0.0.443

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/docs/membership_architecture.md +237 -0
  2. package/package.json +3 -3
  3. package/scripts/deployments/diamonds/DeploySpace.s.sol +0 -7
  4. package/scripts/deployments/diamonds/DeploySpaceFactory.s.sol +2 -2
  5. package/scripts/deployments/facets/DeployMembership.s.sol +2 -1
  6. package/scripts/deployments/utils/DeployMockERC20.s.sol +1 -1
  7. package/scripts/deployments/utils/DeployMockUSDC.s.sol +19 -0
  8. package/scripts/interactions/InteractBaseAlpha.s.sol +3 -0
  9. package/scripts/interactions/InteractPostDeploy.s.sol +11 -0
  10. package/src/apps/facets/registry/AppRegistryBase.sol +4 -2
  11. package/src/apps/facets/registry/IAppRegistry.sol +1 -1
  12. package/src/factory/facets/architect/IArchitect.sol +1 -0
  13. package/src/factory/facets/create/CreateSpaceBase.sol +2 -9
  14. package/src/factory/facets/feature/FeatureManagerFacet.sol +32 -29
  15. package/src/factory/facets/feature/FeatureManagerMod.sol +248 -0
  16. package/src/factory/facets/feature/{IFeatureManagerFacet.sol → IFeatureManager.sol} +2 -35
  17. package/src/factory/facets/fee/FeeManagerFacet.sol +1 -1
  18. package/src/factory/facets/fee/FeeTypesLib.sol +8 -1
  19. package/src/spaces/facets/dispatcher/DispatcherBase.sol +13 -5
  20. package/src/spaces/facets/gated/EntitlementGated.sol +9 -5
  21. package/src/spaces/facets/membership/IMembership.sol +11 -17
  22. package/src/spaces/facets/membership/MembershipBase.sol +30 -59
  23. package/src/spaces/facets/membership/MembershipFacet.sol +19 -1
  24. package/src/spaces/facets/membership/MembershipStorage.sol +1 -0
  25. package/src/spaces/facets/membership/join/MembershipJoin.sol +192 -125
  26. package/src/spaces/facets/treasury/ITreasury.sol +2 -1
  27. package/src/spaces/facets/treasury/Treasury.sol +21 -24
  28. package/src/spaces/facets/xchain/SpaceEntitlementGated.sol +3 -4
  29. package/scripts/deployments/facets/DeployPrepayFacet.s.sol +0 -31
  30. package/scripts/interactions/InteractPrepay.s.sol +0 -30
  31. package/src/factory/facets/feature/FeatureManagerBase.sol +0 -152
  32. package/src/factory/facets/feature/FeatureManagerStorage.sol +0 -47
  33. package/src/spaces/facets/prepay/IPrepay.sol +0 -44
  34. package/src/spaces/facets/prepay/PrepayBase.sol +0 -27
  35. package/src/spaces/facets/prepay/PrepayFacet.sol +0 -65
  36. package/src/spaces/facets/prepay/PrepayStorage.sol +0 -26
@@ -6,6 +6,8 @@ import {IMembership} from "./IMembership.sol";
6
6
  import {IMembershipPricing} from "./pricing/IMembershipPricing.sol";
7
7
 
8
8
  // libraries
9
+ import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";
10
+ import {CurrencyTransfer} from "../../../utils/libraries/CurrencyTransfer.sol";
9
11
  import {CustomRevert} from "../../../utils/libraries/CustomRevert.sol";
10
12
 
11
13
  // contracts
@@ -15,6 +17,7 @@ import {MembershipJoin} from "./join/MembershipJoin.sol";
15
17
 
16
18
  contract MembershipFacet is IMembership, MembershipJoin, ReentrancyGuard, Facet {
17
19
  using CustomRevert for bytes4;
20
+ using SafeTransferLib for address;
18
21
 
19
22
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
20
23
  /* JOIN */
@@ -141,6 +144,7 @@ contract MembershipFacet is IMembership, MembershipJoin, ReentrancyGuard, Facet
141
144
 
142
145
  _verifyFreeAllocation(newAllocation);
143
146
  _setMembershipFreeAllocation(newAllocation);
147
+ emit MembershipFreeAllocationUpdated(newAllocation);
144
148
  }
145
149
 
146
150
  /// @inheritdoc IMembership
@@ -178,14 +182,24 @@ contract MembershipFacet is IMembership, MembershipJoin, ReentrancyGuard, Facet
178
182
  }
179
183
 
180
184
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
181
- /* GETTERS */
185
+ /* CURRENCY */
182
186
  /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
183
187
 
188
+ /// @inheritdoc IMembership
189
+ function setMembershipCurrency(address currency) external onlyOwner {
190
+ _setMembershipCurrency(currency);
191
+ emit MembershipCurrencyUpdated(currency);
192
+ }
193
+
184
194
  /// @inheritdoc IMembership
185
195
  function getMembershipCurrency() external view returns (address) {
186
196
  return _getMembershipCurrency();
187
197
  }
188
198
 
199
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
200
+ /* GETTERS */
201
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
202
+
189
203
  /// @inheritdoc IMembership
190
204
  function getSpaceFactory() external view returns (address) {
191
205
  return _getSpaceFactory();
@@ -193,6 +207,10 @@ contract MembershipFacet is IMembership, MembershipJoin, ReentrancyGuard, Facet
193
207
 
194
208
  /// @inheritdoc IMembership
195
209
  function revenue() external view returns (uint256) {
210
+ address currency = _getMembershipCurrency();
211
+ if (currency != CurrencyTransfer.NATIVE_TOKEN) {
212
+ return currency.balanceOf(address(this));
213
+ }
196
214
  return address(this).balance;
197
215
  }
198
216
  }
@@ -25,6 +25,7 @@ library MembershipStorage {
25
25
  uint256 freeAllocation;
26
26
  address pricingModule;
27
27
  mapping(uint256 => uint256) renewalPriceByTokenId;
28
+ // deprecated
28
29
  uint256 tokenBalance;
29
30
  mapping(bytes32 => address) pendingJoinRequests;
30
31
  string membershipImage;
@@ -10,9 +10,11 @@ import {IRolesBase} from "../../roles/IRoles.sol";
10
10
  import {IMembership} from "../IMembership.sol";
11
11
 
12
12
  // libraries
13
+ import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";
13
14
  import {BasisPoints} from "../../../../utils/libraries/BasisPoints.sol";
14
15
  import {CurrencyTransfer} from "../../../../utils/libraries/CurrencyTransfer.sol";
15
16
  import {CustomRevert} from "../../../../utils/libraries/CustomRevert.sol";
17
+ import {MembershipStorage} from "../MembershipStorage.sol";
16
18
  import {Permissions} from "../../Permissions.sol";
17
19
 
18
20
  // contracts
@@ -22,7 +24,6 @@ import {DispatcherBase} from "../../dispatcher/DispatcherBase.sol";
22
24
  import {Entitled} from "../../Entitled.sol";
23
25
  import {EntitlementGatedBase} from "../../gated/EntitlementGatedBase.sol";
24
26
  import {PointsBase} from "../../points/PointsBase.sol";
25
- import {PrepayBase} from "../../prepay/PrepayBase.sol";
26
27
  import {ReferralsBase} from "../../referrals/ReferralsBase.sol";
27
28
  import {RolesBase} from "../../roles/RolesBase.sol";
28
29
  import {MembershipBase} from "../MembershipBase.sol";
@@ -30,7 +31,18 @@ import {MembershipBase} from "../MembershipBase.sol";
30
31
  /// @title MembershipJoin
31
32
  /// @notice Handles the logic for joining a space, including entitlement checks and payment
32
33
  /// processing
33
- /// @dev Inherits from multiple base contracts to provide comprehensive membership functionality
34
+ /// @dev Join Flow:
35
+ /// 1. Payment captured upfront via `_validateAndCapturePayment` (ETH held, ERC20 transferred in)
36
+ /// 2. Entitlement check determines sync vs async path:
37
+ /// - Sync (local entitlements): immediate token issuance or rejection
38
+ /// - Async (crosschain entitlements): payment held in dispatcher, resolved via callback
39
+ /// 3. On success: fees distributed from contract via `_pay*` functions, token issued
40
+ /// 4. On failure: full refund via `_refundBalance`
41
+ ///
42
+ /// Payment Handling:
43
+ /// - ETH: received via payable, excess refunded after fees
44
+ /// - ERC20: exact amount transferred in, no refund needed
45
+ /// - All fee payments use `address(this)` as source since funds are already in contract
34
46
  abstract contract MembershipJoin is
35
47
  IRolesBase,
36
48
  IPartnerRegistryBase,
@@ -41,11 +53,11 @@ abstract contract MembershipJoin is
41
53
  RolesBase,
42
54
  EntitlementGatedBase,
43
55
  Entitled,
44
- PrepayBase,
45
56
  PointsBase,
46
57
  ERC721ABase
47
58
  {
48
59
  using CustomRevert for bytes4;
60
+ using SafeTransferLib for address;
49
61
 
50
62
  /// @notice Constant representing the permission to join a space
51
63
  bytes32 internal constant JOIN_SPACE = bytes32(abi.encodePacked(Permissions.JoinSpace));
@@ -53,6 +65,12 @@ abstract contract MembershipJoin is
53
65
  /// @notice Constant representing the joinSpace(address) function selector
54
66
  bytes4 internal constant JOIN_SPACE_SELECTOR = bytes4(keccak256("joinSpace(address)"));
55
67
 
68
+ struct PricingDetails {
69
+ uint256 basePrice;
70
+ uint256 amountDue;
71
+ bool shouldCharge;
72
+ }
73
+
56
74
  /// @notice Encodes data for joining a space
57
75
  /// @param selector The type of transaction (join with or without referral)
58
76
  /// @param sender The address of the sender
@@ -82,17 +100,14 @@ abstract contract MembershipJoin is
82
100
  return joinDetails;
83
101
  }
84
102
 
85
- // Check if this is a free join due to prepaid supply
86
- uint256 prepaidSupply = _getPrepaidSupply();
87
- if (prepaidSupply > 0) {
88
- joinDetails.isPrepaid = true;
89
- return joinDetails;
90
- }
91
-
92
103
  (uint256 totalRequired, ) = _getTotalMembershipPayment(membershipPrice);
93
104
  (joinDetails.amountDue, joinDetails.shouldCharge) = (totalRequired, true);
94
105
  }
95
106
 
107
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
108
+ /* JOIN */
109
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
110
+
96
111
  /// @notice Handles the process of joining a space
97
112
  /// @param receiver The address that will receive the membership token
98
113
  function _joinSpace(address receiver) internal {
@@ -100,17 +115,14 @@ abstract contract MembershipJoin is
100
115
 
101
116
  PricingDetails memory joinDetails = _getPricingDetails();
102
117
 
103
- // Validate payment if required
104
- if (joinDetails.shouldCharge && msg.value < joinDetails.amountDue) {
105
- Membership__InsufficientPayment.selector.revertWith();
106
- }
107
-
108
- // Consume prepaid membership if applicable
109
- if (joinDetails.isPrepaid) _reducePrepay(1);
118
+ // Validate and capture payment (handles both free and paid memberships)
119
+ address currency = _getMembershipCurrency();
120
+ uint256 capturedAmount = _validateAndCapturePayment(currency, joinDetails.amountDue);
110
121
 
111
122
  bytes32 transactionId = _registerTransaction(
112
123
  receiver,
113
- _encodeJoinSpaceData(JOIN_SPACE_SELECTOR, msg.sender, receiver, "")
124
+ _encodeJoinSpaceData(JOIN_SPACE_SELECTOR, msg.sender, receiver, ""),
125
+ capturedAmount
114
126
  );
115
127
 
116
128
  (bool isEntitled, bool isCrosschainPending) = _checkEntitlement(
@@ -122,7 +134,11 @@ abstract contract MembershipJoin is
122
134
 
123
135
  if (!isCrosschainPending) {
124
136
  if (isEntitled) {
125
- if (joinDetails.shouldCharge) _chargeForJoinSpace(transactionId, joinDetails);
137
+ if (!joinDetails.shouldCharge) {
138
+ _afterChargeForJoinSpace(transactionId, receiver, 0);
139
+ } else {
140
+ _chargeForJoinSpace(transactionId, joinDetails);
141
+ }
126
142
  _refundBalance(transactionId, receiver);
127
143
  _issueToken(receiver);
128
144
  } else {
@@ -139,13 +155,9 @@ abstract contract MembershipJoin is
139
155
 
140
156
  PricingDetails memory joinDetails = _getPricingDetails();
141
157
 
142
- // Validate payment if required
143
- if (joinDetails.shouldCharge && msg.value < joinDetails.amountDue) {
144
- Membership__InsufficientPayment.selector.revertWith();
145
- }
146
-
147
- // Consume prepaid membership if applicable
148
- if (joinDetails.isPrepaid) _reducePrepay(1);
158
+ // Validate and capture payment (handles both free and paid memberships)
159
+ address currency = _getMembershipCurrency();
160
+ uint256 capturedAmount = _validateAndCapturePayment(currency, joinDetails.amountDue);
149
161
 
150
162
  _validateUserReferral(receiver, referral);
151
163
 
@@ -155,7 +167,8 @@ abstract contract MembershipJoin is
155
167
 
156
168
  bytes32 transactionId = _registerTransaction(
157
169
  receiver,
158
- _encodeJoinSpaceData(selector, msg.sender, receiver, referralData)
170
+ _encodeJoinSpaceData(selector, msg.sender, receiver, referralData),
171
+ capturedAmount
159
172
  );
160
173
 
161
174
  (bool isEntitled, bool isCrosschainPending) = _checkEntitlement(
@@ -167,9 +180,11 @@ abstract contract MembershipJoin is
167
180
 
168
181
  if (!isCrosschainPending) {
169
182
  if (isEntitled) {
170
- if (joinDetails.shouldCharge)
183
+ if (!joinDetails.shouldCharge) {
184
+ _afterChargeForJoinSpace(transactionId, receiver, 0);
185
+ } else {
171
186
  _chargeForJoinSpaceWithReferral(transactionId, joinDetails);
172
-
187
+ }
173
188
  _refundBalance(transactionId, receiver);
174
189
  _issueToken(receiver);
175
190
  } else {
@@ -184,12 +199,46 @@ abstract contract MembershipJoin is
184
199
  emit MembershipTokenRejected(receiver);
185
200
  }
186
201
 
202
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
203
+ /* VALIDATION */
204
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
205
+
206
+ /// @notice Validates and captures payment based on currency type
207
+ /// @dev For ETH: validates msg.value >= required amount, returns msg.value for refund handling
208
+ /// @dev For ERC20: rejects any ETH sent, transfers tokens from sender to this contract
209
+ /// @dev Handles free memberships (amountRequired = 0) correctly for both currency types
210
+ /// @param currency The currency address (NATIVE_TOKEN for ETH, or ERC20 address)
211
+ /// @param amountRequired The required payment amount (0 for free memberships)
212
+ /// @return The amount to capture (msg.value for ETH, amountRequired for ERC20)
213
+ function _validateAndCapturePayment(
214
+ address currency,
215
+ uint256 amountRequired
216
+ ) internal returns (uint256) {
217
+ if (currency == CurrencyTransfer.NATIVE_TOKEN) {
218
+ // ETH payment: validate msg.value, return full amount for refund handling
219
+ if (msg.value < amountRequired) Membership__InsufficientPayment.selector.revertWith();
220
+ return msg.value;
221
+ }
222
+
223
+ // ERC20 payment: reject any ETH sent
224
+ if (msg.value != 0) revert Membership__UnexpectedValue();
225
+
226
+ // Transfer ERC20 tokens from sender to contract (skip for free memberships)
227
+ if (amountRequired != 0) _transferIn(currency, msg.sender, amountRequired);
228
+
229
+ return amountRequired;
230
+ }
231
+
187
232
  function _validateUserReferral(address receiver, ReferralTypes memory referral) internal view {
188
233
  if (referral.userReferral == receiver || referral.userReferral == msg.sender) {
189
234
  Membership__InvalidAddress.selector.revertWith();
190
235
  }
191
236
  }
192
237
 
238
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
239
+ /* ENTITLEMENT */
240
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
241
+
193
242
  /// @notice Checks if a user is entitled to join the space and handles the entitlement process
194
243
  /// @dev This function checks both local and crosschain entitlements
195
244
  /// @param receiver The address of the user trying to join the space
@@ -300,7 +349,7 @@ abstract contract MembershipJoin is
300
349
  bytes32 transactionId,
301
350
  PricingDetails memory joinDetails
302
351
  ) internal {
303
- (bytes4 selector, address sender, address receiver, ) = abi.decode(
352
+ (bytes4 selector, , address receiver, ) = abi.decode(
304
353
  _getCapturedData(transactionId),
305
354
  (bytes4, address, address, bytes)
306
355
  );
@@ -309,16 +358,9 @@ abstract contract MembershipJoin is
309
358
  Membership__InvalidTransactionType.selector.revertWith();
310
359
  }
311
360
 
312
- uint256 protocolFee = _collectProtocolFee(sender, joinDetails.basePrice);
313
- uint256 ownerProceeds = joinDetails.amountDue - protocolFee;
361
+ _payProtocolFee(_getMembershipCurrency(), joinDetails.basePrice);
314
362
 
315
- _afterChargeForJoinSpace(
316
- transactionId,
317
- sender,
318
- receiver,
319
- joinDetails.amountDue,
320
- ownerProceeds
321
- );
363
+ _afterChargeForJoinSpace(transactionId, receiver, joinDetails.amountDue);
322
364
  }
323
365
 
324
366
  /// @notice Processes the charge for joining a space with referral
@@ -339,45 +381,31 @@ abstract contract MembershipJoin is
339
381
 
340
382
  ReferralTypes memory referral = abi.decode(referralData, (ReferralTypes));
341
383
 
342
- uint256 ownerProceeds;
343
- {
344
- uint256 protocolFee = _collectProtocolFee(sender, joinDetails.basePrice);
345
-
346
- uint256 partnerFee = _collectPartnerFee(
347
- sender,
348
- referral.partner,
349
- joinDetails.basePrice
350
- );
351
-
352
- uint256 referralFee = _collectReferralCodeFee(
353
- sender,
354
- referral.userReferral,
355
- referral.referralCode,
356
- joinDetails.basePrice
357
- );
358
-
359
- ownerProceeds = joinDetails.amountDue - protocolFee - partnerFee - referralFee;
360
- }
361
-
362
- _afterChargeForJoinSpace(
363
- transactionId,
384
+ address currency = _getMembershipCurrency();
385
+ _payProtocolFee(currency, joinDetails.basePrice);
386
+ _payPartnerFee(currency, referral.partner, joinDetails.basePrice);
387
+ _payReferralFee(
388
+ currency,
364
389
  sender,
365
- receiver,
366
- joinDetails.amountDue,
367
- ownerProceeds
390
+ referral.userReferral,
391
+ referral.referralCode,
392
+ joinDetails.basePrice
368
393
  );
394
+
395
+ _afterChargeForJoinSpace(transactionId, receiver, joinDetails.amountDue);
369
396
  }
370
397
 
398
+ /// @notice Finalizes the charge after fees have been paid
399
+ /// @dev Releases `paymentRequired` from captured value (leaving excess for refund),
400
+ /// cleans up transaction data, and mints points. Called by both sync and async flows.
401
+ /// @param transactionId The unique identifier for this join transaction
402
+ /// @param receiver The address receiving the membership
403
+ /// @param paymentRequired The amount consumed for payment (fees + owner proceeds)
371
404
  function _afterChargeForJoinSpace(
372
405
  bytes32 transactionId,
373
- address payer,
374
406
  address receiver,
375
- uint256 paymentRequired,
376
- uint256 ownerProceeds
407
+ uint256 paymentRequired
377
408
  ) internal {
378
- // account for owner's proceeds
379
- if (ownerProceeds != 0) _transferIn(payer, ownerProceeds);
380
-
381
409
  _releaseCapturedValue(transactionId, paymentRequired);
382
410
  _deleteCapturedData(transactionId);
383
411
 
@@ -413,81 +441,98 @@ abstract contract MembershipJoin is
413
441
  }
414
442
  }
415
443
 
416
- /// @notice Refunds the balance to the sender if necessary
444
+ /// @notice Refunds the remaining captured balance
445
+ /// @dev NOTE: Currently refunds go to `receiver` (the membership recipient), not the original
446
+ /// payer (msg.sender). This means if Alice pays for Bob's membership, excess ETH is refunded
447
+ /// to Bob, not Alice. This design may change in a future update to refund the original payer.
417
448
  /// @param transactionId The unique identifier for this join transaction
418
- /// @param sender The address of the sender to refund
419
- function _refundBalance(bytes32 transactionId, address sender) internal {
449
+ /// @param receiver The address to receive the refund (currently the membership receiver)
450
+ function _refundBalance(bytes32 transactionId, address receiver) internal {
420
451
  uint256 userValue = _getCapturedValue(transactionId);
421
452
  if (userValue > 0) {
422
453
  _releaseCapturedValue(transactionId, userValue);
423
454
  CurrencyTransfer.transferCurrency(
424
455
  _getMembershipCurrency(),
425
456
  address(this),
426
- sender,
457
+ receiver,
427
458
  userValue
428
459
  );
429
460
  }
430
461
  }
431
462
 
432
- /// @notice Collects the referral fee if applicable
433
- /// @param payer The address of the payer
463
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
464
+ /* FEE DISTRIBUTION */
465
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
466
+
467
+ /// @notice Pays the protocol fee to the platform fee recipient
468
+ /// @param currency The currency to pay in
469
+ /// @param membershipPrice The price of the membership
470
+ /// @return protocolFee The amount of protocol fee paid
471
+ function _payProtocolFee(
472
+ address currency,
473
+ uint256 membershipPrice
474
+ ) internal returns (uint256 protocolFee) {
475
+ protocolFee = _getProtocolFee(membershipPrice);
476
+
477
+ CurrencyTransfer.transferCurrency(
478
+ currency,
479
+ address(this),
480
+ _getPlatformRequirements().getFeeRecipient(),
481
+ protocolFee
482
+ );
483
+ }
484
+
485
+ /// @notice Pays the referral fee if applicable
486
+ /// @param currency The currency to pay in
487
+ /// @param sender The original sender address (used for self-referral check)
488
+ /// @param userReferral The user referral address
434
489
  /// @param referralCode The referral code used
435
490
  /// @param membershipPrice The price of the membership
436
- /// @return referralFee The amount of referral fee collected
437
- function _collectReferralCodeFee(
438
- address payer,
491
+ function _payReferralFee(
492
+ address currency,
493
+ address sender,
439
494
  address userReferral,
440
495
  string memory referralCode,
441
496
  uint256 membershipPrice
442
- ) internal returns (uint256 referralFee) {
497
+ ) internal {
443
498
  if (bytes(referralCode).length != 0) {
444
499
  Referral memory referral = _referralInfo(referralCode);
445
500
 
446
- if (referral.recipient == address(0) || referral.basisPoints == 0) return 0;
501
+ if (referral.recipient == address(0) || referral.basisPoints == 0) return;
447
502
 
448
- referralFee = BasisPoints.calculate(membershipPrice, referral.basisPoints);
503
+ uint256 referralFee = BasisPoints.calculate(membershipPrice, referral.basisPoints);
449
504
 
450
505
  CurrencyTransfer.transferCurrency(
451
- _getMembershipCurrency(),
452
- payer,
506
+ currency,
507
+ address(this),
453
508
  referral.recipient,
454
509
  referralFee
455
510
  );
456
511
  } else if (userReferral != address(0)) {
457
- if (userReferral == payer) return 0;
512
+ if (userReferral == sender) return;
458
513
 
459
- referralFee = BasisPoints.calculate(membershipPrice, _defaultBpsFee());
514
+ uint256 referralFee = BasisPoints.calculate(membershipPrice, _defaultBpsFee());
460
515
 
461
- CurrencyTransfer.transferCurrency(
462
- _getMembershipCurrency(),
463
- payer,
464
- userReferral,
465
- referralFee
466
- );
516
+ CurrencyTransfer.transferCurrency(currency, address(this), userReferral, referralFee);
467
517
  }
468
518
  }
469
519
 
470
- /// @notice Collects the partner fee if applicable
471
- /// @param payer The address of the payer
520
+ /// @notice Pays the partner fee if applicable
521
+ /// @param currency The currency to pay in
472
522
  /// @param partner The address of the partner
473
523
  /// @param membershipPrice The price of the membership
474
- /// @return partnerFee The amount of partner fee collected
475
- function _collectPartnerFee(
476
- address payer,
477
- address partner,
478
- uint256 membershipPrice
479
- ) internal returns (uint256 partnerFee) {
480
- if (partner == address(0)) return 0;
524
+ function _payPartnerFee(address currency, address partner, uint256 membershipPrice) internal {
525
+ if (partner == address(0)) return;
481
526
 
482
527
  Partner memory partnerInfo = IPartnerRegistry(_getSpaceFactory()).partnerInfo(partner);
483
528
 
484
- if (partnerInfo.fee == 0) return 0;
529
+ if (partnerInfo.fee == 0) return;
485
530
 
486
- partnerFee = BasisPoints.calculate(membershipPrice, partnerInfo.fee);
531
+ uint256 partnerFee = BasisPoints.calculate(membershipPrice, partnerInfo.fee);
487
532
 
488
533
  CurrencyTransfer.transferCurrency(
489
- _getMembershipCurrency(),
490
- payer,
534
+ currency,
535
+ address(this),
491
536
  partnerInfo.recipient,
492
537
  partnerFee
493
538
  );
@@ -499,12 +544,13 @@ abstract contract MembershipJoin is
499
544
 
500
545
  uint256 duration = _getMembershipDuration();
501
546
  uint256 basePrice = _getMembershipRenewalPrice(tokenId, _totalSupply());
547
+ address currency = _getMembershipCurrency();
502
548
 
503
549
  // Handle free renewal
504
550
  if (basePrice == 0) {
505
- // Refund any ETH sent
551
+ // Refund any ETH sent (regardless of membership currency)
506
552
  CurrencyTransfer.transferCurrency(
507
- _getMembershipCurrency(),
553
+ CurrencyTransfer.NATIVE_TOKEN,
508
554
  address(this),
509
555
  payer,
510
556
  msg.value
@@ -515,26 +561,25 @@ abstract contract MembershipJoin is
515
561
 
516
562
  (uint256 totalRequired, ) = _getTotalMembershipPayment(basePrice);
517
563
 
518
- if (totalRequired > msg.value) Membership__InvalidPayment.selector.revertWith();
564
+ if (currency == CurrencyTransfer.NATIVE_TOKEN) {
565
+ // ETH payment: validate msg.value
566
+ if (totalRequired > msg.value) Membership__InvalidPayment.selector.revertWith();
519
567
 
520
- // Collect protocol fee (transfers from payer)
521
- uint256 protocolFee = _collectProtocolFee(payer, basePrice);
568
+ _payProtocolFee(currency, basePrice);
522
569
 
523
- // Calculate owner proceeds (what goes to space owner)
524
- uint256 ownerProceeds = totalRequired - protocolFee;
570
+ // Handle excess payment
571
+ uint256 excess = msg.value - totalRequired;
572
+ if (excess > 0) {
573
+ CurrencyTransfer.transferCurrency(currency, address(this), payer, excess);
574
+ }
575
+ } else {
576
+ // ERC20 payment: reject any ETH sent
577
+ if (msg.value != 0) revert Membership__UnexpectedValue();
525
578
 
526
- // Transfer owner proceeds to contract
527
- if (ownerProceeds > 0) _transferIn(payer, ownerProceeds);
579
+ // Transfer ERC20 from payer to contract
580
+ _transferIn(currency, payer, totalRequired);
528
581
 
529
- // Handle excess payment
530
- uint256 excess = msg.value - totalRequired;
531
- if (excess > 0) {
532
- CurrencyTransfer.transferCurrency(
533
- _getMembershipCurrency(),
534
- address(this),
535
- payer,
536
- excess
537
- );
582
+ _payProtocolFee(currency, basePrice);
538
583
  }
539
584
 
540
585
  _mintMembershipPoints(receiver, totalRequired);
@@ -542,11 +587,16 @@ abstract contract MembershipJoin is
542
587
  }
543
588
 
544
589
  /// @notice Mints points to a member based on their paid amount
545
- /// @dev This function handles point minting for both new joins and renewals
590
+ /// @dev Only mints points for ETH payments. ERC20 support requires oracle integration.
546
591
  /// @param receiver The address receiving the points
547
592
  /// @param paidAmount The amount paid for membership
548
593
  function _mintMembershipPoints(address receiver, uint256 paidAmount) internal {
549
- // calculate points and credit them
594
+ // No points for free memberships
595
+ if (paidAmount == 0) return;
596
+
597
+ // TODO: Add ERC20 support - requires price oracle to convert token amount to points
598
+ if (_getMembershipCurrency() != CurrencyTransfer.NATIVE_TOKEN) return;
599
+
550
600
  address airdropDiamond = _getAirdropDiamond();
551
601
  uint256 points = _getPoints(
552
602
  airdropDiamond,
@@ -556,4 +606,21 @@ abstract contract MembershipJoin is
556
606
  _mintPoints(airdropDiamond, receiver, points);
557
607
  _mintPoints(airdropDiamond, _owner(), points);
558
608
  }
609
+
610
+ /// @notice Transfers tokens from an address to this contract
611
+ /// @param currency The currency address (NATIVE_TOKEN for ETH, or ERC20 address)
612
+ /// @param from The address to transfer from
613
+ /// @param amount The amount to transfer
614
+ function _transferIn(address currency, address from, uint256 amount) internal {
615
+ if (currency == CurrencyTransfer.NATIVE_TOKEN) return;
616
+
617
+ // Handle ERC20 tokens with fee-on-transfer check
618
+ uint256 balanceBefore = currency.balanceOf(address(this));
619
+ currency.safeTransferFrom(from, address(this), amount);
620
+ uint256 balanceAfter = currency.balanceOf(address(this));
621
+
622
+ if (balanceAfter - balanceBefore < amount) {
623
+ Membership__InsufficientPayment.selector.revertWith();
624
+ }
625
+ }
559
626
  }
@@ -11,8 +11,9 @@ interface ITreasury {
11
11
  /// @notice Withdraw funds from the treasury to a specified account
12
12
  /// @dev Can only be called by the owner of the contract. Will revert if account is zero address
13
13
  /// or if balance is 0
14
+ /// @param currency The currency to withdraw (NATIVE_TOKEN for ETH, or ERC20 address)
14
15
  /// @param account The address to withdraw funds to
15
- function withdraw(address account) external;
16
+ function withdraw(address currency, address account) external;
16
17
 
17
18
  /// @notice Handle the receipt of a single ERC721 token
18
19
  /// @dev Implements the IERC721Receiver interface to safely receive ERC721 tokens