@towns-protocol/contracts 0.0.369 → 0.0.371

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towns-protocol/contracts",
3
- "version": "0.0.369",
3
+ "version": "0.0.371",
4
4
  "packageManager": "yarn@3.8.0",
5
5
  "scripts": {
6
6
  "build-types": "bash scripts/build-contract-types.sh",
@@ -35,7 +35,7 @@
35
35
  "@layerzerolabs/oapp-evm": "^0.3.2",
36
36
  "@openzeppelin/merkle-tree": "^1.0.8",
37
37
  "@prb/test": "^0.6.4",
38
- "@towns-protocol/prettier-config": "^0.0.369",
38
+ "@towns-protocol/prettier-config": "^0.0.371",
39
39
  "@typechain/ethers-v5": "^11.1.2",
40
40
  "@wagmi/cli": "^2.2.0",
41
41
  "forge-std": "github:foundry-rs/forge-std#v1.10.0",
@@ -57,5 +57,5 @@
57
57
  "publishConfig": {
58
58
  "access": "public"
59
59
  },
60
- "gitHead": "5a4adeead17c71980426b92b98c23952536e08fa"
60
+ "gitHead": "1b8084e16a1892d0cc2eee713b926c259dc4a93b"
61
61
  }
@@ -54,7 +54,6 @@ contract DeployAppRegistry is IDiamondInitHelper, DiamondHelper, Deployer {
54
54
  facetHelper.add("DiamondLoupeFacet");
55
55
  facetHelper.add("IntrospectionFacet");
56
56
  facetHelper.add("OwnableFacet");
57
- facetHelper.add("MetadataFacet");
58
57
 
59
58
  // Deploy the first batch of facets
60
59
  facetHelper.deployBatch(deployer);
@@ -87,26 +86,27 @@ contract DeployAppRegistry is IDiamondInitHelper, DiamondHelper, Deployer {
87
86
  facet,
88
87
  DeployOwnable.makeInitData(deployer)
89
88
  );
90
-
91
- facet = facetHelper.predictAddress("MetadataFacet");
92
- addFacet(
93
- makeCut(facet, FacetCutAction.Add, DeployMetadata.selectors()),
94
- facet,
95
- DeployMetadata.makeInitData(bytes32("AppRegistry"), "")
96
- );
97
89
  }
98
90
 
