@towns-protocol/contracts 0.0.451 → 0.0.453

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.
@@ -19,8 +19,12 @@ MembershipFacet (external interface)
19
19
  ├─ PointsBase (rewards points)
20
20
  └─ ERC721ABase (NFT implementation)
21
21
 
22
- SpaceEntitlementGated (result handler)
22
+ SpaceEntitlementGated (xchain/SpaceEntitlementGated.sol)
23
23
  └─ Overrides _onEntitlementCheckResultPosted()
24
+
25
+ ProtocolFeeLib (ProtocolFeeLib.sol)
26
+ └─ Wraps FeeManager.chargeFee with ERC20 approval handling
27
+ └─ Used by: MembershipJoin, TippingBase
24
28
  ```
25
29
 
26
30
  ### Storage Architecture
@@ -67,6 +71,8 @@ Memberships can be priced in:
67
71
 
68
72
  Currency validation happens in `_setMembershipCurrency()` which requires the token to have an enabled fee configuration in FeeManager.
69
73
 
74
+ **Note**: Rewards points (`_mintMembershipPoints`) are only minted for native ETH payments. ERC20 payments require an oracle for point calculation.
75
+
70
76
  ## Core Flow: Join → Entitlement Check → Token
71
77
 
72
78
  ### Happy Path (Local Entitlement)
@@ -84,10 +90,10 @@ User: joinSpace(receiver) + payment
84
90
  │ └─ ✓ User has local entitlement → PASS
85
91
 
86
92
  ├─ Charge for Join
87
- │ ├─ Protocol fee → Platform
93
+ │ ├─ Protocol fee → Platform (via FeeManager)
88
94
  │ ├─ Partner fee → Partner (if any)
89
95
  │ ├─ Referral fee → Referrer (if any)
90
- │ └─ Base price Space owner
96
+ │ └─ Base price remains in contract as revenue
91
97
 
92
98
  ├─ Refund Excess (if overpaid)
93
99
 
@@ -218,10 +224,21 @@ function _releaseCapturedValue(bytes32 transactionId, uint256 amount) internal
218
224
  ### Fee Distribution
219
225
 
220
226
  ```solidity
227
+ // MembershipJoin.sol - pays protocol fee via FeeManager
228
+ function _payProtocolFee(address currency, uint256 basePrice, uint256 expectedFee) internal
229
+
230
+ // ProtocolFeeLib.sol - shared fee charging logic
231
+ function charge(
232
+ address spaceFactory, // FeeManager address
233
+ bytes32 feeType, // fee type identifier
234
+ address user, // payer (msg.sender)
235
+ address currency, // NATIVE_TOKEN or ERC20
236
+ uint256 amount, // base price for fee calculation
237
+ uint256 expectedFee // pre-calculated fee amount
238
+ ) internal returns (uint256 protocolFee)
239
+
221
240
  // MembershipBase.sol
