Skip to content

Commit 2080ac2

Browse files
committed
first pass
1 parent d522a38 commit 2080ac2

File tree

3 files changed

+232
-0
lines changed

3 files changed

+232
-0
lines changed

documents/CaveatEnforcers.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,110 @@ This redemption may alter the state of other contracts. For example, the balance
2525
Consider a scenario where D1 includes an array of caveats: one caveat is the `NativeBalanceGteEnforcer`, which verifies that Bob’s balance has increased as a result of the execution attached to D1. The second caveat is the `NativeTokenPaymentEnforcer`, which deducts from Bob’s balance by redeeming D2. If these enforcers are not correctly ordered, they could conflict. For instance, if the `NativeTokenPaymentEnforcer` is executed before the `NativeBalanceGteEnforcer`, Bob’s balance would be reduced first, potentially causing the `NativeBalanceGteEnforcer` to fail its validation of ensuring Bob’s balance exceeds a certain threshold.
2626

2727
Because the `NativeTokenPaymentEnforcer` modifies the state of external contracts, it is essential to carefully order enforcers in the delegation to prevent conflicts. The enforcers are designed to protect the execution process, but they do not guarantee a final state after the redemption. This means that even if the `NativeBalanceGteEnforcer` validates Bob’s balance at one point, subsequent enforcers, such as the `NativeTokenPaymentEnforcer`, may modify it later.
28+
29+
### ERC20SubscriptionEnforcer
30+
31+
A delegate (i.e., redeemer) can use the `ERC20SubscriptionEnforcer` to transfer ERC20 tokens from the delegator's account once every `x` day.
32+
33+
Given an initial timestamp from the redeemer, the `next allowed timestamp` is calculated dynamically, checking this value against the current `block.timestamp` to ensure the redemption fits within the next cycle before execution. `ERC20SubscriptionEnforcer` will enforce the following constraints:
34+
35+
1. The redeemer can only redeem the subscription token amount once per cycle, preventing duplicate claims in the same cycle
36+
2. The redeemer can claim missed a cycle. They skip a claim period at a later date.
37+
38+
### References
39+
40+
Here are a few implementations of subscriptions in the wild that we have taken some inspiration from:
41+
42+
- [OG delegatable framework DistrictERC20PermitSubscriptionsEnforcer](https://github.com/district-labs/delegatable-enforcers/blob/main/contracts/DistrictERC20PermitSubscriptionsEnforcer.sol)
43+
- [Coinbase SpendPermissionManager](https://github.com/coinbase/spend-permissions/blob/main/src/SpendPermissionManager.sol)
44+
45+
### Caveat input:
46+
47+
#### Start Timestamp
48+
49+
An initial timestamp is passed into the caveat args to determine the subscription's start date.
50+
51+
---
52+
53+
#### Formulas:
54+
55+
Below is a set of formulas used in the `ERC20SubscriptionEnforcer` to enforce on-chain subscriptions.
56+
57+
#### Elapsed time
58+
59+
Determines in **seconds** how much time has passed since the subscription start date.
60+
61+
```math
62+
\text{elapsedTime} = \text{block.timestamp} - \text{startTimestamp}
63+
```
64+
65+
---
66+
67+
#### Periods passed(cycles)
68+
69+
We will use integer division to dynamically determine how many full `x-day` periods have passed since the `elapsed time` while ignoring any remainder(i.e., accumulated extra days/hours toward the current cycle in progress). We `elapsedTime` evaluates to `0`. No full cycle has passed, and the first cycle is in progress.
70+
71+
```math
72+
\text{periodsPassed} = \frac{\text{elapsedTime}}{30 \text{ days}}
73+
```
74+
75+
For example:
76+
77+
- If an of `elapsedTime = 75 days`, then:
78+
\[
79+
\frac{75}{30} = 2
80+
\]
81+
(meaning **2 full cycles** have passed and the **third cycle** is in progress. Once the 90-day mark is reached, the redeemer can claim tokens for the third cycle).
82+
83+
For example:
84+
85+
- If an of `elapsedTime = 10 days`, then:
86+
\[
87+
\frac{10}{30} = 0
88+
\]
89+
(meaning **first full cycles** is in progress).
90+
91+
---
92+
93+
#### Next valid timestamp
94+
95+
We can use a simple calculation with values derived from the previous formula to determine the next timestamp the redeemer is eligible to claim the subscription token amount.
96+
97+
Since `periodsPassed` will give us the last completed cycle, we need to `+1` to know where to start the next cycle (i.e., cycle actively in progress).
98+
99+
```math
100+
\text{nextValidTimestamp} = \text{startTimestamp} + (\text{periodsPassed} + 1) \times 30 \text{ days}
101+
```
102+
103+
---
104+
105+
##### Example Calculation
106+
107+
Given the following input for the `ERC20SubscriptionEnforcer`:
108+
109+
- `cycle = 30 days`
110+
- `startTimestamp = 1,000,000` (Unix time)(Mon Jan 12 1970 13:46:40)
111+
- `block.timestamp = 1,090,000` (current time)(Tue Jan 13 1970 14:46:40)
112+
113+
1. Compute `elapsedTime`
114+
\[
115+
\text{elapsedTime} = 1,090,000 - 1,000,000 = 90,000 \text{ seconds}
116+
\]
117+
118+
2. Compute `periodsPassed`
119+
\[
120+
\text{periodsPassed} = \frac{90,000}{30 \times 24 \times 60 \times 60} = \frac{90,000}{2,592,000} = 0
121+
\]
122+
123+
(No full periods have passed, since `90,000` seconds is **less than 30 days**. The first cycle is still in progress.)
124+
125+
1. Compute `nextValidTimestamp`
126+
\[
127+
\text{nextValidTimestamp} = 1,000,000 + (0 + 1) \times 2,592,000
128+
\]
129+
130+
\[
131+
= 1,000,000 + 2,592,000 = 3,592,000
132+
\]
133+
134+
(This means the redeemer can submit a transaction with a timestamp **3,592,000** or later to claim the token amount for the first 30-day cycle.)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// SPDX-License-Identifier: MIT AND Apache-2.0
2+
pragma solidity 0.8.23;
3+
4+
import { CaveatEnforcer } from "./CaveatEnforcer.sol";
5+
6+
/**
7+
* @title ERC20 Subscription Enforcer Contract
8+
* @dev
9+
*/
10+
contract ERC20SubscriptionEnforcer {
11+
////////////////////////////// State //////////////////////////////
12+
13+
// TODO: use similar logic found in LimitedCallsEnforcer to handle case for missed periods
14+
mapping(uint256 => bool) public periodCallsClaimed;
15+
uint256 public immutable START_TIMESTAMP;
16+
uint256 public immutable PERIOD_DURATION; // Number of days per period in seconds
17+
18+
event Subscribed(address indexed user, uint256 nextAllowedTime);
19+
event FulfilMissedSubscribed(address indexed user, uint256 missedPeriod);
20+
21+
////////////////////////////// External Methods //////////////////////////////
22+
23+
constructor(uint256 _startTimestamp, uint256 _periodDurationInDays) {
24+
START_TIMESTAMP = _startTimestamp;
25+
PERIOD_DURATION = _periodDurationInDays * 1 days;
26+
}
27+
28+
function canSubscribe() public view returns (bool) {
29+
return block.timestamp >= getNextValidTimestamp();
30+
}
31+
32+
function getCurrentPeriod() public view returns (uint256) {
33+
return (block.timestamp - START_TIMESTAMP) / PERIOD_DURATION;
34+
}
35+
36+
function getNextValidTimestamp() public view returns (uint256) {
37+
uint256 elapsedTime = block.timestamp - START_TIMESTAMP;
38+
uint256 periodsPassed = elapsedTime / PERIOD_DURATION;
39+
return START_TIMESTAMP + (periodsPassed + 1) * PERIOD_DURATION;
40+
}
41+
42+
function fulfilMissedSubscribe(uint256 missedPeriod) external {
43+
require(periodCallsClaimed[missedPeriod] == false, "Already claimed for this period");
44+
45+
periodCallsClaimed[missedPeriod] = true;
46+
47+
emit FulfilMissedSubscribed(msg.sender, missedPeriod);
48+
}
49+
50+
function subscribe() external {
51+
uint256 currentPeriod = getCurrentPeriod();
52+
53+
require(canSubscribe(), "Subscription period not reached");
54+
require(periodCallsClaimed[currentPeriod] == false, "Already claimed for this period");
55+
56+
periodCallsClaimed[currentPeriod] = true;
57+
58+
emit Subscribed(msg.sender, getNextValidTimestamp());
59+
}
60+
61+
////////////////////////////// Public Methods //////////////////////////////
62+
63+
////////////////////////////// Internal Methods //////////////////////////////
64+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// SPDX-License-Identifier: MIT AND Apache-2.0
2+
pragma solidity 0.8.23;
3+
4+
import { CaveatEnforcer } from "./CaveatEnforcer.sol";
5+
6+
/**
7+
* @title ERC2 Thirty Day Subscription Enforcer Contract
8+
* @dev
9+
*/
10+
contract ERC2ThirtyDaySubscriptionEnforcer {
11+
////////////////////////////// State //////////////////////////////
12+
13+
mapping(uint256 => bool) public periodCallsClaimed;
14+
uint256 public immutable START_TIMESTAMP;
15+
16+
event Subscribed(address indexed user, uint256 nextAllowedTime);
17+
event FulfilMissedSubscribed(address indexed user, uint256 missedPeriod);
18+
19+
////////////////////////////// External Methods //////////////////////////////
20+
21+
constructor(uint256 _startTimestamp) {
22+
START_TIMESTAMP = _startTimestamp;
23+
}
24+
25+
function canSubscribe() public view returns (bool) {
26+
return block.timestamp >= getNextValidTimestamp();
27+
}
28+
29+
function getCurrentPeriod() public view returns (uint256) {
30+
return (block.timestamp - START_TIMESTAMP) / 30 days;
31+
}
32+
33+
function getNextValidTimestamp() public view returns (uint256) {
34+
uint256 elapsedTime = block.timestamp - START_TIMESTAMP;
35+
uint256 periodsPassed = elapsedTime / 30 days;
36+
return START_TIMESTAMP + (periodsPassed + 1) * 30 days;
37+
}
38+
39+
function fulfilMissedSubscribe(uint256 missedPeriod) external {
40+
require(periodCallsClaimed[missedPeriod] == false, "Already claimed for this period");
41+
42+
periodCallsClaimed[missedPeriod] = true;
43+
44+
emit FulfilMissedSubscribed(msg.sender, missedPeriod);
45+
}
46+
47+
function subscribe() external {
48+
uint256 currentPeriod = getCurrentPeriod();
49+
50+
require(canSubscribe(), "Subscription period not reached");
51+
require(periodCallsClaimed[currentPeriod] == false, "Already claimed for this period");
52+
53+
periodCallsClaimed[currentPeriod] = true;
54+
55+
emit Subscribed(msg.sender, getNextValidTimestamp());
56+
}
57+
58+
////////////////////////////// Public Methods //////////////////////////////
59+
60+
////////////////////////////// Internal Methods //////////////////////////////
61+
}

0 commit comments

Comments
 (0)