@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.
|
|
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.
|
|
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": "
|
|
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
|
|
111
|
-
/// @param
|
|
112
|
-
/// @return The renewal buffer for the
|
|
113
|
-
function getRenewalBuffer(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 =
|
|
96
|
-
sub.nextRenewalTime =
|
|
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
|
-
|
|
262
|
+
uint256 duration = membershipFacet.getMembershipDuration();
|
|
263
|
+
uint40 nextRenewalTime = _calculateBaseRenewalTime(expiresAt, duration);
|
|
246
264
|
|
|
247
|
-
if (sub.nextRenewalTime !=
|
|
248
|
-
sub.nextRenewalTime =
|
|
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
|
|
266
|
-
return _getRenewalBuffer(
|
|
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 =
|
|
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
|
-
|
|
392
|
-
|
|
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
|
|
407
|
-
/// @param
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
449
|
+
/// @dev Calculates the base renewal time without minimum buffer enforcement
|
|
430
450
|
/// @param expirationTime The expiration timestamp of the membership
|
|
431
|
-
/// @
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
455
|
+
uint256 duration
|
|
443
456
|
) internal view returns (uint40) {
|
|
444
|
-
|
|
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(
|
|
462
|
+
uint256 buffer = _getRenewalBuffer(duration);
|
|
447
463
|
uint256 timeUntilExpiration = expirationTime - block.timestamp;
|
|
448
464
|
|
|
449
|
-
if (buffer >= timeUntilExpiration)
|
|
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
|
|
471
|
+
return (expirationTime - buffer).toUint40();
|
|
452
472
|
}
|
|
453
473
|
|
|
454
|
-
/// @dev
|
|
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
|
-
/// @
|
|
457
|
-
|
|
458
|
-
|
|
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
|
}
|