@towns-protocol/contracts 0.0.364 → 0.0.366

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.
@@ -0,0 +1,256 @@
1
+ # Staking Position State Machine
2
+
3
+ This document describes the lifecycle of a staking position in the RewardsDistributionV2 contract.
4
+
5
+ ## State-Determining Fields
6
+
7
+ A deposit's state is determined by two key properties (once it exists):
8
+
9
+ - **deposit.delegatee**: Address the stake is delegated to (operator or space)
10
+ - **deposit.pendingWithdrawal**: Amount waiting to be withdrawn
11
+
12
+ Note: DepositIds are assigned sequentially starting from 0 when stake() is called. The "Non-existent" state refers to the period before calling stake() to create the deposit.
13
+
14
+ ## States
15
+
16
+ ### 1. Non-existent
17
+ - **Condition**: Before calling stake()/permitAndStake()/stakeOnBehalf()
18
+ - **Description**: No deposit has been created yet; depositId will be assigned upon staking
19
+
20
+ ### 2. Active
21
+ - **Condition**: `delegatee != address(0)` AND `pendingWithdrawal == 0`
22
+ - **Description**: Stake is actively delegated to an operator or space, earning rewards
23
+ - **Key behaviors**:
24
+ - Earning staking rewards based on `deposit.beneficiary`
25
+ - Delegated voting power goes to `deposit.delegatee`
26
+ - Can increase stake, redelegate, or change beneficiary
27
+
28
+ ### 3. Withdrawal Initiated
29
+ - **Condition**: `delegatee == address(0)` AND `pendingWithdrawal > 0`
30
+ - **Description**: User has initiated withdrawal, stake is no longer active
31
+ - **Key behaviors**:
32
+ - No longer earning rewards
33
+ - Delegation has been removed
34
+ - Funds held in proxy, waiting for final withdrawal
35
+ - Can redelegate to restake the pending amount
36
+
37
+ ### 4. Withdrawn
38
+ - **Condition**: `delegatee == address(0)` AND `pendingWithdrawal == 0` AND deposit exists
39
+ - **Description**: Withdrawal completed, deposit shell remains but inactive
40
+ - **Key behaviors**:
41
+ - No active stake or pending withdrawals
42
+ - Deposit record persists in storage
43
+ - Cannot increase stake (would fail delegatee validation)
44
+ - Could theoretically redelegate with 0 amount
45
+
46
+ ## State Transitions
47
+
48
+ ### From Non-existent → Active
49
+
50
+ **Functions**: `stake()`, `permitAndStake()`, `stakeOnBehalf()`
51
+
52
+ Creates a new deposit with:
53
+ - New depositId assigned (sequential, starting from 0)
54
+ - Delegatee set to specified operator/space
55
+ - Beneficiary set for rewards
56
+ - Stake amount transferred to new delegation proxy
57
+
58
+ ---
59
+
60
+ ### Active → Active (State Preserved)
61
+
62
+ #### Increase Stake
63
+
64
+ **Functions**: `increaseStake()`, `permitAndIncreaseStake()`
65
+
66
+ Adds more funds to existing position:
67
+ - Validates existing delegatee is still valid
68
+ - Increases stake amount
69
+ - Updates earning power for rewards
70
+
71
+ #### Redelegate
72
+
73
+ **Function**: `redelegate()` (when `pendingWithdrawal == 0`)
74
+
75
+ Changes delegation target:
76
+ - Validates new delegatee is operator or space
77
+ - Updates rewards with old commission rate
78
+ - Sets new delegatee and commission rate
79
+ - Calls `DelegationProxy.redelegate()` to update on-chain delegation
80
+
81
+ #### Change Beneficiary
82
+
83
+ **Function**: `changeBeneficiary()`
84
+
85
+ Changes who receives rewards:
86
+ - Validates delegatee is still valid
87
+ - Settles existing rewards
88
+ - Transfers earning power to new beneficiary
89
+
90
+ ---
91
+
92
+ ### Active → Withdrawal Initiated
93
+
94
+ **Function**: `initiateWithdraw()`
95
+
96
+ Begins withdrawal process:
97
+ - Calls internal withdraw logic that:
98
+ - Sets `delegatee = address(0)`
99
+ - Moves stake amount to `pendingWithdrawal`
100
+ - Claims any pending rewards
101
+ - For external deposits: Calls `DelegationProxy.redelegate(address(0))`
102
+ - For self-owned deposits: Sets `pendingWithdrawal = 0` (immediate withdrawal)
103
+
104
+ **Special Case**: If `owner == address(this)`, the deposit goes directly to Withdrawn state.
105
+
106
+ ---
107
+
108
+ ### Withdrawal Initiated → Withdrawn
109
+
110
+ **Function**: `withdraw()`
111
+
112
+ Completes withdrawal:
113
+ - Validates `pendingWithdrawal > 0`
114
+ - Validates `owner != address(this)` (self-owned deposits cannot withdraw)
115
+ - Sets `pendingWithdrawal = 0`
116
+ - Transfers tokens from proxy to owner
117
+
118
+ ---
119
+
120
+ ### Withdrawal Initiated → Active
121
+
122
+ **Function**: `redelegate()` (when `pendingWithdrawal > 0`)
123
+
124
+ Restakes pending withdrawal:
125
+ - Validates new delegatee is operator or space
126
+ - Calls `increaseStake()` with `pendingWithdrawal` amount
127
+ - Sets `deposit.delegatee = newDelegatee`
128
+ - Sets `deposit.pendingWithdrawal = 0`
129
+ - Calls `DelegationProxy.redelegate()` to update delegation
130
+
131
+ This is a special recovery mechanism allowing users to restake without completing withdrawal.
132
+
133
+ ---
134
+
135
+ ## State Transition Diagram
136
+
137
+ ```
138
+ ┌─────────────┐
139
+ │ │
140
+ │ Non-existent│
141
+ │ │
142
+ └──────┬──────┘
143
+ │ stake(), permitAndStake(), stakeOnBehalf()
144
+
145
+ ┌─────────────────────────────────────────┐
146
+ │ │
147
+ │ Active │
148
+ │ • Earning rewards │
149
+ │ • Delegated to operator/space │
150
+ │ │◄────┐
151
+ └──┬──────────┬───────────────────────┬──┘ │
152
+ │ │ │ │
153
+ │ │ │ │
154
+ │ │ increaseStake() │ │
155
+ │ │ permitAndIncreaseStake() │
156
+ │ │ redelegate() │ │
157
+ │ │ changeBeneficiary() │ │
158
+ │ └───────────────────────┘ │
159
+ │ │
160
+ │ initiateWithdraw() │
161
+ ▼ │
162
+ ┌─────────────────────────────────────────┐ │
163
+ │ │ │
164
+ │ Withdrawal Initiated │ │
165
+ │ • No rewards │ │
166
+ │ • Funds in proxy, pending withdrawal │ │
167
+ │ │ │
168
+ └──┬──────────────────────────────────┬───┘ │
169
+ │ │ │
170
+ │ withdraw() │ │
171
+ │ │ │
172
+ ▼ │ │
173
+ ┌─────────────────────────────────────────┐ │
174
+ │ │ │
175
+ │ Withdrawn │ │
176
+ │ • Empty deposit │ │
177
+ │ • No active stake │ │
178
+ │ │ │
179
+ └─────────────────────────────────────────┘ │
180
+
181
+ redelegate() ──────────────┘
182
+ ```
183
+
184
+ ## Function Availability by State
185
+
186
+ | Function | Non-existent | Active | Withdrawal Initiated | Withdrawn |
187
+ |--------------------------|--------------|--------------------------|----------------------|--------------|
188
+ | stake() | ✅ Creates | ❌ | ❌ | ❌ |
189
+ | permitAndStake() | ✅ Creates | ❌ | ❌ | ❌ |
190
+ | stakeOnBehalf() | ✅ Creates | ❌ | ❌ | ❌ |
191
+ | increaseStake() | ❌ | ✅ | ❌ | ❌ |
192
+ | permitAndIncreaseStake() | ❌ | ✅ | ❌ | ❌ |
193
+ | redelegate() | ❌ | ✅ Same state | ✅ → Active | ⚠️ Edge case |
194
+ | changeBeneficiary() | ❌ | ✅ | ❌ | ❌ |
195
+ | initiateWithdraw() | ❌ | ✅ → Withdrawal Initiated | ❌ | ❌ |
196
+ | withdraw() | ❌ | ❌ | ✅ → Withdrawn | ❌ |
197
+
198
+ ## Special Cases
199
+
200
+ ### Self-Owned Deposits (owner == address(this))
201
+
202
+ When a deposit is owned by the contract itself:
203
+
204
+ - **No delegation proxy** is used
205
+ - **initiateWithdraw()**: Sets `pendingWithdrawal = 0` immediately (goes straight to Withdrawn)
206
+ - **withdraw()**: Reverts with `RewardsDistribution__CannotWithdrawFromSelf`
207
+
208
+ This is used internally by the protocol for special staking mechanisms.
209
+
210
+ ### Redelegate with Pending Withdrawal
211
+
212
+ When `redelegate()` is called on a deposit in "Withdrawal Initiated" state:
213
+
214
+ - Instead of calling `staking.redelegate()`, it calls `staking.increaseStake()`
215
+ - Treats the `pendingWithdrawal` as a new stake increase
216
+ - Manually sets `deposit.delegatee` to the new delegatee
217
+ - Manually sets `deposit.pendingWithdrawal = 0`
218
+ - Effectively "cancels" the withdrawal and restakes to a new delegatee
219
+
220
+ This provides a recovery path without requiring users to complete withdrawal and create a new deposit.
221
+
222
+ ## Validation Rules
223
+
224
+ ### Owner Validation
225
+ - All state-changing functions require `msg.sender == deposit.owner` (via `_revertIfNotDepositOwner()`)
226
+
227
+ ### Delegatee Validation
228
+ - `increaseStake()`, `redelegate()`, `changeBeneficiary()`: Require delegatee to be a valid operator or space
229
+ - Validation happens via `_revertIfNotOperatorOrSpace(delegatee)`
230
+
231
+ ### Amount Validation
232
+ - `withdraw()`: Requires `pendingWithdrawal > 0`
233
+
234
+ ## Implementation Notes
235
+
236
+ ### Reward Settlement
237
+ - Most state transitions trigger reward settlement via the internal `StakingRewards` library
238
+ - `_sweepSpaceRewardsIfNecessary()` is called to transfer space delegation rewards to operators
239
+
240
+ ### Delegation Proxy Lifecycle
241
+ - Proxy is deployed when first stake is created (for non-self-owned deposits)
242
+ - Proxy persists through the entire deposit lifecycle
243
+ - Proxy's delegation target is updated via `redelegate()` calls
244
+ - Tokens are transferred from/to proxy during stake/withdrawal operations
245
+
246
+ ### Commission Rates
247
+ - Commission rate is fetched at the time of each operation
248
+ - For space delegatees, the commission rate of their active operator is used
249
+ - Rate changes don't affect existing positions until next state transition
250
+
251
+ ## References
252
+
253
+ - Main contract: `RewardsDistributionV2.sol` lines 94-242
254
+ - Base implementation: `RewardsDistributionBase.sol`
255
+ - Storage library: `StakingRewards.sol`
256
+ - Proxy implementation: `DelegationProxy.sol`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towns-protocol/contracts",
3
- "version": "0.0.364",
3
+ "version": "0.0.366",
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.364",
38
+ "@towns-protocol/prettier-config": "^0.0.366",
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": "14393591e10dacdc499c872086597abae8aa2fe1"
60
+ "gitHead": "ed5b2f3718b5da967d60c30fc589bdf20568f992"
61
61
  }
