@towns-protocol/contracts 0.0.442 → 0.0.444

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 (24) hide show
  1. package/docs/membership_architecture.md +237 -0
  2. package/package.json +3 -3
  3. package/scripts/deployments/diamonds/DeploySpaceFactory.s.sol +2 -2
  4. package/scripts/deployments/facets/DeployMembership.s.sol +2 -1
  5. package/scripts/deployments/utils/DeployMockERC20.s.sol +1 -1
  6. package/scripts/deployments/utils/DeployMockUSDC.s.sol +19 -0
  7. package/scripts/interactions/InteractPostDeploy.s.sol +11 -0
  8. package/src/factory/facets/feature/FeatureManagerFacet.sol +32 -29
  9. package/src/factory/facets/feature/FeatureManagerMod.sol +248 -0
  10. package/src/factory/facets/feature/{IFeatureManagerFacet.sol → IFeatureManager.sol} +2 -35
  11. package/src/factory/facets/fee/FeeManagerFacet.sol +1 -1
  12. package/src/factory/facets/fee/FeeTypesLib.sol +8 -1
  13. package/src/spaces/facets/dispatcher/DispatcherBase.sol +13 -5
  14. package/src/spaces/facets/gated/EntitlementGated.sol +9 -5
  15. package/src/spaces/facets/membership/IMembership.sol +11 -1
  16. package/src/spaces/facets/membership/MembershipBase.sol +30 -59
  17. package/src/spaces/facets/membership/MembershipFacet.sol +19 -1
  18. package/src/spaces/facets/membership/MembershipStorage.sol +1 -0
  19. package/src/spaces/facets/membership/join/MembershipJoin.sol +186 -110
  20. package/src/spaces/facets/treasury/ITreasury.sol +2 -1
  21. package/src/spaces/facets/treasury/Treasury.sol +21 -24
  22. package/src/spaces/facets/xchain/SpaceEntitlementGated.sol +3 -2
  23. package/src/factory/facets/feature/FeatureManagerBase.sol +0 -152
  24. package/src/factory/facets/feature/FeatureManagerStorage.sol +0 -47
@@ -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
@@ -29,7 +31,18 @@ import {MembershipBase} from "../MembershipBase.sol";
29
31
  /// @title MembershipJoin
30
32
  /// @notice Handles the logic for joining a space, including entitlement checks and payment
31
33
  /// processing
32
- /// @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
33
46
  abstract contract MembershipJoin is
34
47
  IRolesBase,
35
48
  IPartnerRegistryBase,
@@ -44,6 +57,7 @@ abstract contract MembershipJoin is
44
57
  ERC721ABase
45
58
  {
46
59
  using CustomRevert for bytes4;
60
+ using SafeTransferLib for address;
47
61
 
48
62
  /// @notice Constant representing the permission to join a space
49
63
  bytes32 internal constant JOIN_SPACE = bytes32(abi.encodePacked(Permissions.JoinSpace));
@@ -90,6 +104,10 @@ abstract contract MembershipJoin is
90
104
  (joinDetails.amountDue, joinDetails.shouldCharge) = (totalRequired, true);
91
105
  }
92
106
 
107
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
108
+ /* JOIN */
109
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
110
+
93
111
  /// @notice Handles the process of joining a space
94
112
  /// @param receiver The address that will receive the membership token