222
- function _collectProtocolFee(address payer, uint256 membershipPrice)
223
- internal returns (uint256 protocolFee)
224
- function _transferIn(address from, uint256 amount) internal returns (uint256)
241
+ function _transferIn(address currency, address from, uint256 amount) internal returns (uint256)
225
242
  ```
226
243
 
227
244
  ### Entitlement Checking
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towns-protocol/contracts",
3
- "version": "0.0.451",
3
+ "version": "0.0.453",
4
4
  "scripts": {
5
5
  "clean": "forge clean",
6
6
  "compile": "forge build",
@@ -33,7 +33,7 @@
33
33
  "@layerzerolabs/oapp-evm": "^0.3.2",
34
34
  "@openzeppelin/merkle-tree": "^1.0.8",
35
35
  "@prb/test": "^0.6.4",
36
- "@towns-protocol/prettier-config": "^0.0.451",
36
+ "@towns-protocol/prettier-config": "^0.0.453",
37
37
  "@wagmi/cli": "^2.2.0",
38
38
  "forge-std": "github:foundry-rs/forge-std#v1.10.0",
39
39
  "prettier": "^3.5.3",
@@ -50,5 +50,5 @@
50
50
  "publishConfig": {
51
51
  "access": "public"
52
52
  },
53
- "gitHead": "ad68c0f2514e44f51293824ebba5ccc2f8a6494c"
53
+ "gitHead": "f94c8877f1ce645d78158e8367c0c05b171c9141"
54
54
  }
@@ -1,17 +1,11 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity ^0.8.23;
3
3
 
4
- // interfaces
5
-
6
- // libraries
7
-
8
- // contracts
9
-
10
4
  import {Subscription} from "./SubscriptionModuleStorage.sol";
11
5
 
12
6
  interface ISubscriptionModuleBase {
13
7
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
14
- /* Structs */
8
+ /* STRUCTS */
15
9
  /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
16
10
 
17
11
  /// @notice Parameters for renewing a subscription
@@ -23,30 +17,25 @@ interface ISubscriptionModuleBase {
23
17
  }
24
18
 
25
19
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
26
- /* Errors */
20
+ /* ERRORS */
27
21
  /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
28
22
 
29
23
  error SubscriptionModule__InactiveSubscription();
30
24
  error SubscriptionModule__InvalidSpace();
31
- error SubscriptionModule__RenewalNotDue();
32
- error SubscriptionModule__RenewalFailed();
33
25
  error SubscriptionModule__InvalidSender();
34
26
  error SubscriptionModule__NotSupported();
35
27
  error SubscriptionModule__InvalidEntityId();
36
28
  error SubscriptionModule__InvalidCaller();
37
- error SubscriptionModule__InvalidAddress();
38
29
  error SubscriptionModule__ExceedsMaxBatchSize();
39
30
  error SubscriptionModule__EmptyBatch();
40
31
  error SubscriptionModule__InvalidTokenOwner();
41
- error SubscriptionModule__InsufficientBalance();
42
32
  error SubscriptionModule__ActiveSubscription();
43
33
  error SubscriptionModule__MembershipBanned();
44
34
  error SubscriptionModule__MembershipExpired();
45
35
  error SubscriptionModule__SubscriptionAlreadyInstalled();
46
- error SubscriptionModule__SubscriptionNotInstalled();
47
36
 
48
37
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
49
- /* Events */
38
+ /* EVENTS */
50
39
  /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
51
40
 
52
41
  event SubscriptionConfigured(
@@ -97,13 +86,45 @@ interface ISubscriptionModuleBase {
97
86
 
98
87
  interface ISubscriptionModule is ISubscriptionModuleBase {
99
88
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
100
- /* Functions */
89
+ /* ADMIN FUNCTIONS */
90
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
91
+
92
+ /// @notice Sets the space factory
93
+ /// @param spaceFactory The address of the space factory
94
+ function setSpaceFactory(address spaceFactory) external;
95
+
96
+ /// @notice Grants an operator access to call processRenewal
97
+ /// @param operator The address of the operator to grant
98
+ function grantOperator(address operator) external;
99
+
100
+ /// @notice Revokes an operator access to call processRenewal
101
+ /// @param operator The address of the operator to revoke
102
+ function revokeOperator(address operator) external;
103
+
104
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
105
+ /* STATE-CHANGING FUNCTIONS */
101
106
  /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
102
107
 
103
108
  /// @notice Processes multiple Towns membership renewals in batch
104
109
  /// @param params The parameters for the renewals
105
110
  function batchProcessRenewals(RenewalParams[] calldata params) external;
106
111
 
112
+ /// @notice Activates a subscription
113
+ /// @param entityId The entity ID of the subscription to activate
114
+ function activateSubscription(uint32 entityId) external;
115
+
116
+ /// @notice Pauses a subscription
117
+ /// @param entityId The entity ID of the subscription to pause
118
+ function pauseSubscription(uint32 entityId) external;
119
+
120
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
121
+ /* GETTERS */
122
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
123
+
124
+ /// @notice Gets the space factory
125
+ /// @return The address of the space factory
126
+ function getSpaceFactory() external view returns (address);
127
+
107
128
  /// @notice Gets the subscription for an account and entity ID
108
129
  /// @param account The address of the account to get the subscription for
109
130
  /// @param entityId The entity ID of the subscription to get
@@ -113,19 +134,6 @@ interface ISubscriptionModule is ISubscriptionModuleBase {
113
134
  uint32 entityId
114
135
  ) external view returns (Subscription memory);
115
136
 
116
- /// @notice Gets the renewal buffer for a membership duration
117
- /// @param duration The membership duration to get the renewal buffer for
118
- /// @return The renewal buffer for the duration
119
- function getRenewalBuffer(uint256 duration) external pure returns (uint256);
120
-
121
- /// @notice Activates a subscription
122
- /// @param entityId The entity ID of the subscription to activate
123
- function activateSubscription(uint32 entityId) external;
124
-
125
- /// @notice Pauses a subscription
126
- /// @param entityId The entity ID of the subscription to pause
127
- function pauseSubscription(uint32 entityId) external;
128
-
129
137
  /// @notice Gets the entity IDs for an account
130
138
  /// @param account The address of the account to get the entity IDs for
131
139
  /// @return The entity IDs for the account
@@ -135,19 +143,8 @@ interface ISubscriptionModule is ISubscriptionModuleBase {
135
143
  /// @param operator The address of the operator to check
136
144
  function isOperator(address operator) external view returns (bool);
137
145
 
138
- /// @notice Grants an operator access to call processRenewal
139
- /// @param operator The address of the operator to grant
140
- function grantOperator(address operator) external;
141
-
142
- /// @notice Revokes an operator access to call processRenewal
143
- /// @param operator The address of the operator to revoke
144
- function revokeOperator(address operator) external;
145
-
146
- /// @notice Sets the space factory
147
- /// @param spaceFactory The address of the space factory
148
- function setSpaceFactory(address spaceFactory) external;
149
-
150
- /// @notice Gets the space factory
151
- /// @return The address of the space factory
152
- function getSpaceFactory() external view returns (address);
146
+ /// @notice Gets the renewal buffer for a membership duration
147
+ /// @param duration The membership duration to get the renewal buffer for
148
+ /// @return The renewal buffer for the duration
149
+ function getRenewalBuffer(uint256 duration) external pure returns (uint256);
153
150
  }
@@ -2,20 +2,22 @@
2
2
  pragma solidity ^0.8.29;
3
3
 
4
4
  // interfaces
5
- import {IModularAccount} from "@erc6900/reference-implementation/interfaces/IModularAccount.sol";
5
+ import {IModularAccount, Call} from "@erc6900/reference-implementation/interfaces/IModularAccount.sol";
6
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7
+ import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
6
8
  import {IMembership} from "../../../spaces/facets/membership/IMembership.sol";
7
9
  import {IBanning} from "../../../spaces/facets/banning/IBanning.sol";
8
- import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
9
10
  import {ISubscriptionModule, ISubscriptionModuleBase} from "./ISubscriptionModule.sol";
10
11
 
11
12
  // libraries
12
- import {LibCall} from "solady/utils/LibCall.sol";
13
13
  import {ValidationLocatorLib} from "modular-account/src/libraries/ValidationLocatorLib.sol";
14
+ import {EnumerableSetLib} from "solady/utils/EnumerableSetLib.sol";
15
+ import {LibCall} from "solady/utils/LibCall.sol";
14
16
  import {SafeCastLib} from "solady/utils/SafeCastLib.sol";
17
+ import {CurrencyTransfer} from "../../../utils/libraries/CurrencyTransfer.sol";
15
18
  import {CustomRevert} from "../../../utils/libraries/CustomRevert.sol";
16
19
  import {Validator} from "../../../utils/libraries/Validator.sol";
17
20
  import {Subscription, SubscriptionModuleStorage} from "./SubscriptionModuleStorage.sol";
18
- import {EnumerableSetLib} from "solady/utils/EnumerableSetLib.sol";
19
21
 
20
22
  /// @title Subscription Module Base
21
23
  /// @notice Base contract with internal logic for subscription management
@@ -34,7 +36,8 @@ abstract contract SubscriptionModuleBase is ISubscriptionModuleBase {
34
36
  NOT_OWNER,
35
37
  RENEWAL_PRICE_CHANGED,
36
38
  INSUFFICIENT_BALANCE,
37
- DURATION_CHANGED
39
+ DURATION_CHANGED,
40
+ CURRENCY_CHANGED
38
41
  }
39
42
 
40
43
  uint256 public constant GRACE_PERIOD = 3 days;
@@ -72,6 +75,7 @@ abstract contract SubscriptionModuleBase is ISubscriptionModuleBase {
72
75
  sub.lastKnownRenewalPrice = membershipFacet.getMembershipRenewalPrice(sub.tokenId);
73
76
  sub.lastKnownExpiresAt = expiresAt;
74
77
  sub.duration = duration;
78
+ sub.lastKnownCurrency = membershipFacet.getMembershipCurrency();
75
79
  sub.nextRenewalTime = _calculateBaseRenewalTime(expiresAt, duration);
76
80
  }
77
81
 
@@ -84,19 +88,33 @@ abstract contract SubscriptionModuleBase is ISubscriptionModuleBase {
84
88
  IMembership membershipFacet,
85
89
  uint256 actualRenewalPrice
86
90
  ) internal {
91
+ address currency = membershipFacet.getMembershipCurrency();
87
92
  // Construct the renewal call to space contract
88
93
  bytes memory renewalCall = abi.encodeCall(IMembership.renewMembership, (sub.tokenId));
89
94
 
90
95
  // Create the data parameter for executeWithRuntimeValidation
91
- // This should be an execute() call to the space contract
92
- bytes memory executeData = abi.encodeCall(
93
- IModularAccount.execute,
94
- (
95
- sub.space, // target
96
- actualRenewalPrice, // value
97
- renewalCall // data
98
- )
99
- );
96
+ bytes memory executeData;
97
+ if (currency == CurrencyTransfer.NATIVE_TOKEN) {
98
+ // ETH path: single execute with value
99
+ executeData = abi.encodeCall(
100
+ IModularAccount.execute,
101
+ (
102
+ sub.space, // target
103
+ actualRenewalPrice, // value
104
+ renewalCall // data
105
+ )
106
+ );
107
+ } else {
108
+ // ERC20 path: batch execute (approve + renewMembership)
109
+ Call[] memory calls = new Call[](2);
110
+ calls[0] = Call({
111
+ target: currency,
112
+ value: 0,
113
+ data: abi.encodeCall(IERC20.approve, (sub.space, actualRenewalPrice))
114
+ });
115
+ calls[1] = Call({target: sub.space, value: 0, data: renewalCall});
116
+ executeData = abi.encodeCall(IModularAccount.executeBatch, (calls));
117
+ }
100
118
 
101
119
  // Use the proper pack function from ValidationLocatorLib
102
120
  bytes memory authorization = ValidationLocatorLib.packSignature(
@@ -228,8 +246,14 @@ abstract contract SubscriptionModuleBase is ISubscriptionModuleBase {
228
246
  return (true, true, SkipReason.RENEWAL_PRICE_CHANGED);
229
247
  }
230
248
 
231
- // Check if account has sufficient balance
232
- if (account.balance < actualRenewalPrice) {
249
+ // Check if currency changed
250
+ address actualCurrency = IMembership(sub.space).getMembershipCurrency();
251
+ if (sub.lastKnownCurrency != actualCurrency) {
252
+ return (true, true, SkipReason.CURRENCY_CHANGED);
253
+ }
254
+
255
+ // Check if account has sufficient balance (ETH or ERC20)
256
+ if (CurrencyTransfer.balanceOf(actualCurrency, account) < actualRenewalPrice) {
233
257
  return (true, true, SkipReason.INSUFFICIENT_BALANCE);
234
258
  }
235
259
 
@@ -253,6 +277,7 @@ abstract contract SubscriptionModuleBase is ISubscriptionModuleBase {
253
277
  if (reason == SkipReason.RENEWAL_PRICE_CHANGED) return "RENEWAL_PRICE_CHANGED";
254
278
  if (reason == SkipReason.INSUFFICIENT_BALANCE) return "INSUFFICIENT_BALANCE";
255
279
  if (reason == SkipReason.DURATION_CHANGED) return "DURATION_CHANGED";
280
+ if (reason == SkipReason.CURRENCY_CHANGED) return "CURRENCY_CHANGED";
256
281
  return "";
257
282
  }
258
283
 
@@ -6,19 +6,19 @@ import {IModule} from "@erc6900/reference-implementation/interfaces/IModule.sol"
6
6
  import {IValidationHookModule} from "@erc6900/reference-implementation/interfaces/IValidationHookModule.sol";
7
7
  import {IValidationModule} from "@erc6900/reference-implementation/interfaces/IValidationModule.sol";
8
8
  import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
9
- import {ISubscriptionModule} from "./ISubscriptionModule.sol";
10
9
  import {IMembership} from "../../../spaces/facets/membership/IMembership.sol";
11
10
  import {IBanning} from "../../../spaces/facets/banning/IBanning.sol";
11
+ import {ISubscriptionModule} from "./ISubscriptionModule.sol";
12
12
 
13
13
  // libraries
14
14
  import {PackedUserOperation} from "@eth-infinitism/account-abstraction/interfaces/PackedUserOperation.sol";
15
15
  import {EnumerableSetLib} from "solady/utils/EnumerableSetLib.sol";
16
16
  import {ReentrancyGuardTransient} from "solady/utils/ReentrancyGuardTransient.sol";
17
+ import {SafeCastLib} from "solady/utils/SafeCastLib.sol";
17
18
  import {CustomRevert} from "../../../utils/libraries/CustomRevert.sol";
18
19
  import {Validator} from "../../../utils/libraries/Validator.sol";
19
20
  import {IArchitect} from "../../../factory/facets/architect/IArchitect.sol";
20
21
  import {Subscription, SubscriptionModuleStorage} from "./SubscriptionModuleStorage.sol";
21
- import {SafeCastLib} from "solady/utils/SafeCastLib.sol";
22
22
 
23
23
  // contracts
24
24
  import {ModuleBase} from "modular-account/src/modules/ModuleBase.sol";
@@ -38,10 +38,10 @@ contract SubscriptionModuleFacet is
38
38
  SubscriptionModuleBase,
39
39
  Facet
40
40
  {
41
- using EnumerableSetLib for EnumerableSetLib.Uint256Set;
42
41
  using EnumerableSetLib for EnumerableSetLib.AddressSet;
43
- using SafeCastLib for uint256;
42
+ using EnumerableSetLib for EnumerableSetLib.Uint256Set;
44
43
  using CustomRevert for bytes4;
44
+ using SafeCastLib for uint256;
45
45
 
46
46
  uint256 internal constant _SIG_VALIDATION_FAILED = 1;
47
47
 
@@ -54,14 +54,34 @@ contract SubscriptionModuleFacet is
54
54
  }
55
55
 
56
56
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
57
- /* External */
57
+ /* ADMIN FUNCTIONS */
58
58
  /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
59
59
 
60
- /// @inheritdoc IModule
61
- function moduleId() external pure returns (string memory) {
62
- return "towns.subscription-module.1.0.0";
60
+ /// @inheritdoc ISubscriptionModule
61
+ function setSpaceFactory(address spaceFactory) external onlyOwner {
62
+ Validator.checkAddress(spaceFactory);
63
+ SubscriptionModuleStorage.getLayout().spaceFactory = spaceFactory;
64
+ emit SpaceFactoryChanged(spaceFactory);
63
65
  }
64
66
 
67
+ /// @inheritdoc ISubscriptionModule
68
+ function grantOperator(address operator) external onlyOwner {
69
+ Validator.checkAddress(operator);
70
+ SubscriptionModuleStorage.getLayout().operators.add(operator);
71
+ emit OperatorGranted(operator);
72
+ }
73
+
74
+ /// @inheritdoc ISubscriptionModule
75
+ function revokeOperator(address operator) external onlyOwner {
76
+ Validator.checkAddress(operator);
77
+ SubscriptionModuleStorage.getLayout().operators.remove(operator);
78
+ emit OperatorRevoked(operator);
79
+ }
80
+
81
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
82
+ /* MODULE LIFECYCLE */
83
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
84
+
65
85
  /// @inheritdoc IModule
66
86
  function onInstall(bytes calldata data) external override nonReentrant {
67
87
  (uint32 entityId, address space, uint256 tokenId) = abi.decode(
@@ -135,69 +155,9 @@ contract SubscriptionModuleFacet is
135
155
  emit SubscriptionDeactivated(msg.sender, entityId);
136
156
  }
137
157
 
138
- /// @inheritdoc IValidationModule
139
- function validateUserOp(
140
- uint32,
141
- PackedUserOperation calldata,
142
- bytes32
143
- ) external pure override returns (uint256) {
144
- return _SIG_VALIDATION_FAILED;
145
- }
146
-
147
- /// @inheritdoc IValidationModule
148
- function validateSignature(
149
- address,
150
- uint32,
151
- address,
152
- bytes32,
153
- bytes calldata
154
- ) external pure override returns (bytes4) {
155
- return 0xffffffff;
156
- }
157
-
158
- /// @inheritdoc IValidationModule
159
- function validateRuntime(
160
- address account,
161
- uint32 entityId,
162
- address sender,
163
- uint256,
164
- bytes calldata,
165
- bytes calldata
166
- ) external view override {
167
- if (sender != address(this)) SubscriptionModule__InvalidSender.selector.revertWith();
168
- bool active = SubscriptionModuleStorage.getLayout().subscriptions[account][entityId].active;
169
- if (!active) SubscriptionModule__InactiveSubscription.selector.revertWith();
170
- }
171
-
172
- /// @inheritdoc IValidationHookModule
173
- function preUserOpValidationHook(
174
- uint32 /* entityId */,
175
- PackedUserOperation calldata /* userOp */,
176
- bytes32 /* userOpHash */
177
- ) external pure override returns (uint256) {
178
- return _SIG_VALIDATION_FAILED;
179
- }
180
-
181
- /// @inheritdoc IValidationHookModule
182
- function preRuntimeValidationHook(
183
- uint32 /* entityId */,
184
- address /* sender */,
185
- uint256 /* value */,
186
- bytes calldata /* data */,
187
- bytes calldata /* authorization */
188
- ) external pure override {
189
- SubscriptionModule__NotSupported.selector.revertWith();
190
- }
191
-
192
- /// @inheritdoc IValidationHookModule
193
- function preSignatureValidationHook(
194
- uint32 /* entityId */,
195
- address /* sender */,
196
- bytes32 /* hash */,
197
- bytes calldata /* signature */
198
- ) external pure override {
199
- SubscriptionModule__NotSupported.selector.revertWith();
200
- }
158
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
159
+ /* STATE-CHANGING FUNCTIONS */
160
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
201
161
 
202
162
  /// @inheritdoc ISubscriptionModule
203
163
  function batchProcessRenewals(RenewalParams[] calldata params) external nonReentrant {
@@ -257,19 +217,6 @@ contract SubscriptionModuleFacet is
257
217
  }
258
218
  }
259
219
 
260
- /// @inheritdoc ISubscriptionModule
261
- function getSubscription(
262
- address account,
263
- uint32 entityId
264
- ) external view returns (Subscription memory) {
265
- return SubscriptionModuleStorage.getLayout().subscriptions[account][entityId];
266
- }
267
-
268
- /// @inheritdoc ISubscriptionModule
269
- function getRenewalBuffer(uint256 duration) external pure returns (uint256) {
270
- return _getRenewalBuffer(duration);
271
- }
272
-
273
220
  /// @inheritdoc ISubscriptionModule
274
221
  function activateSubscription(uint32 entityId) external nonReentrant {
275
222
  SubscriptionModuleStorage.Layout storage $ = SubscriptionModuleStorage.getLayout();
@@ -304,16 +251,26 @@ contract SubscriptionModuleFacet is
304
251
  _pauseSubscription(sub, msg.sender, entityId);
305
252
  }
306
253
 
254
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
255
+ /* GETTERS */
256
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
257
+
307
258
  /// @inheritdoc ISubscriptionModule
308
- function getEntityIds(address account) external view returns (uint256[] memory) {
309
- return SubscriptionModuleStorage.getLayout().entityIds[account].values();
259
+ function getSpaceFactory() external view returns (address) {
260
+ return SubscriptionModuleStorage.getLayout().spaceFactory;
310
261
  }
311
262
 
312
263
  /// @inheritdoc ISubscriptionModule
313
- function grantOperator(address operator) external onlyOwner {
314
- Validator.checkAddress(operator);
315
- SubscriptionModuleStorage.getLayout().operators.add(operator);
316
- emit OperatorGranted(operator);
264
+ function getSubscription(
265
+ address account,
266
+ uint32 entityId
267
+ ) external view returns (Subscription memory) {
268
+ return SubscriptionModuleStorage.getLayout().subscriptions[account][entityId];
269
+ }
270
+
271
+ /// @inheritdoc ISubscriptionModule
272
+ function getEntityIds(address account) external view returns (uint256[] memory) {
273
+ return SubscriptionModuleStorage.getLayout().entityIds[account].values();
317
274
  }
318
275
 
319
276
  /// @inheritdoc ISubscriptionModule
@@ -321,26 +278,77 @@ contract SubscriptionModuleFacet is
321
278
  return SubscriptionModuleStorage.getLayout().operators.contains(operator);
322
279
  }
323
280
 
324
- /// @inheritdoc ISubscriptionModule
325
- function revokeOperator(address operator) external onlyOwner {
326
- Validator.checkAddress(operator);
327
- SubscriptionModuleStorage.getLayout().operators.remove(operator);
328
- emit OperatorRevoked(operator);
281
+ /// @inheritdoc IValidationModule
282
+ function validateRuntime(
283
+ address account,
284
+ uint32 entityId,
285
+ address sender,
286
+ uint256,
287
+ bytes calldata,
288
+ bytes calldata
289
+ ) external view override {
290
+ if (sender != address(this)) SubscriptionModule__InvalidSender.selector.revertWith();
291
+ bool active = SubscriptionModuleStorage.getLayout().subscriptions[account][entityId].active;
292
+ if (!active) SubscriptionModule__InactiveSubscription.selector.revertWith();
329
293
  }
330
294
 
331
- /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
332
- /* SPACE FACTORY */
333
- /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
295
+ /// @inheritdoc IModule
296
+ function moduleId() external pure returns (string memory) {
297
+ return "towns.subscription-module.1.0.0";
298
+ }
334
299
 
335
300
  /// @inheritdoc ISubscriptionModule
336
- function setSpaceFactory(address spaceFactory) external onlyOwner {
337
- Validator.checkAddress(spaceFactory);
338
- SubscriptionModuleStorage.getLayout().spaceFactory = spaceFactory;
339
- emit SpaceFactoryChanged(spaceFactory);
301
+ function getRenewalBuffer(uint256 duration) external pure returns (uint256) {
302
+ return _getRenewalBuffer(duration);
340
303
  }
341
304
 
342
- /// @inheritdoc ISubscriptionModule
343
- function getSpaceFactory() external view returns (address) {
344
- return SubscriptionModuleStorage.getLayout().spaceFactory;
305
+ /// @inheritdoc IValidationModule
306
+ function validateUserOp(
307
+ uint32,
308
+ PackedUserOperation calldata,
309
+ bytes32
310
+ ) external pure override returns (uint256) {
311
+ return _SIG_VALIDATION_FAILED;
312
+ }
313
+
314
+ /// @inheritdoc IValidationModule
315
+ function validateSignature(
316
+ address,
317
+ uint32,
318
+ address,
319
+ bytes32,
320
+ bytes calldata
321
+ ) external pure override returns (bytes4) {
322
+ return 0xffffffff;
323
+ }
324
+
325
+ /// @inheritdoc IValidationHookModule
326
+ function preUserOpValidationHook(
327
+ uint32 /* entityId */,
328
+ PackedUserOperation calldata /* userOp */,
329
+ bytes32 /* userOpHash */
330
+ ) external pure override returns (uint256) {
331
+ return _SIG_VALIDATION_FAILED;
332
+ }
333
+
334
+ /// @inheritdoc IValidationHookModule
335
+ function preRuntimeValidationHook(
336
+ uint32 /* entityId */,
337
+ address /* sender */,
338
+ uint256 /* value */,
339
+ bytes calldata /* data */,
340
+ bytes calldata /* authorization */
341
+ ) external pure override {
342
+ SubscriptionModule__NotSupported.selector.revertWith();
343
+ }
344
+
345
+ /// @inheritdoc IValidationHookModule
346
+ function preSignatureValidationHook(
347
+ uint32 /* entityId */,
348
+ address /* sender */,
349
+ bytes32 /* hash */,
350
+ bytes calldata /* signature */
351
+ ) external pure override {
352
+ SubscriptionModule__NotSupported.selector.revertWith();
345
353
  }
346
354
  }
@@ -14,6 +14,7 @@ struct Subscription {
14
14
  uint64 duration; // 8 bytes
15
15
  uint256 lastKnownRenewalPrice; // 32 bytes
16
16
  uint256 lastKnownExpiresAt; // 32 bytes
17
+ address lastKnownCurrency; // 20 bytes - currency at install/sync time
17
18
  }
18
19
 
19
20
  struct OperatorConfig {
@@ -121,17 +121,36 @@ contract EntitlementChecker is IEntitlementChecker, Facet {
121
121
  function requestEntitlementCheck(CheckType checkType, bytes calldata data) external payable {
122
122
  if (checkType == CheckType.V1) {
123
123
  if (msg.value != 0) EntitlementChecker_InvalidValue.selector.revertWith();
124
- (address receiver, bytes32 transactionId, uint256 roleId, address[] memory nodes) = abi
125
- .decode(data, (address, bytes32, uint256, address[]));
124
+ // equivalent: abi.decode(data, (address, bytes32, uint256, address[]))
125
+ address receiver;
126
+ bytes32 transactionId;
127
+ uint256 roleId;
128
+ address[] calldata nodes;
129
+ assembly {
130
+ receiver := shr(96, shl(96, calldataload(data.offset)))
131
+ transactionId := calldataload(add(data.offset, 0x20))
132
+ roleId := calldataload(add(data.offset, 0x40))
133
+ // nodes is dynamic: offset at 0x60, array starts at data.offset + offset
134
+ let nodesPtr := add(data.offset, calldataload(add(data.offset, 0x60)))
135
+ nodes.length := calldataload(nodesPtr)
136
+ nodes.offset := add(nodesPtr, 0x20)
137
+ }
126
138
  emit EntitlementCheckRequested(receiver, msg.sender, transactionId, roleId, nodes);
127
139
  } else if (checkType == CheckType.V2) {
128
- (
129
- address receiver,
130
- bytes32 transactionId,
131
- uint256 requestId,
132
- bytes memory extraData
133
- ) = abi.decode(data, (address, bytes32, uint256, bytes));
134
- address sender = abi.decode(extraData, (address));
140
+ // equivalent: abi.decode(data, (address, bytes32, uint256, bytes))
141
+ // extraData contains: (address sender)
142
+ address receiver;
143
+ bytes32 transactionId;
144
+ uint256 requestId;
145
+ address sender;
146
+ assembly {
147
+ receiver := shr(96, shl(96, calldataload(data.offset)))
148
+ transactionId := calldataload(add(data.offset, 0x20))
149
+ requestId := calldataload(add(data.offset, 0x40))
150
+ // extraData offset at 0x60, sender is first word after length
151
+ let extraDataPtr := add(data.offset, calldataload(add(data.offset, 0x60)))
152
+ sender := shr(96, shl(96, calldataload(add(extraDataPtr, 0x20))))
153
+ }
135
154
  _requestEntitlementCheck(
136
155
  receiver,
137
156
  transactionId,
@@ -141,14 +160,21 @@ contract EntitlementChecker is IEntitlementChecker, Facet {
141
160
  sender
142
161
  );
143
162
  } else if (checkType == CheckType.V3) {
144
- (
145
- address receiver,
146
- bytes32 transactionId,
147
- uint256 requestId,
148
- address currency,
149
- uint256 amount,
150
- address sender
151
- ) = abi.decode(data, (address, bytes32, uint256, address, uint256, address));
163
+ // equivalent: abi.decode(data, (address, bytes32, uint256, address, uint256, address))
164
+ address receiver;
165
+ bytes32 transactionId;
166
+ uint256 requestId;
167
+ address currency;
168
+ uint256 amount;
169
+ address sender;
170
+ assembly {
171
+ receiver := shr(96, shl(96, calldataload(data.offset)))
172
+ transactionId := calldataload(add(data.offset, 0x20))
173
+ requestId := calldataload(add(data.offset, 0x40))
174
+ currency := shr(96, shl(96, calldataload(add(data.offset, 0x60))))
175
+ amount := calldataload(add(data.offset, 0x80))
176
+ sender := shr(96, shl(96, calldataload(add(data.offset, 0xa0))))
177
+ }
152
178
  _requestEntitlementCheck(receiver, transactionId, requestId, currency, amount, sender);
153
179
  } else {
154
180
  EntitlementChecker_InvalidCheckType.selector.revertWith();
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.23;
3
+
4
+ import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";
5
+ import {IFeeManager} from "../../factory/facets/fee/IFeeManager.sol";
6
+ import {CurrencyTransfer} from "../../utils/libraries/CurrencyTransfer.sol";
7
+
8
+ library ProtocolFeeLib {
9
+ using SafeTransferLib for address;
10
+
11
+ /// @notice Charges protocol fee via FeeManager with ERC20 approval handling
12
+ /// @param spaceFactory The FeeManager address
13
+ /// @param feeType The type of fee being charged
14
+ /// @param user The user paying the fee
15
+ /// @param currency The payment currency (NATIVE_TOKEN or ERC20)
16
+ /// @param amount The base amount for fee calculation
17
+ /// @param expectedFee The pre-calculated expected fee
18
+ /// @return protocolFee The actual fee charged
19
+ function charge(
20
+ address spaceFactory,
21
+ bytes32 feeType,
22
+ address user,
23
+ address currency,
24
+ uint256 amount,
25
+ uint256 expectedFee
26
+ ) internal returns (uint256 protocolFee) {
27
+ if (expectedFee == 0) return 0;
28
+
29
+ bool isNative = currency == CurrencyTransfer.NATIVE_TOKEN;
30
+ if (!isNative) currency.safeApproveWithRetry(spaceFactory, expectedFee);
31
+
32
+ protocolFee = IFeeManager(spaceFactory).chargeFee{value: isNative ? expectedFee : 0}(
33
+ feeType,
34
+ user,
35
+ amount,
36
+ currency,
37
+ expectedFee,
38
+ ""
39
+ );
40
+
41
+ if (!isNative) currency.safeApprove(spaceFactory, 0);
42
+ }
43
+ }
@@ -75,6 +75,11 @@ interface IMembershipBase {
75
75
  event MembershipFreeAllocationUpdated(uint256 indexed allocation);
76
76
  event MembershipWithdrawal(address indexed currency, address indexed recipient, uint256 amount);
77
77
  event MembershipTokenIssued(address indexed recipient, uint256 indexed tokenId);
78
+ /// @notice Emitted when a membership payment is processed (new membership or renewal)
79
+ /// @param currency The currency used for payment (0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE for ETH)
80
+ /// @param price The base membership price paid
81
+ /// @param protocolFee The protocol fee paid
82
+ event MembershipPaid(address indexed currency, uint256 price, uint256 protocolFee);
78
83
  event MembershipTokenRejected(address indexed recipient);
79
84
  }
80
85
 
@@ -97,7 +102,7 @@ interface IMembership is IMembershipBase {
97
102
  /// @param referral The referral data
98
103
  function joinSpaceWithReferral(
99
104
  address receiver,
100
- ReferralTypes memory referral
105
+ ReferralTypes calldata referral
101
106
  ) external payable;
102
107
 
103
108
  /// @notice Renew a space membership
@@ -2,8 +2,8 @@
2
2
  pragma solidity ^0.8.23;
3
3
 
4
4
  // interfaces
5
- import {IMembership} from "./IMembership.sol";
6
5
  import {IMembershipPricing} from "./pricing/IMembershipPricing.sol";
6
+ import {IMembership} from "./IMembership.sol";
7
7
 
8
8
  // libraries
9
9
  import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";
@@ -26,13 +26,22 @@ contract MembershipFacet is IMembership, MembershipJoin, ReentrancyGuard, Facet
26
26
  /// @inheritdoc IMembership
27
27
  function joinSpace(JoinType action, bytes calldata data) external payable nonReentrant {
28
28
  if (action == JoinType.Basic) {
29
- address receiver = abi.decode(data, (address));
29
+ // equivalent: abi.decode(data, (address))
30
+ address receiver;
31
+ assembly {
32
+ receiver := shr(96, shl(96, calldataload(data.offset)))
33
+ }
30
34
  _joinSpace(receiver);
31
35
  } else if (action == JoinType.WithReferral) {
32
- (address receiver, ReferralTypes memory referral) = abi.decode(
33
- data,
34
- (address, ReferralTypes)
35
- );
36
+ // equivalent: abi.decode(data, (address, ReferralTypes))
37
+ address receiver;
38
+ ReferralTypes calldata referral;
39
+ assembly {
40
+ receiver := shr(96, shl(96, calldataload(data.offset)))
41
+ // this is a variable length struct, so (data.offset + 0x20) contains
42
+ // the offset from data.offset at which the struct begins
43
+ referral := add(data.offset, calldataload(add(data.offset, 0x20)))
44
+ }
36
45
  _joinSpaceWithReferral(receiver, referral);
37
46
  } else {
38
47
  Membership__InvalidAction.selector.revertWith();
@@ -47,7 +56,7 @@ contract MembershipFacet is IMembership, MembershipJoin, ReentrancyGuard, Facet
47
56
  /// @inheritdoc IMembership
48
57
  function joinSpaceWithReferral(
49
58
  address receiver,
50
- ReferralTypes memory referral
59
+ ReferralTypes calldata referral
51
60
  ) external payable nonReentrant {
52
61
  _joinSpaceWithReferral(receiver, referral);
53
62
  }
@@ -16,6 +16,7 @@ import {CurrencyTransfer} from "../../../../utils/libraries/CurrencyTransfer.sol
16
16
  import {CustomRevert} from "../../../../utils/libraries/CustomRevert.sol";
17
17
  import {MembershipStorage} from "../MembershipStorage.sol";
18
18
  import {Permissions} from "../../Permissions.sol";
19
+ import {ProtocolFeeLib} from "../../ProtocolFeeLib.sol";
19
20
 
20
21
  // contracts
21
22
  import {ERC5643Base} from "../../../../diamond/facets/token/ERC5643/ERC5643Base.sol";
@@ -67,6 +68,7 @@ abstract contract MembershipJoin is
67
68
 
68
69
  struct PricingDetails {
69
70
  uint256 basePrice;
71
+ uint256 protocolFee;
70
72
  uint256 amountDue;
71
73
  bool shouldCharge;
72
74
  }
@@ -100,8 +102,12 @@ abstract contract MembershipJoin is
100
102
  return joinDetails;
101
103
  }
102
104
 
103
- (uint256 totalRequired, ) = _getTotalMembershipPayment(membershipPrice);
104
- (joinDetails.amountDue, joinDetails.shouldCharge) = (totalRequired, true);
105
+ (uint256 totalRequired, uint256 protocolFee) = _getTotalMembershipPayment(membershipPrice);
106
+ (joinDetails.protocolFee, joinDetails.amountDue, joinDetails.shouldCharge) = (
107
+ protocolFee,
108
+ totalRequired,
109
+ true
110
+ );
105
111
  }
106
112
 
107
113
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
@@ -150,7 +156,7 @@ abstract contract MembershipJoin is
150
156
  /// @notice Handles the process of joining a space with a referral
151
157
  /// @param receiver The address that will receive the membership token
152
158
  /// @param referral The referral information
153
- function _joinSpaceWithReferral(address receiver, ReferralTypes memory referral) internal {
159
+ function _joinSpaceWithReferral(address receiver, ReferralTypes calldata referral) internal {
154
160
  _validateJoinSpace(receiver);
155
161
 
156
162
  PricingDetails memory joinDetails = _getPricingDetails();
@@ -229,7 +235,10 @@ abstract contract MembershipJoin is
229
235
  return amountRequired;
230
236
  }
231
237
 
232
- function _validateUserReferral(address receiver, ReferralTypes memory referral) internal view {
238
+ function _validateUserReferral(
239
+ address receiver,
240
+ ReferralTypes calldata referral
241
+ ) internal view {
233
242
  if (referral.userReferral == receiver || referral.userReferral == msg.sender) {
234
243
  Membership__InvalidAddress.selector.revertWith();
235
244
  }
@@ -341,7 +350,10 @@ abstract contract MembershipJoin is
341
350
  Membership__InvalidTransactionType.selector.revertWith();
342
351
  }
343
352
 
344
- _payProtocolFee(_getMembershipCurrency(), joinDetails.basePrice);
353
+ address currency = _getMembershipCurrency();
354
+ _payProtocolFee(currency, joinDetails.basePrice, joinDetails.protocolFee);
355
+
356
+ emit MembershipPaid(currency, joinDetails.basePrice, joinDetails.protocolFee);
345
357
 
346
358
  _afterChargeForJoinSpace(transactionId, receiver, joinDetails.amountDue);
347
359
  }
@@ -365,7 +377,7 @@ abstract contract MembershipJoin is
365
377
  ReferralTypes memory referral = abi.decode(referralData, (ReferralTypes));
366
378
 
367
379
  address currency = _getMembershipCurrency();
368
- _payProtocolFee(currency, joinDetails.basePrice);
380
+ _payProtocolFee(currency, joinDetails.basePrice, joinDetails.protocolFee);
369
381
  _payPartnerFee(currency, referral.partner, joinDetails.basePrice);
370
382
  _payReferralFee(
371
383
  currency,
@@ -375,6 +387,8 @@ abstract contract MembershipJoin is
375
387
  joinDetails.basePrice
376
388
  );
377
389
 
390
+ emit MembershipPaid(currency, joinDetails.basePrice, joinDetails.protocolFee);
391
+
378
392
  _afterChargeForJoinSpace(transactionId, receiver, joinDetails.amountDue);
379
393
  }
380
394
 
@@ -410,7 +424,6 @@ abstract contract MembershipJoin is
410
424
  // set expiration of membership
411
425
  _renewSubscription(tokenId, _getMembershipDuration());
412
426
 
413
- // emit event
414
427
  emit MembershipTokenIssued(receiver, tokenId);
415
428
  }
416
429
 
@@ -447,21 +460,18 @@ abstract contract MembershipJoin is
447
460
  /* FEE DISTRIBUTION */
448
461
  /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
449
462
 
450
- /// @notice Pays the protocol fee to the platform fee recipient
463
+ /// @notice Pays the protocol fee via FeeManager
451
464
  /// @param currency The currency to pay in
452
- /// @param membershipPrice The price of the membership
453
- /// @return protocolFee The amount of protocol fee paid
454
- function _payProtocolFee(
455
- address currency,
456
- uint256 membershipPrice
457
- ) internal returns (uint256 protocolFee) {
458
- protocolFee = _getProtocolFee(membershipPrice);
459
-
460
- CurrencyTransfer.transferCurrency(
465
+ /// @param basePrice The base price of the membership (for fee calculation)
466
+ /// @param expectedFee The pre-calculated protocol fee
467
+ function _payProtocolFee(address currency, uint256 basePrice, uint256 expectedFee) internal {
468
+ ProtocolFeeLib.charge(
469
+ _getSpaceFactory(),
470
+ _getMembershipFeeType(currency),
471
+ msg.sender,
461
472
  currency,
462
- address(this),
463
- _getPlatformRequirements().getFeeRecipient(),
464
- protocolFee
473
+ basePrice,
474
+ expectedFee
465
475
  );
466
476
  }
467
477
 
@@ -542,13 +552,13 @@ abstract contract MembershipJoin is
542
552
  return;
543
553
  }
544
554
 
545
- (uint256 totalRequired, ) = _getTotalMembershipPayment(basePrice);
555
+ (uint256 totalRequired, uint256 protocolFee) = _getTotalMembershipPayment(basePrice);
546
556
 
547
557
  if (currency == CurrencyTransfer.NATIVE_TOKEN) {
548
558
  // ETH payment: validate msg.value
549
559
  if (totalRequired > msg.value) Membership__InvalidPayment.selector.revertWith();
550
560
 
551
- _payProtocolFee(currency, basePrice);
561
+ _payProtocolFee(currency, basePrice, protocolFee);
552
562
 
553
563
  // Handle excess payment
554
564
  uint256 excess = msg.value - totalRequired;
@@ -562,9 +572,10 @@ abstract contract MembershipJoin is
562
572
  // Transfer ERC20 from payer to contract
563
573
  _transferIn(currency, payer, totalRequired);
564
574
 
565
- _payProtocolFee(currency, basePrice);
575
+ _payProtocolFee(currency, basePrice, protocolFee);
566
576
  }
567
577
 
578
+ emit MembershipPaid(currency, basePrice, protocolFee);
568
579
  _mintMembershipPoints(receiver, totalRequired);
569
580
  _renewSubscription(tokenId, uint64(duration));
570
581
  }
@@ -13,8 +13,7 @@ import {CurrencyTransfer} from "../../../utils/libraries/CurrencyTransfer.sol";
13
13
  import {CustomRevert} from "../../../utils/libraries/CustomRevert.sol";
14
14
  import {MembershipStorage} from "../membership/MembershipStorage.sol";
15
15
  import {TippingStorage} from "./TippingStorage.sol";
16
- import {BasisPoints} from "../../../utils/libraries/BasisPoints.sol";
17
- import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";
16
+ import {ProtocolFeeLib} from "../ProtocolFeeLib.sol";
18
17
 
19
18
  // contracts
20
19
  import {PointsBase} from "../points/PointsBase.sol";
@@ -23,8 +22,6 @@ abstract contract TippingBase is ITippingBase, PointsBase {
23
22
  using EnumerableSet for EnumerableSet.AddressSet;
24
23
  using CustomRevert for bytes4;
25
24
 
26
- uint256 internal constant MAX_FEE_TOLERANCE = 100; // 1% tolerance
27
-
28
25
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
29
26
  /* Internal Functions */
30
27
  /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
@@ -205,41 +202,28 @@ abstract contract TippingBase is ITippingBase, PointsBase {
205
202
  address currency,
206
203
  uint256 amount
207
204
  ) internal returns (uint256 protocolFee) {
208
- MembershipStorage.Layout storage ds = MembershipStorage.layout();
209
- address spaceFactory = ds.spaceFactory;
205
+ address spaceFactory = MembershipStorage.layout().spaceFactory;
210
206
 
211
- // Calculate expected fee
212
- uint256 expectedFee = IFeeManager(spaceFactory).calculateFee(
207
+ // Calculate fee first
208
+ protocolFee = IFeeManager(spaceFactory).calculateFee(
213
209
  FeeTypesLib.TIP_MEMBER,
214
210
  msg.sender,
215
211
  amount,
216
212
  ""
217
213
  );
218
214
 
219
- if (expectedFee == 0) return 0;
220
-
221
- // Add slippage tolerance (1%)
222
- uint256 maxFee = expectedFee + BasisPoints.calculate(expectedFee, MAX_FEE_TOLERANCE);
223
-
224
- // Approve ERC20 if needed (native token sends value with call)
225
- bool isNative = currency == CurrencyTransfer.NATIVE_TOKEN;
226
- if (!isNative) SafeTransferLib.safeApproveWithRetry(currency, spaceFactory, maxFee);
227
-
228
- // Charge fee (excess native token will be refunded)
229
- protocolFee = IFeeManager(spaceFactory).chargeFee{value: isNative ? maxFee : 0}(
215
+ // Charge with pre-calculated fee
216
+ protocolFee = ProtocolFeeLib.charge(
217
+ spaceFactory,
230
218
  FeeTypesLib.TIP_MEMBER,
231
219
  msg.sender,
232
- amount,
233
220
  currency,
234
- maxFee,
235
- ""
221
+ amount,
222
+ protocolFee
236
223
  );
237
224
 
238
- // Reset ERC20 approval
239
- if (!isNative) SafeTransferLib.safeApprove(currency, spaceFactory, 0);
240
-
241
225
  // Mint points for fee payment (only for ETH tips)
242
- if (isNative) {
226
+ if (protocolFee > 0 && currency == CurrencyTransfer.NATIVE_TOKEN) {
243
227
  address airdropDiamond = _getAirdropDiamond();
244
228
  uint256 points = _getPoints(
245
229
  airdropDiamond,
@@ -8,8 +8,6 @@ import {IWETH} from "../interfaces/IWETH.sol";
8
8
  import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";
9
9
  import {CustomRevert} from "./CustomRevert.sol";
10
10
 
11
- // contracts
12
-
13
11
  library CurrencyTransfer {
14
12
  using SafeTransferLib for address;
15
13
  using CustomRevert for bytes4;
@@ -124,4 +122,10 @@ library CurrencyTransfer {
124
122
  if (actualFee > 0) safeTransferERC20(currency, payer, recipient, actualFee);
125
123
  }
126
124
  }
125
+
126
+ /// @dev Returns the balance of `account` in `currency`.
127
+ function balanceOf(address currency, address account) internal view returns (uint256) {
128
+ if (currency == NATIVE_TOKEN) return account.balance;
129
+ return currency.balanceOf(account);
130
+ }
127
131
  }