@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
@@ -0,0 +1,237 @@
1
+ # Membership Contract Architecture
2
+
3
+ ## Overview
4
+
5
+ The Towns Protocol membership system manages space memberships through NFT tokens with time-based expiration. It supports flexible pricing models, role-based entitlements (including cross-chain validation), referral systems, and fee distribution. Built on the Diamond pattern (EIP-2535), the system supports both native ETH and ERC20 (e.g., USDC) payments.
6
+
7
+ ## Architecture
8
+
9
+ ### Contract Hierarchy
10
+
11
+ ```
12
+ MembershipFacet (external interface)
13
+ └─ MembershipJoin (join + renewal logic)
14
+ ├─ MembershipBase (pricing, fees, storage)
15
+ ├─ DispatcherBase (transaction capture)
16
+ ├─ EntitlementGatedBase (entitlement checks)
17
+ ├─ ReferralsBase (referral fees)
18
+ ├─ PrepayBase (prepaid memberships)
19
+ ├─ PointsBase (rewards points)
20
+ └─ ERC721ABase (NFT implementation)
21
+
22
+ SpaceEntitlementGated (result handler)
23
+ └─ Overrides _onEntitlementCheckResultPosted()
24
+ ```
25
+
26
+ ### Storage Architecture
27
+
28
+ **Diamond Storage Pattern** - Each facet uses isolated storage:
29
+
30
+ - `MembershipStorage` - pricing, duration, limits, currency
31
+ - `DispatcherStorage` - transactionBalance, transactionData
32
+ - `EntitlementGatedStorage` - crosschain check state
33
+ - `ReferralsStorage` - referral codes and fees
34
+ - `PrepayStorage` - prepaid supply tracking
35
+
36
+ ### Key Facets
37
+
38
+ | Facet | Responsibility |
39
+ |-------|---------------|
40
+ | `MembershipFacet.sol` | External API (join, renew, setters) |
41
+ | `MembershipJoin.sol` | Join logic, payment processing |
42
+ | `MembershipBase.sol` | Pricing, fees, validation |
43
+ | `DispatcherBase.sol` | Transaction capture (ETH/ERC20) |
44
+ | `EntitlementGatedBase.sol` | Entitlement check requests |
45
+ | `SpaceEntitlementGated.sol` | Entitlement result handling |
46
+
47
+ ## Payment Model
48
+
49
+ ### Fee-Added Pricing
50
+
51
+ The system uses a **fee-added** pricing model where protocol fees are added on top of the base price:
52
+
53
+ ```
54
+ Total Price = Base Price + Protocol Fee
55
+
56
+ Example (ETH):
57
+ Base Price: 1.0 ETH → Space owner
58
+ Protocol Fee: 0.1 ETH → Platform
59
+ Total: 1.1 ETH ← User pays this
60
+ ```
61
+
62
+ ### Currency Support
63
+
64
+ Memberships can be priced in:
65
+ - **Native ETH** (`address(0)` or `NATIVE_TOKEN`)
66
+ - **ERC20 tokens** (e.g., USDC) - must be enabled in FeeManager
67
+
68
+ Currency validation happens in `_setMembershipCurrency()` which requires the token to have an enabled fee configuration in FeeManager.
69
+
70
+ ## Core Flow: Join → Entitlement Check → Token
71
+
72
+ ### Happy Path (Local Entitlement)
73
+
74
+ ```
75
+ User: joinSpace(receiver) + payment
76
+
77
+ ├─ Validate: supply limit, payment amount
78
+
79
+ ├─ Register Transaction
80
+ │ └─ Capture payment in transactionBalance[txId]
81
+ │ └─ Store: [selector, sender, receiver, referral]
82
+
83
+ ├─ Check Entitlement (Local)
84
+ │ └─ ✓ User has local entitlement → PASS
85
+
86
+ ├─ Charge for Join
87
+ │ ├─ Protocol fee → Platform
88
+ │ ├─ Partner fee → Partner (if any)
89
+ │ ├─ Referral fee → Referrer (if any)
90
+ │ └─ Base price → Space owner
91
+
92
+ ├─ Refund Excess (if overpaid)
93
+
94
+ └─ Issue Token
95
+ └─ Mint NFT with expiration timestamp
96
+ ```
97
+
98
+ ### Crosschain Entitlement Path
99
+
100
+ ```
101
+ User: joinSpace(receiver) + payment
102
+
103
+ ├─ Validate & Register (same as above)
104
+
105
+ ├─ Check Entitlement
106
+ │ ├─ Local entitlements: NONE
107
+ │ └─ Crosschain entitlements: EXISTS
108
+
109
+ ├─ Request Crosschain Check
110
+ │ └─ Send gas fee to EntitlementChecker
111
+ │ └─ Payment remains locked in transactionBalance[txId]
112
+
113
+ └─ Return (pending state)
114
+
115
+ [Later - Entitlement result posted by checker]
116
+
117
+ EntitlementChecker: postEntitlementCheckResultV2(txId, PASSED)
118
+
119
+ ├─ _onEntitlementCheckResultPosted(txId, PASSED)
120
+ │ ├─ Retrieve: payment = transactionBalance[txId]
121
+ │ ├─ Charge for Join (same fee distribution)
122
+ │ ├─ Refund Excess
123
+ │ └─ Issue Token
124
+
125
+ └─ Delete Transaction
126
+ ```
127
+
128
+ ### Rejection Path
129
+
130
+ ```
131
+ Entitlement Check: FAILED
132
+
133
+ ├─ _rejectMembership(txId, receiver)
134
+ │ ├─ Delete: transactionData[txId]
135
+ │ ├─ Refund: full transactionBalance[txId] → receiver
136
+ │ └─ Emit: MembershipTokenRejected
137
+
138
+ └─ Transaction cleaned up
139
+ ```
140
+
141
+ ## Payment Capture System
142
+
143
+ ### Transaction Registration
144
+
145
+ Generates unique transaction ID and captures payment amount:
146
+
147
+ ```solidity
148
+ // Called in _joinSpace() or _joinSpaceWithReferral()
149
+ bytes32 txId = _registerTransaction(sender, encodedData, capturedAmount);
150
+
151
+ // Inside DispatcherBase:
152
+ // 1. Generate txId from sender + block.number + nonce
153
+ // 2. Store: transactionData[txId] = encodedData
154
+ // 3. Capture: transactionBalance[txId] = capturedAmount
155
+ ```
156
+
157
+ ### Payment Release
158
+
159
+ ```solidity
160
+ // Release consumed amount (after fee distribution)
161
+ _releaseCapturedValue(txId, amountDue)
162
+
163
+ // Refund remaining balance
164
+ _refundBalance(txId, receiver)
165
+ └─ CurrencyTransfer.transferCurrency(currency, address(this), receiver, remaining)
166
+ ```
167
+
168
+ ## Entitlement Check System
169
+
170
+ ### Local vs Crosschain
171
+
172
+ **Local Entitlements**: Immediate validation (same transaction)
173
+ - ERC20/ERC721 ownership checks
174
+ - Token balance thresholds
175
+ - User allowlists
176
+
177
+ **Crosschain Entitlements**: Async validation
178
+ - Checks on other chains (Ethereum L1, Base L2)
179
+ - Requires ETH for gas (sent to EntitlementChecker)
180
+ - Payment held until result posted
181
+
182
+ ### Check Flow
183
+
184
+ ```
185
+ _checkEntitlement(receiver, sender, txId, amountDue)
186
+
187
+ ├─ PHASE 1: Check Local Entitlements
188
+ │ └─ If ANY pass → return (true, false)
189
+
190
+ └─ PHASE 2: Check Crosschain Entitlements
191
+ └─ For each crosschain entitlement:
192
+ ├─ First request: send gas fee
193
+ └─ return (false, true) ← pending state
194
+ ```
195
+
196
+ ## Appendix: Key Functions
197
+
198
+ ### Join Flow Entry Points
199
+
200
+ ```solidity
201
+ // MembershipFacet.sol
202
+ function joinSpace(address receiver) external payable nonReentrant
203
+ function joinSpace(JoinType joinType, bytes calldata data) external payable nonReentrant
204
+ function joinSpaceWithReferral(address receiver, ReferralTypes memory referral)
205
+ external payable nonReentrant
206
+ ```
207
+
208
+ ### Payment Capture
209
+
210
+ ```solidity
211
+ // DispatcherBase.sol
212
+ function _registerTransaction(address sender, bytes memory data, uint256 capturedAmount)
213
+ internal returns (bytes32 transactionId)
214
+ function _getCapturedValue(bytes32 transactionId) internal view returns (uint256)
215
+ function _releaseCapturedValue(bytes32 transactionId, uint256 amount) internal
216
+ ```
217
+
218
+ ### Fee Distribution
219
+
220
+ ```solidity
221
+ // MembershipBase.sol
222
+ function _collectProtocolFee(address payer, uint256 membershipPrice)
223
+ internal returns (uint256 protocolFee)
224
+ function _transferIn(address from, uint256 amount) internal returns (uint256)
225
+ ```
226
+
227
+ ### Entitlement Checking
228
+
229
+ ```solidity
230
+ // MembershipJoin.sol
231
+ function _checkEntitlement(address receiver, address sender, bytes32 txId, uint256 amount)
232
+ internal returns (bool isEntitled, bool isCrosschainPending)
233
+
234
+ // EntitlementGatedBase.sol
235
+ function _requestEntitlementCheckV2(...)
236
+ function _postEntitlementCheckResultV2(bytes32 txId, uint256 roleId, NodeVoteStatus result)
237
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towns-protocol/contracts",
3
- "version": "0.0.442",
3
+ "version": "0.0.444",
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.442",
36
+ "@towns-protocol/prettier-config": "^0.0.444",
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": "d2e07ef0deb899067d0d1a47e30c4edd5bb22547"
53
+ "gitHead": "bed6929e87d5aec54647c2b7fdb8e703401aa99f"
54
54
  }
@@ -223,7 +223,7 @@ contract DeploySpaceFactory is IDiamondInitHelper, DiamondHelper, Deployer {
223
223
  facet,
224
224
  DeployPlatformRequirements.makeInitData(
225
225
  deployer, // feeRecipient
226
- 500, // membershipBps 5%
226
+ 1000, // membershipBps 10%
227
227
  0.0005 ether, // membershipFee
228
228
  1000, // membershipFreeAllocation
229
229
  365 days, // membershipDuration
@@ -354,7 +354,7 @@ contract DeploySpaceFactory is IDiamondInitHelper, DiamondHelper, Deployer {
354
354
  facet,
355
355
  DeployPlatformRequirements.makeInitData(
356
356
  deployer, // feeRecipient
357
- 500, // membershipBps 5%
357
+ 1000, // membershipBps 10%
358
358
  0.0005 ether, // membershipFee
359
359
  1000, // membershipFreeAllocation
360
360
  365 days, // membershipDuration
@@ -13,7 +13,7 @@ library DeployMembership {
13
13
  using DynamicArrayLib for DynamicArrayLib.DynamicArray;
14
14
 
15
15
  function selectors() internal pure returns (bytes4[] memory res) {
16
- DynamicArrayLib.DynamicArray memory arr = DynamicArrayLib.p().reserve(22);
16
+ DynamicArrayLib.DynamicArray memory arr = DynamicArrayLib.p().reserve(23);
17
17
 
18
18
  // Funds
19
19
  arr.p(IMembership.revenue.selector);
@@ -50,6 +50,7 @@ library DeployMembership {
50
50
 
51
51
  // Currency
52
52
  arr.p(IMembership.getMembershipCurrency.selector);
53
+ arr.p(IMembership.setMembershipCurrency.selector);
53
54
 
54
55
  // Image
55
56
  arr.p(IMembership.setMembershipImage.selector);
@@ -16,7 +16,7 @@ contract DeployMockERC20 is Deployer {
16
16
  // address predeterminedAddress = vm.computeCreate2Address(salt, initCodeHash);
17
17
 
18
18
  vm.startBroadcast(deployer);
19
- MockERC20 deployment = new MockERC20("TownsTest", "TToken");
19
+ MockERC20 deployment = new MockERC20("TownsTest", "TToken", 18);
20
20
  vm.stopBroadcast();
21
21
 
22
22
  return address(deployment);
@@ -0,0 +1,19 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.24;
3
+
4
+ import {Deployer} from "scripts/common/Deployer.s.sol";
5
+ import {MockERC20} from "test/mocks/MockERC20.sol";
6
+
7
+ contract DeployMockUSDC is Deployer {
8
+ function versionName() public pure override returns (string memory) {
9
+ return "utils/mockUSDC";
10
+ }
11
+
12
+ function __deploy(address deployer) internal override returns (address) {
13
+ vm.startBroadcast(deployer);
14
+ MockERC20 deployment = new MockERC20("USD Coin", "USDC", 6);
15
+ vm.stopBroadcast();
16
+
17
+ return address(deployment);
18
+ }
19
+ }
@@ -39,6 +39,7 @@ contract InteractPostDeploy is Interaction {
39
39
  address riverAirdrop = getDeployment("riverAirdrop");
40
40
  address appRegistry = getDeployment("appRegistry");
41
41
  address subscriptionModule = getDeployment("subscriptionModule");
42
+ address mockUSDC = getDeployment("mockUSDC");
42
43
  address townsBase = deployTownsBase.deploy(deployer);
43
44
  address proxyDelegation = deployProxyDelegation.deploy(deployer);
44
45
 
@@ -59,6 +60,16 @@ contract InteractPostDeploy is Interaction {
59
60
  INodeOperator operatorFacet = INodeOperator(baseRegistry);
60
61
  operatorFacet.registerOperator(OPERATOR);
61
62
  operatorFacet.setOperatorStatus(OPERATOR, NodeOperatorStatus.Approved);
63
+
64
+ // Configure membership fee for mock USDC
65
+ IFeeManager(spaceFactory).setFeeConfig(
66
+ FeeTypesLib.membership(mockUSDC),
67
+ deployer,
68
+ FeeCalculationMethod.HYBRID,
69
+ 1000, // 10%
70
+ 1_500_000, // $1.50 (6 decimals)
71
+ true
72
+ );
62
73
  vm.stopBroadcast();
63
74
  }
64
75
  }
@@ -2,12 +2,10 @@
2
2
  pragma solidity ^0.8.23;
3
3
 
4
4
  // interfaces
5
- import {IFeatureManagerFacet} from "./IFeatureManagerFacet.sol";
6
- import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
5
+ import {IFeatureManager} from "./IFeatureManager.sol";
7
6
 
8
7
  // libraries
9
- import {FeatureManagerBase} from "./FeatureManagerBase.sol";
10
- import {FeatureCondition} from "./IFeatureManagerFacet.sol";
8
+ import "./FeatureManagerMod.sol" as FeatureManagerMod;
11
9
 
12
10
  // contracts
13
11
  import {OwnableBase} from "@towns-protocol/diamond/src/facets/ownable/OwnableBase.sol";
@@ -16,61 +14,66 @@ import {Facet} from "@towns-protocol/diamond/src/facets/Facet.sol";
16
14
  /// @title FeatureManagerFacet
17
15
  /// @notice Manages feature conditions and checks for spaces
18
16
  /// @dev This facet is responsible for managing feature conditions and checking if a space meets the condition for a feature to be enabled
19
- contract FeatureManagerFacet is IFeatureManagerFacet, OwnableBase, Facet, FeatureManagerBase {
17
+ contract FeatureManagerFacet is IFeatureManager, OwnableBase, Facet {
20
18
  function __FeatureManagerFacet_init() external onlyInitializing {
21
- _addInterface(type(IFeatureManagerFacet).interfaceId);
19
+ _addInterface(type(IFeatureManager).interfaceId);
22
20
  }
23
21
 
24
- /// @inheritdoc IFeatureManagerFacet
22
+ /// @inheritdoc IFeatureManager
25
23
  function setFeatureCondition(
26
24
  bytes32 featureId,
27
- FeatureCondition calldata condition
25
+ FeatureManagerMod.FeatureCondition calldata condition
28
26
  ) external onlyOwner {
29
- _upsertFeatureCondition(featureId, condition, true);
30
- emit FeatureConditionSet(featureId, condition);
27
+ FeatureManagerMod.upsertFeatureCondition(featureId, condition, true);
31
28
  }
32
29
 
33
- /// @inheritdoc IFeatureManagerFacet
30
+ /// @inheritdoc IFeatureManager
34
31
  function updateFeatureCondition(
35
32
  bytes32 featureId,
36
- FeatureCondition calldata condition
33
+ FeatureManagerMod.FeatureCondition calldata condition
37
34
  ) external onlyOwner {
38
- _upsertFeatureCondition(featureId, condition, false);
39
- emit FeatureConditionSet(featureId, condition);
35
+ FeatureManagerMod.upsertFeatureCondition(featureId, condition, false);
40
36
  }
41
37
 
42
- /// @inheritdoc IFeatureManagerFacet
38
+ /// @inheritdoc IFeatureManager
43
39
  function disableFeatureCondition(bytes32 featureId) external onlyOwner {
44
- _disableFeatureCondition(featureId);
45
- emit FeatureConditionDisabled(featureId);
40
+ FeatureManagerMod.disableFeatureCondition(featureId);
46
41
  }
47
42
 
48
- /// @inheritdoc IFeatureManagerFacet
43
+ /// @inheritdoc IFeatureManager
49
44
  function getFeatureCondition(
50
45
  bytes32 featureId
51
- ) external view returns (FeatureCondition memory result) {
46
+ ) external view returns (FeatureManagerMod.FeatureCondition memory result) {
52
47
  // Gas optimization: Reclaim implicit memory allocation for return variable
53
48
  // since we're loading from storage, not using the pre-allocated memory
54
49
  assembly ("memory-safe") {
55
50
  mstore(0x40, result)
56
51
  }
57
- result = _getFeatureCondition(featureId);
52
+ result = FeatureManagerMod.getFeatureCondition(featureId);
58
53
  }
59
54
 
60
- /// @inheritdoc IFeatureManagerFacet
61
- function getFeatureConditions() external view returns (FeatureCondition[] memory) {
62
- return _getFeatureConditions();
55
+ /// @inheritdoc IFeatureManager
56
+ function getFeatureConditions()
57
+ external
58
+ view
59
+ returns (FeatureManagerMod.FeatureCondition[] memory)
60
+ {
61
+ return FeatureManagerMod.getFeatureConditions();
63
62
  }
64
63
 
65
- /// @inheritdoc IFeatureManagerFacet
64
+ /// @inheritdoc IFeatureManager
66
65
  function getFeatureConditionsForSpace(
67
66
  address space
68
- ) external view returns (FeatureCondition[] memory) {
69
- return _getFeatureConditionsForSpace(space);
67
+ ) external view returns (FeatureManagerMod.FeatureCondition[] memory) {
68
+ return FeatureManagerMod.getFeatureConditionsForAddress(space);
70
69
  }
71
70
 
72
- /// @inheritdoc IFeatureManagerFacet
73
- function checkFeatureCondition(bytes32 featureId, address space) external view returns (bool) {
74
- return _isValidCondition(_getFeatureCondition(featureId), space);
71
+ /// @inheritdoc IFeatureManager
72
+ function checkFeatureCondition(bytes32 featureId, address addr) external view returns (bool) {
73
+ return
74
+ FeatureManagerMod.isValidCondition(
75
+ FeatureManagerMod.getFeatureCondition(featureId),
76
+ addr
77
+ );
75
78
  }
76
79
  }