99
91
  function diamondInitParams(address deployer) public returns (Diamond.InitParams memory) {
100
92
  // Queue up feature facets for batch deployment
101
93
  facetHelper.add("MultiInit");
94
+ facetHelper.add("MetadataFacet");
102
95
  facetHelper.add("UpgradeableBeaconFacet");
103
96
  facetHelper.add("AppRegistryFacet");
104
97
  facetHelper.add("SimpleApp");
105
98
 
106
99
  facetHelper.deployBatch(deployer);
107
100
 
101
+ address facet = facetHelper.getDeployedAddress("MetadataFacet");
102
+ addFacet(
103
+ makeCut(facet, FacetCutAction.Add, DeployMetadata.selectors()),
104
+ facet,
105
+ DeployMetadata.makeInitData(bytes32("AppRegistry"), "")
106
+ );
107
+
108
108
  address simpleApp = facetHelper.getDeployedAddress("SimpleApp");
109
- address facet = facetHelper.getDeployedAddress("UpgradeableBeaconFacet");
109
+ facet = facetHelper.getDeployedAddress("UpgradeableBeaconFacet");
110
110
 
111
111
  addFacet(
112
112
  makeCut(facet, FacetCutAction.Add, DeployUpgradeableBeacon.selectors()),
@@ -62,13 +62,21 @@ contract DeploySubscriptionModule is DiamondHelper, Deployer, IDiamondInitHelper
62
62
  function diamondInitParams(address deployer) public returns (Diamond.InitParams memory) {
63
63
  // Queue up feature facets for batch deployment
64
64
  facetHelper.add("MultiInit");
65
+ facetHelper.add("MetadataFacet");
65
66
  facetHelper.add("SubscriptionModuleFacet");
66
67
 
67
68
  // Deploy all facets in a batch
68
69
  facetHelper.deployBatch(deployer);
69
70
 
70
71
  // Add feature facets
71
- address facet = facetHelper.getDeployedAddress("SubscriptionModuleFacet");
72
+ address facet = facetHelper.getDeployedAddress("MetadataFacet");
73
+ addFacet(
74
+ makeCut(facet, FacetCutAction.Add, DeployMetadata.selectors()),
75
+ facet,
76
+ DeployMetadata.makeInitData(METADATA_NAME, "")
77
+ );
78
+
79
+ facet = facetHelper.getDeployedAddress("SubscriptionModuleFacet");
72
80
  addFacet(
73
81
  makeCut(facet, FacetCutAction.Add, DeploySubscriptionModuleFacet.selectors()),
74
82
  facet,
@@ -95,7 +103,6 @@ contract DeploySubscriptionModule is DiamondHelper, Deployer, IDiamondInitHelper
95
103
  facetHelper.add("DiamondLoupeFacet");
96
104
  facetHelper.add("IntrospectionFacet");
97
105
  facetHelper.add("OwnableFacet");
98
- facetHelper.add("MetadataFacet");
99
106
 
100
107
  // Get predicted addresses
101
108
  address facet = facetHelper.predictAddress("DiamondCutFacet");
@@ -125,13 +132,6 @@ contract DeploySubscriptionModule is DiamondHelper, Deployer, IDiamondInitHelper
125
132
  facet,
126
133
  DeployOwnable.makeInitData(deployer)
127
134
  );
128
-
129
- facet = facetHelper.predictAddress("MetadataFacet");
130
- addFacet(
131
- makeCut(facet, FacetCutAction.Add, DeployMetadata.selectors()),
132
- facet,
133
- DeployMetadata.makeInitData(METADATA_NAME, "")
134
- );
135
135
  }
136
136
 
137
137
  function __deploy(address deployer) internal override returns (address) {
@@ -34,6 +34,8 @@ library DeploySubscriptionModuleFacet {
34
34
  arr.p(SubscriptionModuleFacet.isOperator.selector);
35
35
  arr.p(SubscriptionModuleFacet.grantOperator.selector);
36
36
  arr.p(SubscriptionModuleFacet.revokeOperator.selector);
37
+ arr.p(SubscriptionModuleFacet.setSpaceFactory.selector);
38
+ arr.p(SubscriptionModuleFacet.getSpaceFactory.selector);
37
39
  arr.p(bytes4(keccak256("MAX_BATCH_SIZE()")));
38
40
  arr.p(bytes4(keccak256("GRACE_PERIOD()")));
39
41
 
@@ -13,6 +13,7 @@ import {AlphaHelper} from "./helpers/AlphaHelper.sol";
13
13
 
14
14
  // fetch facet deployer contract
15
15
  import {DeploySubscriptionModuleFacet} from "scripts/deployments/facets/DeploySubscriptionModuleFacet.s.sol";
16
+ import {SubscriptionModuleFacet} from "src/apps/modules/subscription/SubscriptionModuleFacet.sol";
16
17
 
17
18
  contract InteractDiamondCut is Interaction, AlphaHelper {
18
19
  function __interact(address deployer) internal override {
@@ -41,5 +42,8 @@ contract InteractDiamondCut is Interaction, AlphaHelper {
41
42
  // execute the diamond cut
42
43
  vm.broadcast(deployer);
43
44
  IDiamondCut(diamond).diamondCut(baseFacets(), address(0), "");
45
+
46
+ vm.broadcast(deployer);
47
+ SubscriptionModuleFacet(diamond).setSpaceFactory(getDeployment("spaceFactory"));
44
48
  }
45
49
  }
@@ -8,6 +8,7 @@ import {IDiamondLoupe, IDiamondLoupeBase} from "@towns-protocol/diamond/src/face
8
8
  import {IERC173} from "@towns-protocol/diamond/src/facets/ownable/IERC173.sol";
9
9
  import {IOwnablePending} from "@towns-protocol/diamond/src/facets/ownable/pending/IOwnablePending.sol";
10
10
  import {IDiamondInitHelper} from "scripts/deployments/diamonds/IDiamondInitHelper.sol";
11
+ import {IMetadata} from "src/diamond/facets/metadata/IMetadata.sol";
11
12
 
12
13
  // libraries
13
14
  import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
@@ -55,13 +56,14 @@ abstract contract AlphaHelper is Interaction, DiamondHelper, IDiamondLoupeBase {
55
56
  function getCoreFacetAddresses(
56
57
  address diamond
57
58
  ) internal view returns (address[] memory coreFacets) {
58
- coreFacets = new address[](5);
59
+ coreFacets = new address[](6);
59
60
 
60
61
  coreFacets[0] = IDiamondLoupe(diamond).facetAddress(IDiamondCut.diamondCut.selector);
61
62
  coreFacets[1] = IDiamondLoupe(diamond).facetAddress(IDiamondLoupe.facets.selector);
62
63
  coreFacets[2] = IDiamondLoupe(diamond).facetAddress(IERC165.supportsInterface.selector);
63
64
  coreFacets[3] = IDiamondLoupe(diamond).facetAddress(IERC173.owner.selector);
64
65
  coreFacets[4] = IDiamondLoupe(diamond).facetAddress(IOwnablePending.currentOwner.selector);
66
+ coreFacets[5] = IDiamondLoupe(diamond).facetAddress(IMetadata.contractType.selector);
65
67
  }
66
68
 
67
69
  /// @notice Check if an address is a core facet that should not be removed
@@ -254,7 +256,7 @@ abstract contract AlphaHelper is Interaction, DiamondHelper, IDiamondLoupeBase {
254
256
  }
255
257
 
256
258
  if (removeSelectors.length() > 0) {
257
- addCut(FacetCut(address(0), FacetCutAction.Remove, asBytes4Array(removeSelectors)));
259
+ addCut(FacetCut(facetAddr, FacetCutAction.Remove, asBytes4Array(removeSelectors)));
258
260
  }
259
261
  }
260
262
  }
@@ -8,8 +8,6 @@ pragma solidity ^0.8.23;
8
8
  // contracts
9
9
 
10
10
  library RiverConfigValues {
11
- bytes32 public constant ENABLE_NEW_SNAPSHOT_FORMAT =
12
- keccak256("stream.enablenewsnapshotformat");
13
11
  bytes32 public constant XCHAIN_BLOCKCHAINS = keccak256("xchain.blockchains");
14
12
  bytes32 public constant NODE_BLOCKLIST = keccak256("node.blocklist");
15
13
  bytes32 public constant STREAM_MINIBLOCK_REGISTRATION_FREQUENCY =
@@ -41,6 +41,10 @@ interface ISubscriptionModuleBase {
41
41
  error SubscriptionModule__InsufficientBalance();
42
42
  error SubscriptionModule__ActiveSubscription();
43
43
  error SubscriptionModule__MembershipBanned();
44
+ error SubscriptionModule__MembershipExpired();
45
+ error SubscriptionModule__SubscriptionAlreadyInstalled();
46
+ error SubscriptionModule__SubscriptionNotInstalled();
47
+
44
48
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
45
49
  /* Events */
46
50
  /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
@@ -88,6 +92,7 @@ interface ISubscriptionModuleBase {
88
92
 
89
93
  event OperatorGranted(address indexed operator);
90
94
  event OperatorRevoked(address indexed operator);
95
+ event SpaceFactoryChanged(address indexed spaceFactory);
91
96
  }
92
97
 
93
98
  interface ISubscriptionModule is ISubscriptionModuleBase {
@@ -137,4 +142,12 @@ interface ISubscriptionModule is ISubscriptionModuleBase {
137
142
  /// @notice Revokes an operator access to call processRenewal
138
143
  /// @param operator The address of the operator to revoke
139
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);
140
153
  }
@@ -0,0 +1,267 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.29;
3
+
4
+ // interfaces
5
+ import {IModularAccount} from "@erc6900/reference-implementation/interfaces/IModularAccount.sol";
6
+ import {IMembership} from "../../../spaces/facets/membership/IMembership.sol";
7
+ import {IBanning} from "../../../spaces/facets/banning/IBanning.sol";
8
+ import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
9
+ import {ISubscriptionModule, ISubscriptionModuleBase} from "./ISubscriptionModule.sol";
10
+
11
+ // libraries
12
+ import {LibCall} from "solady/utils/LibCall.sol";
13
+ import {ValidationLocatorLib} from "modular-account/src/libraries/ValidationLocatorLib.sol";
14
+ import {SafeCastLib} from "solady/utils/SafeCastLib.sol";
15
+ import {CustomRevert} from "../../../utils/libraries/CustomRevert.sol";
16
+ import {Validator} from "../../../utils/libraries/Validator.sol";
17
+ import {Subscription, SubscriptionModuleStorage} from "./SubscriptionModuleStorage.sol";
18
+ import {EnumerableSetLib} from "solady/utils/EnumerableSetLib.sol";
19
+
20
+ /// @title Subscription Module Base
21
+ /// @notice Base contract with internal logic for subscription management
22
+ abstract contract SubscriptionModuleBase is ISubscriptionModuleBase {
23
+ using SafeCastLib for uint256;
24
+ using CustomRevert for bytes4;
25
+ using EnumerableSetLib for EnumerableSetLib.Uint256Set;
26
+
27
+ /// @dev Reasons why a renewal might be skipped
28
+ enum SkipReason {
29
+ NONE,
30
+ NOT_DUE,
31
+ INACTIVE,
32
+ PAST_GRACE,
33
+ MEMBERSHIP_BANNED,
34
+ NOT_OWNER,
35
+ RENEWAL_PRICE_CHANGED,
36
+ INSUFFICIENT_BALANCE,
37
+ DURATION_CHANGED
38
+ }
39
+
40
+ uint256 public constant GRACE_PERIOD = 3 days;
41
+
42
+ // Dynamic buffer times based on expiration proximity
43
+ uint256 public constant BUFFER_IMMEDIATE = 2 minutes; // For expirations within 1 hour
44
+ uint256 public constant BUFFER_SHORT = 1 hours; // For expirations within 6 hours
45
+ uint256 public constant BUFFER_MEDIUM = 6 hours; // For expirations within 24 hours
46
+ uint256 public constant BUFFER_LONG = 12 hours; // For expirations more than 24 hours away
47
+
48
+ /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
49
+ /* Internal */
50
+ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
51
+
52
+ function _hasEntityId(
53
+ SubscriptionModuleStorage.Layout storage $,
54
+ address account,
55
+ uint32 entityId
56
+ ) internal view returns (bool) {
57
+ return $.entityIds[account].contains(entityId);
58
+ }
59
+
60
+ /// @dev Syncs subscription state with current membership data
61
+ /// @param sub The subscription storage to update
62
+ /// @param membershipFacet The membership facet to query
63
+ /// @param expiresAt The current expiration timestamp
64
+ /// @param duration The membership duration
65
+ function _syncSubscriptionState(
66
+ Subscription storage sub,
67
+ IMembership membershipFacet,
68
+ uint256 expiresAt,
69
+ uint64 duration
70
+ ) internal {
71
+ sub.active = true;
72
+ sub.lastKnownRenewalPrice = membershipFacet.getMembershipRenewalPrice(sub.tokenId);
73
+ sub.lastKnownExpiresAt = expiresAt;
74
+ sub.duration = duration;
75
+ sub.nextRenewalTime = _calculateBaseRenewalTime(expiresAt, duration);
76
+ }
77
+
78
+ /// @dev Processes a single subscription renewal
79
+ /// @param sub The subscription to renew
80
+ /// @param params The parameters for the renewal
81
+ function _processRenewal(
82
+ Subscription storage sub,
83
+ ISubscriptionModule.RenewalParams calldata params,
84
+ IMembership membershipFacet,
85
+ uint256 actualRenewalPrice
86
+ ) internal {
87
+ // Construct the renewal call to space contract
88
+ bytes memory renewalCall = abi.encodeCall(IMembership.renewMembership, (sub.tokenId));
89
+
90
+ // 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
+ );
100
+
101
+ // Use the proper pack function from ValidationLocatorLib
102
+ bytes memory authorization = ValidationLocatorLib.packSignature(
103
+ params.entityId,
104
+ false, // selector-based
105
+ bytes.concat(hex"ff", abi.encode(sub.space, sub.tokenId))
106
+ );
107
+
108
+ // Call executeWithRuntimeValidation with the correct parameters
109
+ bytes memory runtimeValidationCall = abi.encodeCall(
110
+ IModularAccount.executeWithRuntimeValidation,
111
+ (
112
+ executeData, // The execute() call data
113
+ authorization // Authorization for validation
114
+ )
115
+ );
116
+
117
+ // External call happens here
118
+ LibCall.callContract(params.account, 0, runtimeValidationCall);
119
+
120
+ // Get the actual new expiration time after successful renewal
121
+ uint256 newExpiresAt = membershipFacet.expiresAt(sub.tokenId);
122
+
123
+ // Calculate next renewal time ensuring it's strictly in the future
124
+ uint256 duration = membershipFacet.getMembershipDuration();
125
+ sub.nextRenewalTime = _calculateBaseRenewalTime(newExpiresAt, duration);
126
+ sub.lastRenewalTime = block.timestamp.toUint40();
127
+ sub.lastKnownExpiresAt = newExpiresAt;
128
+ sub.spent += actualRenewalPrice;
129
+
130
+ emit SubscriptionRenewed(
131
+ params.account,
132
+ params.entityId,
133
+ sub.space,
134
+ sub.tokenId,
135
+ sub.nextRenewalTime,
136
+ newExpiresAt
137
+ );
138
+ emit SubscriptionSpent(params.account, params.entityId, actualRenewalPrice, sub.spent);
139
+ }
140
+
141
+ /// @dev Determines the appropriate renewal buffer time based on membership duration
142
+ /// @param duration The membership duration in seconds
143
+ /// @return The appropriate buffer time in seconds before expiration
144
+ function _getRenewalBuffer(uint256 duration) internal pure returns (uint256) {
145
+ // For memberships shorter than 1 hour, use immediate buffer (2 minutes)
146
+ if (duration <= 1 hours) return BUFFER_IMMEDIATE;
147
+
148
+ // For memberships shorter than 6 hours, use short buffer (1 hour)
149
+ if (duration <= 6 hours) return BUFFER_SHORT;
150
+
151
+ // For memberships shorter than 24 hours, use medium buffer (6 hours)
152
+ if (duration <= 24 hours) return BUFFER_MEDIUM;
153
+
154
+ // For memberships longer than 24 hours, use long buffer (12 hours)
155
+ return BUFFER_LONG;
156
+ }
157
+
158
+ /// @dev Calculates the base renewal time without minimum buffer enforcement
159
+ /// @param expirationTime The expiration timestamp of the membership
160
+ /// @param duration The membership duration in seconds
161
+ /// @return The base renewal time as uint40
162
+ function _calculateBaseRenewalTime(
163
+ uint256 expirationTime,
164
+ uint256 duration
165
+ ) internal view returns (uint40) {
166
+ if (expirationTime <= block.timestamp) return block.timestamp.toUint40();
167
+
168
+ uint256 buffer = _getRenewalBuffer(duration);
169
+ uint256 timeUntilExpiration = expirationTime - block.timestamp;
170
+
171
+ if (buffer >= timeUntilExpiration) {
172
+ // If buffer is larger than time until expiration,
173
+ // schedule for after the expiration by the same amount
174
+ return (expirationTime + (buffer - timeUntilExpiration)).toUint40();
175
+ }
176
+
177
+ return (expirationTime - buffer).toUint40();
178
+ }
179
+
180
+ /// @dev Requires that msg.sender is the owner of the membership token
181
+ /// @param sub The subscription storage reference
182
+ function _requireOwnership(Subscription storage sub) internal view {
183
+ address owner = IERC721(sub.space).ownerOf(sub.tokenId);
184
+ if (msg.sender != owner) SubscriptionModule__InvalidCaller.selector.revertWith();
185
+ }
186
+
187
+ /// @dev Validates renewal eligibility and returns skip reason if any
188
+ /// @param sub The subscription to validate
189
+ /// @param account The account address
190
+ /// @param actualRenewalPrice The current renewal price
191
+ /// @param actualDuration The current membership duration
192
+ /// @return shouldSkip Whether to skip this renewal
193
+ /// @return shouldPause Whether to pause the subscription (only relevant if shouldSkip is true)
194
+ /// @return reason The skip reason if shouldSkip is true
195
+ function _validateRenewalEligibility(
196
+ Subscription storage sub,
197
+ address account,
198
+ uint256 actualRenewalPrice,
199
+ uint256 actualDuration
200
+ ) internal view returns (bool shouldSkip, bool shouldPause, SkipReason reason) {
201
+ // Check if renewal is due
202
+ if (sub.nextRenewalTime > block.timestamp) {
203
+ return (true, false, SkipReason.NOT_DUE);
204
+ }
205
+
206
+ // Check if subscription is active
207
+ if (!sub.active) {
208
+ return (true, false, SkipReason.INACTIVE);
209
+ }
210
+
211
+ // Check if past grace period
212
+ if (sub.nextRenewalTime + GRACE_PERIOD < block.timestamp) {
213
+ return (true, true, SkipReason.PAST_GRACE);
214
+ }
215
+
216
+ // Check if membership is banned
217
+ if (IBanning(sub.space).isBanned(sub.tokenId)) {
218
+ return (true, true, SkipReason.MEMBERSHIP_BANNED);
219
+ }
220
+
221
+ // Check if account is still the owner
222
+ if (IERC721(sub.space).ownerOf(sub.tokenId) != account) {
223
+ return (true, true, SkipReason.NOT_OWNER);
224
+ }
225
+
226
+ // Check if renewal price changed
227
+ if (sub.lastKnownRenewalPrice != actualRenewalPrice) {
228
+ return (true, true, SkipReason.RENEWAL_PRICE_CHANGED);
229
+ }
230
+
231
+ // Check if account has sufficient balance
232
+ if (account.balance < actualRenewalPrice) {
233
+ return (true, true, SkipReason.INSUFFICIENT_BALANCE);
234
+ }
235
+
236
+ // Check if duration changed
237
+ if (sub.duration != actualDuration) {
238
+ return (true, true, SkipReason.DURATION_CHANGED);
239
+ }
240
+
241
+ return (false, false, SkipReason.NONE);
242
+ }
243
+
244
+ /// @dev Converts a SkipReason enum to its string representation
245
+ /// @param reason The skip reason to convert
246
+ /// @return The string representation of the reason
247
+ function _skipReasonToString(SkipReason reason) internal pure returns (string memory) {
248
+ if (reason == SkipReason.NOT_DUE) return "NOT_DUE";
249
+ if (reason == SkipReason.INACTIVE) return "INACTIVE";
250
+ if (reason == SkipReason.PAST_GRACE) return "PAST_GRACE";
251
+ if (reason == SkipReason.MEMBERSHIP_BANNED) return "MEMBERSHIP_BANNED";
252
+ if (reason == SkipReason.NOT_OWNER) return "NOT_OWNER";
253
+ if (reason == SkipReason.RENEWAL_PRICE_CHANGED) return "RENEWAL_PRICE_CHANGED";
254
+ if (reason == SkipReason.INSUFFICIENT_BALANCE) return "INSUFFICIENT_BALANCE";
255
+ if (reason == SkipReason.DURATION_CHANGED) return "DURATION_CHANGED";
256
+ return "";
257
+ }
258
+
259
+ function _pauseSubscription(
260
+ Subscription storage sub,
261
+ address account,
262
+ uint32 entityId
263
+ ) internal {
264
+ sub.active = false;
265
+ emit SubscriptionPaused(account, entityId);
266
+ }
267
+ }
@@ -3,7 +3,6 @@ pragma solidity ^0.8.29;
3
3
 
4
4
  // interfaces
5
5
  import {IModule} from "@erc6900/reference-implementation/interfaces/IModule.sol";
6
- import {IModularAccount} from "@erc6900/reference-implementation/interfaces/IModularAccount.sol";
7
6
  import {IValidationHookModule} from "@erc6900/reference-implementation/interfaces/IValidationHookModule.sol";
8
7
  import {IValidationModule} from "@erc6900/reference-implementation/interfaces/IValidationModule.sol";
9
8
  import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
@@ -14,11 +13,10 @@ import {IBanning} from "../../../spaces/facets/banning/IBanning.sol";
14
13
  // libraries
15
14
  import {PackedUserOperation} from "@eth-infinitism/account-abstraction/interfaces/PackedUserOperation.sol";
16
15
  import {EnumerableSetLib} from "solady/utils/EnumerableSetLib.sol";
17
- import {LibCall} from "solady/utils/LibCall.sol";
18
- import {ValidationLocatorLib} from "modular-account/src/libraries/ValidationLocatorLib.sol";
19
16
  import {ReentrancyGuardTransient} from "solady/utils/ReentrancyGuardTransient.sol";
20
17
  import {CustomRevert} from "../../../utils/libraries/CustomRevert.sol";
21
18
  import {Validator} from "../../../utils/libraries/Validator.sol";
19
+ import {IArchitect} from "../../../factory/facets/architect/IArchitect.sol";
22
20
  import {Subscription, SubscriptionModuleStorage} from "./SubscriptionModuleStorage.sol";
23
21
  import {SafeCastLib} from "solady/utils/SafeCastLib.sol";
24
22
 
@@ -26,6 +24,7 @@ import {SafeCastLib} from "solady/utils/SafeCastLib.sol";
26
24
  import {ModuleBase} from "modular-account/src/modules/ModuleBase.sol";
27
25
  import {OwnableBase} from "@towns-protocol/diamond/src/facets/ownable/OwnableBase.sol";
28
26
  import {Facet} from "@towns-protocol/diamond/src/facets/Facet.sol";
27
+ import {SubscriptionModuleBase} from "./SubscriptionModuleBase.sol";
29
28
 
30
29
  /// @title Subscription Module
31
30
  /// @notice Module for managing subscriptions to spaces
@@ -36,6 +35,7 @@ contract SubscriptionModuleFacet is
36
35
  ModuleBase,
37
36
  OwnableBase,
38
37
  ReentrancyGuardTransient,
38
+ SubscriptionModuleBase,
39
39
  Facet
40
40
  {
41
41
  using EnumerableSetLib for EnumerableSetLib.Uint256Set;
@@ -46,13 +46,6 @@ contract SubscriptionModuleFacet is
46
46
  uint256 internal constant _SIG_VALIDATION_FAILED = 1;
47
47
 
48
48
  uint256 public constant MAX_BATCH_SIZE = 50;
49
- uint256 public constant GRACE_PERIOD = 3 days;
50
-
51
- // Dynamic buffer times based on expiration proximity
52
- uint256 public constant BUFFER_IMMEDIATE = 2 minutes; // For expirations within 1 hour
53
- uint256 public constant BUFFER_SHORT = 1 hours; // For expirations within 6 hours
54
- uint256 public constant BUFFER_MEDIUM = 6 hours; // For expirations within 24 hours
55
- uint256 public constant BUFFER_LONG = 12 hours; // For expirations more than 24 hours away
56
49
 
57
50
  function __SubscriptionModule_init() external onlyInitializing {
58
51
  _addInterface(type(ISubscriptionModule).interfaceId);
@@ -88,19 +81,32 @@ contract SubscriptionModuleFacet is
88
81
 
89
82
  SubscriptionModuleStorage.Layout storage $ = SubscriptionModuleStorage.getLayout();
90
83
 
84
+ if (IArchitect($.spaceFactory).getTokenIdBySpace(space) == 0)
85
+ SubscriptionModule__InvalidSpace.selector.revertWith();
86
+
91
87
  if (!$.entityIds[msg.sender].add(entityId))
92
88
  SubscriptionModule__InvalidEntityId.selector.revertWith();
93
89
 
90
+ if ($.tokenIdByAccountBySpace[msg.sender][space] != 0)
91
+ SubscriptionModule__SubscriptionAlreadyInstalled.selector.revertWith();
92
+
93
+ $.tokenIdByAccountBySpace[msg.sender][space] = tokenId;
94
+
94
95
  IMembership membershipFacet = IMembership(space);
96
+
95
97
  uint256 expiresAt = membershipFacet.expiresAt(tokenId);
96
- uint256 duration = membershipFacet.getMembershipDuration();
98
+
99
+ // Prevent installation with expired membership
100
+ if (expiresAt <= block.timestamp)
101
+ SubscriptionModule__MembershipExpired.selector.revertWith();
102
+
103
+ uint64 duration = membershipFacet.getMembershipDuration();
97
104
 
98
105
  Subscription storage sub = $.subscriptions[msg.sender][entityId];
99
106
  sub.space = space;
100
- sub.active = true;
101
107
  sub.tokenId = tokenId;
102
108
  sub.installTime = block.timestamp.toUint40();
103
- sub.nextRenewalTime = _calculateBaseRenewalTime(expiresAt, duration);
109
+ _syncSubscriptionState(sub, membershipFacet, expiresAt, duration);
104
110
 
105
111
  emit SubscriptionConfigured(
106
112
  msg.sender,
@@ -121,6 +127,9 @@ contract SubscriptionModuleFacet is
121
127
  if (!$.entityIds[msg.sender].remove(entityId))
122
128
  SubscriptionModule__InvalidEntityId.selector.revertWith();
123
129
 
130
+ Subscription storage sub = $.subscriptions[msg.sender][entityId];
131
+
132
+ delete $.tokenIdByAccountBySpace[msg.sender][sub.space];
124
133
  delete $.subscriptions[msg.sender][entityId];
125
134
 
126
135
  emit SubscriptionDeactivated(msg.sender, entityId);
@@ -158,7 +167,6 @@ contract SubscriptionModuleFacet is
158
167
  if (sender != address(this)) SubscriptionModule__InvalidSender.selector.revertWith();
159
168
  bool active = SubscriptionModuleStorage.getLayout().subscriptions[account][entityId].active;
160
169
  if (!active) SubscriptionModule__InactiveSubscription.selector.revertWith();
161
- return;
162
170
  }
163
171
 
164
172
  /// @inheritdoc IValidationHookModule
@@ -203,68 +211,46 @@ contract SubscriptionModuleFacet is
203
211
  if (!$.operators.contains(msg.sender))
204
212
  SubscriptionModule__InvalidCaller.selector.revertWith();
205
213
 
206
- IMembership membershipFacet;
207
-
208
214
  for (uint256 i; i < paramsLen; ++i) {
209
215
  Subscription storage sub = $.subscriptions[params[i].account][params[i].entityId];
210
216
 
211
- // Skip if renewal not due (check original nextRenewalTime first)
212
- if (sub.nextRenewalTime > block.timestamp) {
213
- emit SubscriptionNotDue(params[i].account, params[i].entityId);
214
- continue;
215
- }
216
-
217
- // Skip inactive subscriptions
218
- if (!sub.active) {
219
- emit BatchRenewalSkipped(params[i].account, params[i].entityId, "INACTIVE");
220
- continue;
221
- }
222
-
223
- // Skip if past grace period
224
- if (sub.nextRenewalTime + GRACE_PERIOD < block.timestamp) {
225
- _pauseSubscription(sub, params[i].account, params[i].entityId);
226
- emit BatchRenewalSkipped(params[i].account, params[i].entityId, "PAST_GRACE");
227
- continue;
228
- }
229
-
230
- if (IBanning(sub.space).isBanned(sub.tokenId)) {
231
- _pauseSubscription(sub, params[i].account, params[i].entityId);
232
- emit BatchRenewalSkipped(
233
- params[i].account,
234
- params[i].entityId,
235
- "MEMBERSHIP_BANNED"
236
- );
237
- continue;
238
- }
239
-
240
- // Skip if account isn't owner anymore (for safety)
241
- if (IERC721(sub.space).ownerOf(sub.tokenId) != params[i].account) {
242
- _pauseSubscription(sub, params[i].account, params[i].entityId);
243
- emit BatchRenewalSkipped(params[i].account, params[i].entityId, "NOT_OWNER");
244
- continue;
245
- }
246
-
247
- membershipFacet = IMembership(sub.space);
248
-
217
+ IMembership membershipFacet = IMembership(sub.space);
249
218
  uint256 actualRenewalPrice = membershipFacet.getMembershipRenewalPrice(sub.tokenId);
219
+ uint256 actualDuration = membershipFacet.getMembershipDuration();
220
+
221
+ // Validate renewal eligibility
222
+ (bool shouldSkip, bool shouldPause, SkipReason reason) = _validateRenewalEligibility(
223
+ sub,
224
+ params[i].account,
225
+ actualRenewalPrice,
226
+ actualDuration
227
+ );
250
228
 
251
- if (params[i].account.balance < actualRenewalPrice) {
252
- _pauseSubscription(sub, params[i].account, params[i].entityId);
253
- emit BatchRenewalSkipped(
254
- params[i].account,
255
- params[i].entityId,
256
- "INSUFFICIENT_BALANCE"
257
- );
229
+ if (shouldSkip) {
230
+ // Handle special case for "NOT_DUE" - different event
231
+ if (reason == SkipReason.NOT_DUE) {
232
+ emit SubscriptionNotDue(params[i].account, params[i].entityId);
233
+ } else {
234
+ // Pause subscription if needed
235
+ if (shouldPause) {
236
+ _pauseSubscription(sub, params[i].account, params[i].entityId);
237
+ }
238
+ emit BatchRenewalSkipped(
239
+ params[i].account,
240
+ params[i].entityId,
241
+ _skipReasonToString(reason)
242
+ );
243
+ }
258
244
  continue;
259
245
  }
260
246
 
261
- uint256 expiresAt = membershipFacet.expiresAt(sub.tokenId);
262
- uint256 duration = membershipFacet.getMembershipDuration();
263
- uint40 nextRenewalTime = _calculateBaseRenewalTime(expiresAt, duration);
264
-
265
- if (sub.nextRenewalTime != nextRenewalTime) {
266
- sub.nextRenewalTime = nextRenewalTime;
247
+ // Check for manual renewal (expiresAt changed)
248
+ uint256 actualExpiresAt = membershipFacet.expiresAt(sub.tokenId);
249
+ if (sub.lastKnownExpiresAt != actualExpiresAt) {
250
+ sub.nextRenewalTime = _calculateBaseRenewalTime(actualExpiresAt, sub.duration);
251
+ sub.lastKnownExpiresAt = actualExpiresAt;
267
252
  emit SubscriptionSynced(params[i].account, params[i].entityId, sub.nextRenewalTime);
253
+ continue;
268
254
  }
269
255
 
270
256
  _processRenewal(sub, params[i], membershipFacet, actualRenewalPrice);
@@ -295,34 +281,25 @@ contract SubscriptionModuleFacet is
295
281
 
296
282
  if (sub.active) SubscriptionModule__ActiveSubscription.selector.revertWith();
297
283
 
298
- address owner = IERC721(sub.space).ownerOf(sub.tokenId);
299
- if (msg.sender != owner) SubscriptionModule__InvalidCaller.selector.revertWith();
284
+ _requireOwnership(sub);
300
285
 
301
286
  IMembership membershipFacet = IMembership(sub.space);
302
287
  uint256 expiresAt = membershipFacet.expiresAt(sub.tokenId);
303
- uint256 duration = membershipFacet.getMembershipDuration();
288
+ uint64 duration = membershipFacet.getMembershipDuration();
304
289
 
305
- // 6. Always sync renewal time to current membership state
306
- uint40 correctNextRenewalTime = _calculateBaseRenewalTime(expiresAt, duration);
307
- if (sub.nextRenewalTime != correctNextRenewalTime) {
308
- sub.nextRenewalTime = correctNextRenewalTime;
309
- emit SubscriptionSynced(msg.sender, entityId, sub.nextRenewalTime);
310
- }
290
+ _syncSubscriptionState(sub, membershipFacet, expiresAt, duration);
311
291
 
312
- sub.active = true;
313
292
  emit SubscriptionActivated(msg.sender, entityId);
314
293
  }
315
294
 
316
295
  /// @inheritdoc ISubscriptionModule
317
296
  function pauseSubscription(uint32 entityId) external nonReentrant {
318
- Subscription storage sub = SubscriptionModuleStorage.getLayout().subscriptions[msg.sender][
319
- entityId
320
- ];
297
+ SubscriptionModuleStorage.Layout storage $ = SubscriptionModuleStorage.getLayout();
298
+ Subscription storage sub = $.subscriptions[msg.sender][entityId];
321
299
 
322
300
  if (!sub.active) SubscriptionModule__InactiveSubscription.selector.revertWith();
323
301
 
324
- address owner = IERC721(sub.space).ownerOf(sub.tokenId);
325
- if (msg.sender != owner) SubscriptionModule__InvalidCaller.selector.revertWith();
302
+ _requireOwnership(sub);
326
303
 
327
304
  _pauseSubscription(sub, msg.sender, entityId);
328
305
  }
@@ -352,176 +329,18 @@ contract SubscriptionModuleFacet is
352
329
  }
353
330
 
354
331
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
355
- /* Internal */
332
+ /* SPACE FACTORY */
356
333
  /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
357
334
 
358
- function _hasEntityId(
359
- SubscriptionModuleStorage.Layout storage $,
360
- address account,
361
- uint32 entityId
362
- ) internal view returns (bool) {
363
- return $.entityIds[account].contains(entityId);
364
- }
365
-
366
- /// @dev Processes a single subscription renewal
367
- /// @param sub The subscription to renew
368
- /// @param params The parameters for the renewal
369
- function _processRenewal(
370
- Subscription storage sub,
371
- RenewalParams calldata params,
372
- IMembership membershipFacet,
373
- uint256 actualRenewalPrice
374
- ) internal {
375
- // Construct the renewal call to space contract
376
- bytes memory renewalCall = abi.encodeCall(IMembership.renewMembership, (sub.tokenId));
377
-
378
- // Create the data parameter for executeWithRuntimeValidation
379
- // This should be an execute() call to the space contract
380
- bytes memory executeData = abi.encodeCall(
381
- IModularAccount.execute,
382
- (
383
- sub.space, // target
384
- actualRenewalPrice, // value
385
- renewalCall // data
386
- )
387
- );
388
-
389
- // Use the proper pack function from ValidationLocatorLib
390
- bytes memory authorization = ValidationLocatorLib.packSignature(
391
- params.entityId,
392
- false, // selector-based
393
- bytes.concat(hex"ff", abi.encode(sub.space, sub.tokenId))
394
- );
395
-
396
- // Call executeWithRuntimeValidation with the correct parameters
397
- bytes memory runtimeValidationCall = abi.encodeCall(
398
- IModularAccount.executeWithRuntimeValidation,
399
- (
400
- executeData, // The execute() call data
401
- authorization // Authorization for validation
402
- )
403
- );
404
-
405
- // External call happens here
406
- LibCall.callContract(params.account, 0, runtimeValidationCall);
407
-
408
- // Get the actual new expiration time after successful renewal
409
- uint256 newExpiresAt = membershipFacet.expiresAt(sub.tokenId);
410
-
411
- // Calculate next renewal time ensuring it's strictly in the future
412
- uint256 duration = membershipFacet.getMembershipDuration();
413
- sub.nextRenewalTime = _enforceMinimumBuffer(
414
- _calculateBaseRenewalTime(newExpiresAt, duration),
415
- newExpiresAt,
416
- duration
417
- );
418
- sub.lastRenewalTime = block.timestamp.toUint40();
419
- sub.spent += actualRenewalPrice;
420
-
421
- emit SubscriptionRenewed(
422
- params.account,
423
- params.entityId,
424
- sub.space,
425
- sub.tokenId,
426
- sub.nextRenewalTime,
427
- newExpiresAt
428
- );
429
- emit SubscriptionSpent(params.account, params.entityId, actualRenewalPrice, sub.spent);
430
- }
431
-
432
- /// @dev Determines the appropriate renewal buffer time based on membership duration
433
- /// @param duration The membership duration in seconds
434
- /// @return The appropriate buffer time in seconds before expiration
435
- function _getRenewalBuffer(uint256 duration) internal pure returns (uint256) {
436
- // For memberships shorter than 1 hour, use immediate buffer (2 minutes)
437
- if (duration <= 1 hours) return BUFFER_IMMEDIATE;
438
-
439
- // For memberships shorter than 6 hours, use short buffer (1 hour)
440
- if (duration <= 6 hours) return BUFFER_SHORT;
441
-
442
- // For memberships shorter than 24 hours, use medium buffer (6 hours)
443
- if (duration <= 24 hours) return BUFFER_MEDIUM;
444
-
445
- // For memberships longer than 24 hours, use long buffer (12 hours)
446
- return BUFFER_LONG;
447
- }
448
-
449
- /// @dev Calculates the base renewal time without minimum buffer enforcement
450
- /// @param expirationTime The expiration timestamp of the membership
451
- /// @param duration The membership duration in seconds
452
- /// @return The base renewal time as uint40
453
- function _calculateBaseRenewalTime(
454
- uint256 expirationTime,
455
- uint256 duration
456
- ) internal view returns (uint40) {
457
- // If membership is already expired, schedule for the future
458
- if (expirationTime <= block.timestamp) {
459
- return (block.timestamp + duration).toUint40();
460
- }
461
-
462
- uint256 buffer = _getRenewalBuffer(duration);
463
- uint256 timeUntilExpiration = expirationTime - block.timestamp;
464
-
465
- if (buffer >= timeUntilExpiration) {
466
- // If buffer is larger than time until expiration,
467
- // schedule for after the expiration by the same amount
468
- return (expirationTime + (buffer - timeUntilExpiration)).toUint40();
469
- }
470
-
471
- return (expirationTime - buffer).toUint40();
472
- }
473
-
474
- /// @dev Enforces minimum buffer to prevent double renewals
475
- /// @param baseTime The base calculated renewal time
476
- /// @param expirationTime The expiration timestamp of the membership
477
- /// @param duration The membership duration in seconds
478
- /// @return The adjusted renewal time with minimum buffer enforced
479
- function _enforceMinimumBuffer(
480
- uint40 baseTime,
481
- uint256 expirationTime,
482
- uint256 duration
483
- ) internal view returns (uint40) {
484
- uint256 operatorBuffer = SubscriptionModuleStorage.getOperatorBuffer(msg.sender);
485
-
486
- // If base time is far enough in the future, use it
487
- if (baseTime > block.timestamp + operatorBuffer) {
488
- return baseTime;
489
- }
490
-
491
- // For very short durations, schedule close to expiration with minimum buffer
492
- if (duration <= 1 hours) {
493
- return (expirationTime - operatorBuffer).toUint40();
494
- }
495
-
496
- // For longer durations, use standard calculation with minimum buffer
497
- uint256 buffer = _getRenewalBuffer(duration);
498
- uint256 minFutureTime = block.timestamp + duration - buffer;
499
-
500
- return (minFutureTime > baseTime ? minFutureTime : baseTime).toUint40();
501
- }
502
-
503
- /// @dev Creates the runtime final data for the renewal
504
- /// @param entityId The entity ID of the subscription
505
- /// @param finalData The final data for the renewal
506
- /// @return The runtime final data
507
- function _runtimeFinal(
508
- uint32 entityId,
509
- bytes memory finalData
510
- ) internal pure returns (bytes memory) {
511
- return
512
- ValidationLocatorLib.packSignature(
513
- entityId,
514
- false, // selector-based
515
- bytes.concat(hex"ff", finalData)
516
- );
335
+ /// @inheritdoc ISubscriptionModule
336
+ function setSpaceFactory(address spaceFactory) external onlyOwner {
337
+ Validator.checkAddress(spaceFactory);
338
+ SubscriptionModuleStorage.getLayout().spaceFactory = spaceFactory;
339
+ emit SpaceFactoryChanged(spaceFactory);
517
340
  }
518
341
 
519
- function _pauseSubscription(
520
- Subscription storage sub,
521
- address account,
522
- uint32 entityId
523
- ) internal {
524
- sub.active = false;
525
- emit SubscriptionPaused(account, entityId);
342
+ /// @inheritdoc ISubscriptionModule
343
+ function getSpaceFactory() external view returns (address) {
344
+ return SubscriptionModuleStorage.getLayout().spaceFactory;
526
345
  }
527
346
  }
@@ -11,7 +11,9 @@ struct Subscription {
11
11
  uint40 lastRenewalTime; // 5 bytes
12
12
  uint40 nextRenewalTime; // 5 bytes
13
13
  bool active; // 1 byte
14
- uint64 duration;
14
+ uint64 duration; // 8 bytes
15
+ uint256 lastKnownRenewalPrice; // 32 bytes
16
+ uint256 lastKnownExpiresAt; // 32 bytes
15
17
  }
16
18
 
17
19
  struct OperatorConfig {
@@ -32,6 +34,8 @@ library SubscriptionModuleStorage {
32
34
  mapping(address account => mapping(uint32 entityId => Subscription)) subscriptions;
33
35
  mapping(address account => EnumerableSetLib.Uint256Set entityIds) entityIds;
34
36
  mapping(address operator => OperatorConfig) operatorConfig;
37
+ mapping(address account => mapping(address space => uint256 tokenId)) tokenIdByAccountBySpace;
38
+ address spaceFactory;
35
39
  }
36
40
 
37
41
  // keccak256(abi.encode(uint256(keccak256("towns.subscription.validation.module.storage")) - 1)) & ~bytes32(uint256(0xff))
@@ -1,27 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity ^0.8.24;
3
-
4
- // interfaces
5
- import {IRiverConfig} from "src/river/registry/facets/config/IRiverConfig.sol";
6
-
7
- // libraries
8
- import {RiverConfigValues} from "scripts/interactions/helpers/RiverConfigValues.sol";
9
-
10
- // contracts
11
- import {Interaction} from "scripts/common/Interaction.s.sol";
12
-
13
- contract InteractEnableNewSnapshotFormat is Interaction {
14
- function __interact(address deployer) internal override {
15
- address riverRegistry = getDeployment("riverRegistry");
16
-
17
- uint64 value = 1;
18
-
19
- vm.startBroadcast(deployer);
20
- IRiverConfig(riverRegistry).setConfiguration(
21
- RiverConfigValues.ENABLE_NEW_SNAPSHOT_FORMAT,
22
- 0,
23
- abi.encode(value)
24
- );
25
- vm.stopBroadcast();
26
- }
27
- }