95
113
  function _joinSpace(address receiver) internal {
@@ -97,14 +115,14 @@ abstract contract MembershipJoin is
97
115
 
98
116
  PricingDetails memory joinDetails = _getPricingDetails();
99
117
 
100
- // Validate payment if required
101
- if (joinDetails.shouldCharge && msg.value < joinDetails.amountDue) {
102
- Membership__InsufficientPayment.selector.revertWith();
103
- }
118
+ // Validate and capture payment (handles both free and paid memberships)
119
+ address currency = _getMembershipCurrency();
120
+ uint256 capturedAmount = _validateAndCapturePayment(currency, joinDetails.amountDue);
104
121
 
105
122
  bytes32 transactionId = _registerTransaction(
106
123
  receiver,
107
- _encodeJoinSpaceData(JOIN_SPACE_SELECTOR, msg.sender, receiver, "")
124
+ _encodeJoinSpaceData(JOIN_SPACE_SELECTOR, msg.sender, receiver, ""),
125
+ capturedAmount
108
126
  );
109
127
 
110
128
  (bool isEntitled, bool isCrosschainPending) = _checkEntitlement(
@@ -116,7 +134,11 @@ abstract contract MembershipJoin is
116
134
 
117
135
  if (!isCrosschainPending) {
118
136
  if (isEntitled) {
119
- if (joinDetails.shouldCharge) _chargeForJoinSpace(transactionId, joinDetails);
137
+ if (!joinDetails.shouldCharge) {
138
+ _afterChargeForJoinSpace(transactionId, receiver, 0);
139
+ } else {
140
+ _chargeForJoinSpace(transactionId, joinDetails);
141
+ }
120
142
  _refundBalance(transactionId, receiver);
121
143
  _issueToken(receiver);
122
144
  } else {
@@ -133,10 +155,9 @@ abstract contract MembershipJoin is
133
155
 
134
156
  PricingDetails memory joinDetails = _getPricingDetails();
135
157
 
136
- // Validate payment if required
137
- if (joinDetails.shouldCharge && msg.value < joinDetails.amountDue) {
138
- Membership__InsufficientPayment.selector.revertWith();
139
- }
158
+ // Validate and capture payment (handles both free and paid memberships)
159
+ address currency = _getMembershipCurrency();
160
+ uint256 capturedAmount = _validateAndCapturePayment(currency, joinDetails.amountDue);
140
161
 
141
162
  _validateUserReferral(receiver, referral);
142
163
 
@@ -146,7 +167,8 @@ abstract contract MembershipJoin is
146
167
 
147
168
  bytes32 transactionId = _registerTransaction(
148
169
  receiver,
149
- _encodeJoinSpaceData(selector, msg.sender, receiver, referralData)
170
+ _encodeJoinSpaceData(selector, msg.sender, receiver, referralData),
171
+ capturedAmount
150
172
  );
151
173
 
152
174
  (bool isEntitled, bool isCrosschainPending) = _checkEntitlement(
@@ -158,9 +180,11 @@ abstract contract MembershipJoin is
158
180
 
159
181
  if (!isCrosschainPending) {
160
182
  if (isEntitled) {
161
- if (joinDetails.shouldCharge)
183
+ if (!joinDetails.shouldCharge) {
184
+ _afterChargeForJoinSpace(transactionId, receiver, 0);
185
+ } else {
162
186
  _chargeForJoinSpaceWithReferral(transactionId, joinDetails);
163
-
187
+ }
164
188
  _refundBalance(transactionId, receiver);
165
189
  _issueToken(receiver);
166
190
  } else {
@@ -175,12 +199,46 @@ abstract contract MembershipJoin is
175
199
  emit MembershipTokenRejected(receiver);
176
200
  }
177
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
+
178
232
  function _validateUserReferral(address receiver, ReferralTypes memory referral) internal view {
179
233
  if (referral.userReferral == receiver || referral.userReferral == msg.sender) {
180
234
  Membership__InvalidAddress.selector.revertWith();
181
235
  }
182
236
  }
183
237
 
238
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
239
+ /* ENTITLEMENT */
240
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
241
+
184
242
  /// @notice Checks if a user is entitled to join the space and handles the entitlement process
185
243
  /// @dev This function checks both local and crosschain entitlements
186
244
  /// @param receiver The address of the user trying to join the space
@@ -291,7 +349,7 @@ abstract contract MembershipJoin is
291
349
  bytes32 transactionId,
292
350
  PricingDetails memory joinDetails
293
351
  ) internal {
294
- (bytes4 selector, address sender, address receiver, ) = abi.decode(
352
+ (bytes4 selector, , address receiver, ) = abi.decode(
295
353
  _getCapturedData(transactionId),
296
354
  (bytes4, address, address, bytes)
297
355
  );
@@ -300,16 +358,9 @@ abstract contract MembershipJoin is
300
358
  Membership__InvalidTransactionType.selector.revertWith();
301
359
  }
302
360
 
303
- uint256 protocolFee = _collectProtocolFee(sender, joinDetails.basePrice);
304
- uint256 ownerProceeds = joinDetails.amountDue - protocolFee;
361
+ _payProtocolFee(_getMembershipCurrency(), joinDetails.basePrice);
305
362
 
306
- _afterChargeForJoinSpace(
307
- transactionId,
308
- sender,
309
- receiver,
310
- joinDetails.amountDue,
311
- ownerProceeds
312
- );
363
+ _afterChargeForJoinSpace(transactionId, receiver, joinDetails.amountDue);
313
364
  }
314
365
 
315
366
  /// @notice Processes the charge for joining a space with referral
@@ -330,45 +381,31 @@ abstract contract MembershipJoin is
330
381
 
331
382
  ReferralTypes memory referral = abi.decode(referralData, (ReferralTypes));
332
383
 
333
- uint256 ownerProceeds;
334
- {
335
- uint256 protocolFee = _collectProtocolFee(sender, joinDetails.basePrice);
336
-
337
- uint256 partnerFee = _collectPartnerFee(
338
- sender,
339
- referral.partner,
340
- joinDetails.basePrice
341
- );
342
-
343
- uint256 referralFee = _collectReferralCodeFee(
344
- sender,
345
- referral.userReferral,
346
- referral.referralCode,
347
- joinDetails.basePrice
348
- );
349
-
350
- ownerProceeds = joinDetails.amountDue - protocolFee - partnerFee - referralFee;
351
- }
352
-
353
- _afterChargeForJoinSpace(
354
- transactionId,
384
+ address currency = _getMembershipCurrency();
385
+ _payProtocolFee(currency, joinDetails.basePrice);
386
+ _payPartnerFee(currency, referral.partner, joinDetails.basePrice);
387
+ _payReferralFee(
388
+ currency,
355
389
  sender,
356
- receiver,
357
- joinDetails.amountDue,
358
- ownerProceeds
390
+ referral.userReferral,
391
+ referral.referralCode,
392
+ joinDetails.basePrice
359
393
  );
394
+
395
+ _afterChargeForJoinSpace(transactionId, receiver, joinDetails.amountDue);
360
396
  }
361
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)
362
404
  function _afterChargeForJoinSpace(
363
405
  bytes32 transactionId,
364
- address payer,
365
406
  address receiver,
366
- uint256 paymentRequired,
367
- uint256 ownerProceeds
407
+ uint256 paymentRequired
368
408
  ) internal {
369
- // account for owner's proceeds
370
- if (ownerProceeds != 0) _transferIn(payer, ownerProceeds);
371
-
372
409
  _releaseCapturedValue(transactionId, paymentRequired);
373
410
  _deleteCapturedData(transactionId);
374
411
 
@@ -404,81 +441,98 @@ abstract contract MembershipJoin is
404
441
  }
405
442
  }
406
443
 
407
- /// @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.
408
448
  /// @param transactionId The unique identifier for this join transaction
409
- /// @param sender The address of the sender to refund
410
- 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 {
411
451
  uint256 userValue = _getCapturedValue(transactionId);
412
452
  if (userValue > 0) {
413
453
  _releaseCapturedValue(transactionId, userValue);
414
454
  CurrencyTransfer.transferCurrency(
415
455
  _getMembershipCurrency(),
416
456
  address(this),
417
- sender,
457
+ receiver,
418
458
  userValue
419
459
  );
420
460
  }
421
461
  }
422
462
 
423
- /// @notice Collects the referral fee if applicable
424
- /// @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
425
489
  /// @param referralCode The referral code used
426
490
  /// @param membershipPrice The price of the membership
427
- /// @return referralFee The amount of referral fee collected
428
- function _collectReferralCodeFee(
429
- address payer,
491
+ function _payReferralFee(
492
+ address currency,
493
+ address sender,
430
494
  address userReferral,
431
495
  string memory referralCode,
432
496
  uint256 membershipPrice
433
- ) internal returns (uint256 referralFee) {
497
+ ) internal {
434
498
  if (bytes(referralCode).length != 0) {
435
499
  Referral memory referral = _referralInfo(referralCode);
436
500
 
437
- if (referral.recipient == address(0) || referral.basisPoints == 0) return 0;
501
+ if (referral.recipient == address(0) || referral.basisPoints == 0) return;
438
502
 
439
- referralFee = BasisPoints.calculate(membershipPrice, referral.basisPoints);
503
+ uint256 referralFee = BasisPoints.calculate(membershipPrice, referral.basisPoints);
440
504
 
441
505
  CurrencyTransfer.transferCurrency(
442
- _getMembershipCurrency(),
443
- payer,
506
+ currency,
507
+ address(this),
444
508
  referral.recipient,
445
509
  referralFee
446
510
  );
447
511
  } else if (userReferral != address(0)) {
448
- if (userReferral == payer) return 0;
512
+ if (userReferral == sender) return;
449
513
 
450
- referralFee = BasisPoints.calculate(membershipPrice, _defaultBpsFee());
514
+ uint256 referralFee = BasisPoints.calculate(membershipPrice, _defaultBpsFee());
451
515
 
452
- CurrencyTransfer.transferCurrency(
453
- _getMembershipCurrency(),
454
- payer,
455
- userReferral,
456
- referralFee
457
- );
516
+ CurrencyTransfer.transferCurrency(currency, address(this), userReferral, referralFee);
458
517
  }
459
518
  }
460
519
 
461
- /// @notice Collects the partner fee if applicable
462
- /// @param payer The address of the payer
520
+ /// @notice Pays the partner fee if applicable
521
+ /// @param currency The currency to pay in
463
522
  /// @param partner The address of the partner
464
523
  /// @param membershipPrice The price of the membership
465
- /// @return partnerFee The amount of partner fee collected
466
- function _collectPartnerFee(
467
- address payer,
468
- address partner,
469
- uint256 membershipPrice
470
- ) internal returns (uint256 partnerFee) {
471
- if (partner == address(0)) return 0;
524
+ function _payPartnerFee(address currency, address partner, uint256 membershipPrice) internal {
525
+ if (partner == address(0)) return;
472
526
 
473
527
  Partner memory partnerInfo = IPartnerRegistry(_getSpaceFactory()).partnerInfo(partner);
474
528
 
475
- if (partnerInfo.fee == 0) return 0;
529
+ if (partnerInfo.fee == 0) return;
476
530
 
477
- partnerFee = BasisPoints.calculate(membershipPrice, partnerInfo.fee);
531
+ uint256 partnerFee = BasisPoints.calculate(membershipPrice, partnerInfo.fee);
478
532
 
479
533
  CurrencyTransfer.transferCurrency(
480
- _getMembershipCurrency(),
481
- payer,
534
+ currency,
535
+ address(this),
482
536
  partnerInfo.recipient,
483
537
  partnerFee
484
538
  );
@@ -490,12 +544,13 @@ abstract contract MembershipJoin is
490
544
 
491
545
  uint256 duration = _getMembershipDuration();
492
546
  uint256 basePrice = _getMembershipRenewalPrice(tokenId, _totalSupply());
547
+ address currency = _getMembershipCurrency();
493
548
 
494
549
  // Handle free renewal
495
550
  if (basePrice == 0) {
496
- // Refund any ETH sent
551
+ // Refund any ETH sent (regardless of membership currency)
497
552
  CurrencyTransfer.transferCurrency(
498
- _getMembershipCurrency(),
553
+ CurrencyTransfer.NATIVE_TOKEN,
499
554
  address(this),
500
555
  payer,
501
556
  msg.value
@@ -506,26 +561,25 @@ abstract contract MembershipJoin is
506
561
 
507
562
  (uint256 totalRequired, ) = _getTotalMembershipPayment(basePrice);
508
563
 
509
- 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();
510
567
 
511
- // Collect protocol fee (transfers from payer)
512
- uint256 protocolFee = _collectProtocolFee(payer, basePrice);
568
+ _payProtocolFee(currency, basePrice);
513
569
 
514
- // Calculate owner proceeds (what goes to space owner)
515
- 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();
516
578
 
517
- // Transfer owner proceeds to contract
518
- if (ownerProceeds > 0) _transferIn(payer, ownerProceeds);
579
+ // Transfer ERC20 from payer to contract
580
+ _transferIn(currency, payer, totalRequired);
519
581
 
520
- // Handle excess payment
521
- uint256 excess = msg.value - totalRequired;
522
- if (excess > 0) {
523
- CurrencyTransfer.transferCurrency(
524
- _getMembershipCurrency(),
525
- address(this),
526
- payer,
527
- excess
528
- );
582
+ _payProtocolFee(currency, basePrice);
529
583
  }
530
584
 
531
585
  _mintMembershipPoints(receiver, totalRequired);
@@ -533,11 +587,16 @@ abstract contract MembershipJoin is
533
587
  }
534
588
 
535
589
  /// @notice Mints points to a member based on their paid amount
536
- /// @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.
537
591
  /// @param receiver The address receiving the points
538
592
  /// @param paidAmount The amount paid for membership
539
593
  function _mintMembershipPoints(address receiver, uint256 paidAmount) internal {
540
- // 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
+
541
600
  address airdropDiamond = _getAirdropDiamond();
542
601
  uint256 points = _getPoints(
543
602
  airdropDiamond,
@@ -547,4 +606,21 @@ abstract contract MembershipJoin is
547
606
  _mintPoints(airdropDiamond, receiver, points);
548
607
  _mintPoints(airdropDiamond, _owner(), points);
549
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
+ }
550
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
@@ -2,53 +2,50 @@
2
2
  pragma solidity ^0.8.23;
3
3
 
4
4
  // interfaces
5
-
6
5
  import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
7
- import {IMembershipBase} from "src/spaces/facets/membership/IMembership.sol";
8
- import {ITreasury} from "src/spaces/facets/treasury/ITreasury.sol";
6
+ import {IMembershipBase} from "../membership/IMembership.sol";
7
+ import {ITreasury} from "./ITreasury.sol";
9
8
 
10
9
  // libraries
11
- import {CurrencyTransfer} from "src/utils/libraries/CurrencyTransfer.sol";
10
+ import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";
11
+ import {CurrencyTransfer} from "../../../utils/libraries/CurrencyTransfer.sol";
12
+ import {CustomRevert} from "../../../utils/libraries/CustomRevert.sol";
12
13
 
13
14
  // contracts
14
-
15
15
  import {Facet} from "@towns-protocol/diamond/src/facets/Facet.sol";
16
16
  import {TokenOwnableBase} from "@towns-protocol/diamond/src/facets/ownable/token/TokenOwnableBase.sol";
17
- import {MembershipStorage} from "src/spaces/facets/membership/MembershipStorage.sol";
18
- import {CustomRevert} from "src/utils/libraries/CustomRevert.sol";
19
17
  import {ReentrancyGuard} from "solady/utils/ReentrancyGuard.sol";
20
18
 
21
19
  contract Treasury is TokenOwnableBase, ReentrancyGuard, Facet, ITreasury {
20
+ using CustomRevert for bytes4;
21
+ using SafeTransferLib for address;
22
+
22
23
  function __Treasury_init() external onlyInitializing {
23
24
  _addInterface(type(IERC1155Receiver).interfaceId);
24
25
  }
25
26
 
26
- ///@inheritdoc ITreasury
27
- function withdraw(address account) external onlyOwner nonReentrant {
28
- if (account == address(0)) {
29
- CustomRevert.revertWith(IMembershipBase.Membership__InvalidAddress.selector);
30
- }
31
-
32
- // get the balance
33
- uint256 balance = address(this).balance;
27
+ /// @inheritdoc ITreasury
28
+ function withdraw(address currency, address account) external onlyOwner nonReentrant {
29
+ if (account == address(0)) IMembershipBase.Membership__InvalidAddress.selector.revertWith();
34
30
 
35
- // verify the balance is not 0
36
- if (balance == 0) {
37
- CustomRevert.revertWith(IMembershipBase.Membership__InsufficientPayment.selector);
38
- }
31
+ // Get balance based on currency type
32
+ uint256 balance = currency == CurrencyTransfer.NATIVE_TOKEN
33
+ ? address(this).balance
34
+ : currency.balanceOf(address(this));
39
35
 
40
- address currency = MembershipStorage.layout().membershipCurrency;
36
+ // Verify the balance is not 0
37
+ if (balance == 0) IMembershipBase.Membership__InsufficientPayment.selector.revertWith();
41
38
 
42
39
  CurrencyTransfer.transferCurrency(currency, address(this), account, balance);
43
40
 
44
- emit IMembershipBase.MembershipWithdrawal(account, balance);
41
+ emit IMembershipBase.MembershipWithdrawal(currency, account, balance);
45
42
  }
46
43
 
47
44
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
48
45
  /* Hooks */
49
46
  /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
50
47
 
51
- ///@inheritdoc ITreasury
48
+ /// @inheritdoc ITreasury
52
49
  function onERC721Received(
53
50
  address,
54
51
  address,
@@ -58,7 +55,7 @@ contract Treasury is TokenOwnableBase, ReentrancyGuard, Facet, ITreasury {
58
55
  return this.onERC721Received.selector;
59
56
  }
60
57
 
61
- ///@inheritdoc ITreasury
58
+ /// @inheritdoc ITreasury
62
59
  function onERC1155Received(
63
60
  address,
64
61
  address,
@@ -69,7 +66,7 @@ contract Treasury is TokenOwnableBase, ReentrancyGuard, Facet, ITreasury {
69
66
  return this.onERC1155Received.selector;
70
67
  }
71
68
 
72
- ///@inheritdoc ITreasury
69
+ /// @inheritdoc ITreasury
73
70
  function onERC1155BatchReceived(
74
71
  address,
75
72
  address,
@@ -10,7 +10,6 @@ import {MembershipJoin} from "../membership/join/MembershipJoin.sol";
10
10
 
11
11
  /// @title SpaceEntitlementGated
12
12
  /// @notice Handles entitlement-gated access to spaces and membership token issuance
13
- /// @dev Inherits from ISpaceEntitlementGatedBase, MembershipJoin, and EntitlementGated
14
13
  contract SpaceEntitlementGated is MembershipJoin, EntitlementGated {
15
14
  /// @notice Processes the result of an entitlement check
16
15
  /// @dev This function is called when the result of an entitlement check is posted
@@ -32,7 +31,9 @@ contract SpaceEntitlementGated is MembershipJoin, EntitlementGated {
32
31
  if (result == NodeVoteStatus.PASSED) {
33
32
  PricingDetails memory joinDetails = _getPricingDetails();
34
33
 
35
- if (joinDetails.shouldCharge) {
34
+ if (!joinDetails.shouldCharge) {
35
+ _afterChargeForJoinSpace(transactionId, receiver, 0);
36
+ } else {
36
37
  uint256 payment = _getCapturedValue(transactionId);
37
38
 
38
39
  if (payment < joinDetails.amountDue) {