@@ -40,6 +40,7 @@ interface ISubscriptionModuleBase {
40
40
  error SubscriptionModule__InvalidTokenOwner();
41
41
  error SubscriptionModule__InsufficientBalance();
42
42
  error SubscriptionModule__ActiveSubscription();
43
+ error SubscriptionModule__MembershipBanned();
43
44
  /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
44
45
  /* Events */
45
46
  /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
@@ -107,10 +108,10 @@ interface ISubscriptionModule is ISubscriptionModuleBase {
107
108
  uint32 entityId
108
109
  ) external view returns (Subscription memory);
109
110
 
110
- /// @notice Gets the renewal buffer for an expiration time
111
- /// @param expirationTime The expiration time to get the renewal buffer for
112
- /// @return The renewal buffer for the expiration time
113
- function getRenewalBuffer(uint256 expirationTime) external view returns (uint256);
111
+ /// @notice Gets the renewal buffer for a membership duration
112
+ /// @param duration The membership duration to get the renewal buffer for
113
+ /// @return The renewal buffer for the duration
114
+ function getRenewalBuffer(uint256 duration) external pure returns (uint256);
114
115
 
115
116
  /// @notice Activates a subscription
116
117
  /// @param entityId The entity ID of the subscription to activate
@@ -9,6 +9,7 @@ import {IValidationModule} from "@erc6900/reference-implementation/interfaces/IV
9
9
  import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
10
10
  import {ISubscriptionModule} from "./ISubscriptionModule.sol";
11
11
  import {IMembership} from "../../../spaces/facets/membership/IMembership.sol";
12
+ import {IBanning} from "../../../spaces/facets/banning/IBanning.sol";
12
13
 
13
14
  // libraries
14
15
  import {PackedUserOperation} from "@eth-infinitism/account-abstraction/interfaces/PackedUserOperation.sol";
@@ -19,6 +20,7 @@ import {ReentrancyGuardTransient} from "solady/utils/ReentrancyGuardTransient.so
19
20
  import {CustomRevert} from "../../../utils/libraries/CustomRevert.sol";
20
21
  import {Validator} from "../../../utils/libraries/Validator.sol";
21
22
  import {Subscription, SubscriptionModuleStorage} from "./SubscriptionModuleStorage.sol";
23
+ import {SafeCastLib} from "solady/utils/SafeCastLib.sol";
22
24
 
23
25
  // contracts
24
26
  import {ModuleBase} from "modular-account/src/modules/ModuleBase.sol";
@@ -38,6 +40,7 @@ contract SubscriptionModuleFacet is
38
40
  {
39
41
  using EnumerableSetLib for EnumerableSetLib.Uint256Set;
40
42
  using EnumerableSetLib for EnumerableSetLib.AddressSet;
43
+ using SafeCastLib for uint256;
41
44
  using CustomRevert for bytes4;
42
45
 
43
46
  uint256 internal constant _SIG_VALIDATION_FAILED = 1;
@@ -77,23 +80,27 @@ contract SubscriptionModuleFacet is
77
80
 
78
81
  if (entityId == 0) SubscriptionModule__InvalidEntityId.selector.revertWith();
79
82
 
83
+ if (IBanning(space).isBanned(tokenId))
84
+ SubscriptionModule__MembershipBanned.selector.revertWith();
85
+
80
86
  if (IERC721(space).ownerOf(tokenId) != msg.sender)
81
87
  SubscriptionModule__InvalidTokenOwner.selector.revertWith();
82
88
 
83
- IMembership membershipFacet = IMembership(space);
84
- uint256 expiresAt = membershipFacet.expiresAt(tokenId);
85
-
86
89
  SubscriptionModuleStorage.Layout storage $ = SubscriptionModuleStorage.getLayout();
87
90
 
88
91
  if (!$.entityIds[msg.sender].add(entityId))
89
92
  SubscriptionModule__InvalidEntityId.selector.revertWith();
90
93
 
94
+ IMembership membershipFacet = IMembership(space);
95
+ uint256 expiresAt = membershipFacet.expiresAt(tokenId);
96
+ uint256 duration = membershipFacet.getMembershipDuration();
97
+
91
98
  Subscription storage sub = $.subscriptions[msg.sender][entityId];
92
99
  sub.space = space;
93
100
  sub.active = true;
94
101
  sub.tokenId = tokenId;
95
- sub.installTime = uint40(block.timestamp);
96
- sub.nextRenewalTime = _calculateNextRenewalTime(expiresAt, sub.installTime);
102
+ sub.installTime = block.timestamp.toUint40();
103
+ sub.nextRenewalTime = _calculateBaseRenewalTime(expiresAt, duration);
97
104
 
98
105
  emit SubscriptionConfigured(
99
106
  msg.sender,
@@ -220,6 +227,16 @@ contract SubscriptionModuleFacet is
220
227
  continue;
221
228
  }
222
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
+
223
240
  // Skip if account isn't owner anymore (for safety)
224
241
  if (IERC721(sub.space).ownerOf(sub.tokenId) != params[i].account) {
225
242
  _pauseSubscription(sub, params[i].account, params[i].entityId);
@@ -242,10 +259,11 @@ contract SubscriptionModuleFacet is
242
259
  }
243
260
 
244
261
  uint256 expiresAt = membershipFacet.expiresAt(sub.tokenId);
245
- uint40 correctNextRenewalTime = _calculateNextRenewalTime(expiresAt, sub.installTime);
262
+ uint256 duration = membershipFacet.getMembershipDuration();
263
+ uint40 nextRenewalTime = _calculateBaseRenewalTime(expiresAt, duration);
246
264
 
247
- if (sub.nextRenewalTime != correctNextRenewalTime) {
248
- sub.nextRenewalTime = correctNextRenewalTime;
265
+ if (sub.nextRenewalTime != nextRenewalTime) {
266
+ sub.nextRenewalTime = nextRenewalTime;
249
267
  emit SubscriptionSynced(params[i].account, params[i].entityId, sub.nextRenewalTime);
250
268
  }
251
269
 
@@ -262,8 +280,8 @@ contract SubscriptionModuleFacet is
262
280
  }
263
281
 
264
282
  /// @inheritdoc ISubscriptionModule
265
- function getRenewalBuffer(uint256 expirationTime) external view returns (uint256) {
266
- return _getRenewalBuffer(expirationTime);
283
+ function getRenewalBuffer(uint256 duration) external pure returns (uint256) {
284
+ return _getRenewalBuffer(duration);
267
285
  }
268
286
 
269
287
  /// @inheritdoc ISubscriptionModule
@@ -282,9 +300,10 @@ contract SubscriptionModuleFacet is
282
300
 
283
301
  IMembership membershipFacet = IMembership(sub.space);
284
302
  uint256 expiresAt = membershipFacet.expiresAt(sub.tokenId);
303
+ uint256 duration = membershipFacet.getMembershipDuration();
285
304
 
286
305
  // 6. Always sync renewal time to current membership state
287
- uint40 correctNextRenewalTime = _calculateNextRenewalTime(expiresAt, sub.installTime);
306
+ uint40 correctNextRenewalTime = _calculateBaseRenewalTime(expiresAt, duration);
288
307
  if (sub.nextRenewalTime != correctNextRenewalTime) {
289
308
  sub.nextRenewalTime = correctNextRenewalTime;
290
309
  emit SubscriptionSynced(msg.sender, entityId, sub.nextRenewalTime);
@@ -388,8 +407,15 @@ contract SubscriptionModuleFacet is
388
407
 
389
408
  // Get the actual new expiration time after successful renewal
390
409
  uint256 newExpiresAt = membershipFacet.expiresAt(sub.tokenId);
391
- sub.nextRenewalTime = _calculateNextRenewalTime(newExpiresAt, sub.installTime);
392
- sub.lastRenewalTime = uint40(block.timestamp);
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();
393
419
  sub.spent += actualRenewalPrice;
394
420
 
395
421
  emit SubscriptionRenewed(
@@ -403,59 +429,75 @@ contract SubscriptionModuleFacet is
403
429
  emit SubscriptionSpent(params.account, params.entityId, actualRenewalPrice, sub.spent);
404
430
  }
405
431
 
406
- /// @dev Determines the appropriate renewal buffer time based on original membership duration
407
- /// @param expirationTime The expiration timestamp of the membership
408
- /// @param installTime The time when the subscription was installed
432
+ /// @dev Determines the appropriate renewal buffer time based on membership duration
433
+ /// @param duration The membership duration in seconds
409
434
  /// @return The appropriate buffer time in seconds before expiration
410
- function _getRenewalBuffer(
411
- uint256 expirationTime,
412
- uint256 installTime
413
- ) internal pure returns (uint256) {
414
- uint256 originalDuration = expirationTime >= installTime ? expirationTime - installTime : 0;
415
-
435
+ function _getRenewalBuffer(uint256 duration) internal pure returns (uint256) {
416
436
  // For memberships shorter than 1 hour, use immediate buffer (2 minutes)
417
- if (originalDuration <= 1 hours) return BUFFER_IMMEDIATE;
437
+ if (duration <= 1 hours) return BUFFER_IMMEDIATE;
418
438
 
419
439
  // For memberships shorter than 6 hours, use short buffer (1 hour)
420
- if (originalDuration <= 6 hours) return BUFFER_SHORT;
440
+ if (duration <= 6 hours) return BUFFER_SHORT;
421
441
 
422
442
  // For memberships shorter than 24 hours, use medium buffer (6 hours)
423
- if (originalDuration <= 24 hours) return BUFFER_MEDIUM;
443
+ if (duration <= 24 hours) return BUFFER_MEDIUM;
424
444
 
425
445
  // For memberships longer than 24 hours, use long buffer (12 hours)
426
446
  return BUFFER_LONG;
427
447
  }
428
448
 
429
- /// @dev Legacy function for backward compatibility - uses current time as install time
449
+ /// @dev Calculates the base renewal time without minimum buffer enforcement
430
450
  /// @param expirationTime The expiration timestamp of the membership
431
- /// @return The appropriate buffer time in seconds before expiration
432
- function _getRenewalBuffer(uint256 expirationTime) internal view returns (uint256) {
433
- return _getRenewalBuffer(expirationTime, block.timestamp);
434
- }
435
-
436
- /// @dev Calculates the correct next renewal time for a given expiration using install time
437
- /// @param expirationTime The expiration timestamp of the membership
438
- /// @param installTime The time when the subscription was installed
439
- /// @return The next renewal time as uint40
440
- function _calculateNextRenewalTime(
451
+ /// @param duration The membership duration in seconds
452
+ /// @return The base renewal time as uint40
453
+ function _calculateBaseRenewalTime(
441
454
  uint256 expirationTime,
442
- uint256 installTime
455
+ uint256 duration
443
456
  ) internal view returns (uint40) {
444
- if (expirationTime <= block.timestamp) return uint40(block.timestamp);
457
+ // If membership is already expired, schedule for the future
458
+ if (expirationTime <= block.timestamp) {
459
+ return (block.timestamp + duration).toUint40();
460
+ }
445
461
 
446
- uint256 buffer = _getRenewalBuffer(expirationTime, installTime);
462
+ uint256 buffer = _getRenewalBuffer(duration);
447
463
  uint256 timeUntilExpiration = expirationTime - block.timestamp;
448
464
 
449
- if (buffer >= timeUntilExpiration) return uint40(block.timestamp);
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
+ }
450
470
 
451
- return uint40(expirationTime - buffer);
471
+ return (expirationTime - buffer).toUint40();
452
472
  }
453
473
 
454
- /// @dev Legacy function for backward compatibility - uses current time as install time
474
+ /// @dev Enforces minimum buffer to prevent double renewals
475
+ /// @param baseTime The base calculated renewal time
455
476
  /// @param expirationTime The expiration timestamp of the membership
456
- /// @return The next renewal time as uint40
457
- function _calculateNextRenewalTime(uint256 expirationTime) internal view returns (uint40) {
458
- return _calculateNextRenewalTime(expirationTime, block.timestamp);
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();
459
501
  }
460
502
 
461
503
  /// @dev Creates the runtime final data for the renewal
@@ -11,17 +11,27 @@ struct Subscription {
11
11
  uint40 lastRenewalTime; // 5 bytes
12
12
  uint40 nextRenewalTime; // 5 bytes
13
13
  bool active; // 1 byte
14
+ uint64 duration;
15
+ }
16
+
17
+ struct OperatorConfig {
18
+ uint256 interval;
19
+ uint256 buffer;
14
20
  }
15
21
 
16
22
  library SubscriptionModuleStorage {
17
23
  using EnumerableSetLib for EnumerableSetLib.Uint256Set;
18
24
  using EnumerableSetLib for EnumerableSetLib.AddressSet;
19
25
 
26
+ uint256 public constant KEEPER_INTERVAL = 5 minutes;
27
+ uint256 public constant MIN_RENEWAL_BUFFER = KEEPER_INTERVAL + 1 minutes; // Minimum buffer to prevent double renewals
28
+
20
29
  /// @custom:storage-location erc7201:towns.subscription.validation.module.storage
21
30
  struct Layout {
22
31
  EnumerableSetLib.AddressSet operators;
23
32
  mapping(address account => mapping(uint32 entityId => Subscription)) subscriptions;
24
33
  mapping(address account => EnumerableSetLib.Uint256Set entityIds) entityIds;
34
+ mapping(address operator => OperatorConfig) operatorConfig;
25
35
  }
26
36
 
27
37
  // keccak256(abi.encode(uint256(keccak256("towns.subscription.validation.module.storage")) - 1)) & ~bytes32(uint256(0xff))
@@ -33,4 +43,10 @@ library SubscriptionModuleStorage {
33
43
  $.slot := STORAGE_SLOT
34
44
  }
35
45
  }
46
+
47
+ function getOperatorBuffer(address operator) internal view returns (uint256) {
48
+ OperatorConfig storage config = getLayout().operatorConfig[operator];
49
+ if (config.interval == 0) return MIN_RENEWAL_BUFFER;
50
+ return config.buffer;
51
+ }
36
52
  }