Skip to content

Commit fd5439f

Browse files
authored
Improve event signatures and subscriber cancellation flow (#123)
* checkpoint * new cancel flow * checkpoint * fixed some warnings * Better events * tests and docs updated * forge fmt * modified subscription cancelled and revoked events and added new subscription removed event * Tests fixed, new tests added and docs updated * minor rename
1 parent bf5556b commit fd5439f

6 files changed

Lines changed: 272 additions & 390 deletions

File tree

README.md

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ New assets are appended to the `assets` array of the corresponding registry in `
9898

9999
### Subscribe
100100

101-
Subscribe to an asset using ERC-2612 permit (gasless approval). The payer signs the permit and pays with tokens; the subscription is associated with a **subscriber** identity (a `bytes32` hash). For cancellation, the subscriber identity should be derived as `keccak256(abi.encode(subscriberId, subscriberAddress))`, so only that `subscriberAddress` can cancel using a signed cancellation flow. The payer and the subscriber can be the same or different (e.g. "pay for someone else"). The **payer** is the address entitled to refunds if the subscription is later cancelled or revoked (unearned time is refunded to the payer).
101+
Subscribe to an asset using ERC-2612 permit (gasless approval). The payer signs the permit and pays with tokens; the subscription is associated with a **subscriber** identity (a `bytes32` hash). For cancellation, the subscriber identity should be derived as `keccak256(abi.encode(subscriberId, subscriberAddress))`, so only that `subscriberAddress` can cancel by signing an off-chain message and calling `cancelSubscription` in one step (no on-chain commit or timestamp binding). The payer and the subscriber can be the same or different (e.g. "pay for someone else"). The **payer** is the address entitled to refunds if the subscription is later cancelled or revoked (unearned time is refunded to the payer).
102102

103103
```bash
104104
./scripts/subscribe.sh <registry_index> <asset_id> <subscriber_id> <value> <payer_private_key>
@@ -269,7 +269,7 @@ All external functions for the registry and asset contracts, for use with JSON-R
269269
270270
---
271271
272-
**subscribe** : Subscribes a subscriber to the asset using ERC-2612 permit; forwards to the asset contract. The permit is signed by the payer; the subscription is attributed to `_subscriber` (payer and subscriber can differ). The payer is the refund beneficiary on cancel/revoke. For cancellation-compatible identity, `_subscriber` should be `keccak256(abi.encode(subscriberId, subscriberAddress))`, where `subscriberAddress` is the address that will commit/sign cancellation.
272+
**subscribe** : Subscribes a subscriber to the asset using ERC-2612 permit; forwards to the asset contract. The permit is signed by the payer; the subscription is attributed to `_subscriber` (payer and subscriber can differ). The payer is the refund beneficiary on cancel/revoke. For cancellation-compatible identity, `_subscriber` should be `keccak256(abi.encode(subscriberId, subscriberAddress))`, where `subscriberAddress` is the address that will call `cancelSubscription` and sign the cancellation payload.
273273
- Type: write
274274
- Permission: none
275275
- Parameters:
@@ -474,7 +474,7 @@ All external functions for the registry and asset contracts, for use with JSON-R
474474
475475
---
476476
477-
**subscribe** : Subscribes a subscriber using ERC-2612 permit: payer signs permit, then payment is pulled and subscription is attributed to the given subscriber. Payer and subscriber can differ (e.g. pay for someone else). The payer is the refund beneficiary on cancel/revoke. For cancellation-compatible identity, `subscriber` should be `keccak256(abi.encode(subscriberId, subscriberAddress))`, where `subscriberAddress` is the address that will commit/sign cancellation.
477+
**subscribe** : Subscribes a subscriber using ERC-2612 permit: payer signs permit, then payment is pulled and subscription is attributed to the given subscriber. Payer and subscriber can differ (e.g. pay for someone else). The payer is the refund beneficiary on cancel/revoke. For cancellation-compatible identity, `subscriber` should be `keccak256(abi.encode(subscriberId, subscriberAddress))`, where `subscriberAddress` is the address that will call `cancelSubscription` and sign the cancellation payload.
478478
- Type: write
479479
- Permission: none
480480
- Parameters:
@@ -546,23 +546,12 @@ All external functions for the registry and asset contracts, for use with JSON-R
546546
547547
---
548548
549-
**commitCancellation** : Commits a cancellation intent for the caller's `(subscriberId, msg.sender)` pair.
549+
**cancelSubscription** : Cancels the caller's subscription after validating an EIP-191 signature from `msg.sender`. Unearned subscription value is refunded to each original payer. There is no separate on-chain commit step: the subscriber signs an off-chain message, then submits one transaction with that signature.
550550
- Type: write
551-
- Permission: caller is the subscriber address committing cancellation
551+
- Permission: `msg.sender` must be the subscriber address represented in `keccak256(abi.encode(subscriberId, msg.sender))` (the recovered signer must equal `msg.sender`).
552552
- Parameters:
553553
- `string subscriberId` : Human-readable subscriber id used in the subscriber hash.
554-
- Returns:
555-
- `uint256` : Commitment timestamp used in the subsequent cancellation signature.
556-
557-
---
558-
559-
**cancelSubscription** : Cancels the caller's subscription after validating a prior commitment and signature. Unearned subscription value is refunded to each original payer.
560-
- Type: write
561-
- Permission: caller must be the subscriber address represented in `keccak256(abi.encode(subscriberId, msg.sender))`
562-
- Parameters:
563-
- `string subscriberId` : Human-readable subscriber id used in the subscriber hash.
564-
- `uint256 timestamp` : Commitment timestamp returned by `commitCancellation`.
565-
- `bytes signature` : ECDSA signature by `msg.sender` over `keccak256(abi.encodePacked(chainid, assetAddress, timestamp, subscriberHash))`.
554+
- `bytes signature` : ECDSA signature by `msg.sender` over the Ethereum signed message hash of `keccak256(abi.encodePacked(chainid, assetAddress, subscriber))`, where `subscriber` is `keccak256(abi.encode(subscriberId, msg.sender))` and `assetAddress` is this asset contract.
566555
- Returns: void
567556
568557
---
@@ -618,13 +607,26 @@ All events emitted by the registry and asset contracts. Use for indexing, loggin
618607
619608
---
620609
621-
**SubscriptionAdded** : Emitted when a new subscription record is created for a subscriber (new nonce). This happens on the first subscription and whenever the payer, subscription price, or registry fee share differs from the active subscription. For renewals that extend an existing active subscription under the same terms, see `SubscriptionExtended`.
610+
**SubscriptionAdded** : Emitted when the first subscription record is created for a subscriber.
622611
- Contract: `Asset`
623612
- Parameters:
624613
- `bytes32 indexed subscriber` : Subscriber identity (hash).
625614
- `uint256 indexed startTime` : Subscription start time (Unix timestamp).
626615
- `uint256 indexed endTime` : Subscription expiry time (Unix timestamp).
627-
- `uint256 nonce` : Subscription nonce (increments each time a new record is created for the subscriber).
616+
- `address payer` : Payer for this subscription (refund beneficiary on cancel/revoke).
617+
- `uint256 subscriptionPrice` : Per-second subscription price snapshot used for this subscription record.
618+
- `uint256 registryFeeShare` : Registry fee share snapshot (0-100) used for this subscription record.
619+
620+
621+
---
622+
623+
**SubscriptionRenewed** : Emitted when a subscriber already has prior subscription history and a new subscription record is created (new nonce). This occurs when in-place extension is not possible (e.g. prior subscription expired, or payer/price/registry fee share differs from the current active record).
624+
- Contract: `Asset`
625+
- Parameters:
626+
- `bytes32 indexed subscriber` : Subscriber identity (hash).
627+
- `uint256 indexed startTime` : New subscription record start time (Unix timestamp).
628+
- `uint256 indexed endTime` : New subscription record expiry time (Unix timestamp).
629+
- `uint256 nonce` : New subscription nonce for this subscriber.
628630
- `address payer` : Payer for this subscription (refund beneficiary on cancel/revoke).
629631
- `uint256 subscriptionPrice` : Per-second subscription price snapshot used for this subscription record.
630632
- `uint256 registryFeeShare` : Registry fee share snapshot (0-100) used for this subscription record.
@@ -671,11 +673,23 @@ All events emitted by the registry and asset contracts. Use for indexing, loggin
671673
- Contract: `Asset`
672674
- Parameters:
673675
- `bytes32 indexed subscriber` : Subscriber whose subscription was revoked.
676+
- `uint256 indexed nonce` : Active nonce after revocation/removal processing.
677+
- `uint256 indexed endTime` : Effective end time after revocation. Will be `0` when the subscriber is fully removed.
674678
675679
676680
---
677681
678682
**SubscriptionCancelled** : Emitted when a subscriber's subscription is cancelled through the subscriber-signed cancellation flow.
679683
- Contract: `Asset`
680684
- Parameters:
681-
- `bytes32 indexed subscriber` : Subscriber hash `keccak256(abi.encode(subscriberId, subscriberAddress))` whose subscription was cancelled.
685+
- `bytes32 indexed subscriber` : Subscriber hash `keccak256(abi.encode(subscriberId, subscriberAddress))` whose subscription was cancelled.
686+
- `uint256 indexed nonce` : Active nonce after cancellation/removal processing.
687+
- `uint256 indexed endTime` : Effective end time after cancellation. Will be `0` when the subscriber is fully removed.
688+
689+
690+
---
691+
692+
**SubscriptionRemoved** : Emitted when all remaining subscription records for a subscriber are deleted and the subscriber is removed from tracking state.
693+
- Contract: `Asset`
694+
- Parameters:
695+
- `bytes32 indexed subscriber` : Subscriber identity (hash) that was fully removed.

src/Asset.sol

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ contract Asset is Ownable, ReentrancyGuard, IAsset {
3131

3232
mapping(bytes32 => Subscription) internal subscriptions;
3333
mapping(bytes32 => uint256) internal nonces;
34-
mapping(bytes32 => uint256) internal cancellations;
3534

3635
mapping(bytes32 => uint256) internal creatorClaimedAtTimestamps;
3736
mapping(bytes32 => uint256) internal creatorClaimedAtNonces;
@@ -58,11 +57,19 @@ contract Asset is Ownable, ReentrancyGuard, IAsset {
5857
error SubscriptionNotFound();
5958
error SubscriptionRevocationFailed();
6059
error SubscriptionCancellationFailed();
61-
error InvalidCancellationCommitment();
6260
error InvalidSignature();
6361
error OnlyRegistryUnauthorizedAccount();
6462

6563
event SubscriptionAdded(
64+
bytes32 indexed subscriber,
65+
uint256 indexed startTime,
66+
uint256 indexed endTime,
67+
address payer,
68+
uint256 subscriptionPrice,
69+
uint256 registryFeeShare
70+
);
71+
72+
event SubscriptionRenewed(
6673
bytes32 indexed subscriber,
6774
uint256 indexed startTime,
6875
uint256 indexed endTime,
@@ -71,12 +78,14 @@ contract Asset is Ownable, ReentrancyGuard, IAsset {
7178
uint256 subscriptionPrice,
7279
uint256 registryFeeShare
7380
);
81+
7482
event SubscriptionExtended(bytes32 indexed subscriber, uint256 indexed endTime);
7583
event CreatorFeeClaimed(bytes32 indexed subscriber, uint256 amount);
7684
event CreatorFeeClaimedBatch(bytes32[] indexed subscribers, uint256 totalAmount);
7785
event SubscriptionPriceUpdated(uint256 newSubscriptionPrice);
78-
event SubscriptionRevoked(bytes32 indexed subscriber);
79-
event SubscriptionCancelled(bytes32 indexed subscriber);
86+
event SubscriptionRevoked(bytes32 indexed subscriber, uint256 indexed nonce, uint256 indexed endTime);
87+
event SubscriptionCancelled(bytes32 indexed subscriber, uint256 indexed nonce, uint256 indexed endTime);
88+
event SubscriptionRemoved(bytes32 indexed subscriber);
8089

8190
/// @notice Initializes the asset with id, price, payment token, and owner.
8291
/// Callable only by the registry (msg.sender).
@@ -152,7 +161,7 @@ contract Asset is Ownable, ReentrancyGuard, IAsset {
152161
return _subscribe(subscriber, payer, value);
153162
}
154163

155-
function _subscribe(bytes32 subscriber, address payer, uint256 value) internal returns (uint256) {
164+
function _subscribe(bytes32 subscriber, address payer, uint256 value) internal returns (uint256 endTime) {
156165
uint256 duration = value / subscriptionPrice;
157166

158167
uint256 startTime = block.timestamp;
@@ -177,7 +186,7 @@ contract Asset is Ownable, ReentrancyGuard, IAsset {
177186
&& subscription.subscriptionPrice == subscriptionPrice
178187
&& subscription.registryFeeShare == registryFeeShare
179188
) {
180-
uint256 endTime = subscription.endTime + duration;
189+
endTime = subscription.endTime + duration;
181190

182191
subscriptions[id].endTime = endTime;
183192

@@ -189,14 +198,23 @@ contract Asset is Ownable, ReentrancyGuard, IAsset {
189198
nonce = ++nonces[subscriber];
190199

191200
id = _hash(subscriber, nonce);
201+
202+
endTime = _addSubscription(id, subscriber, startTime, duration, registryFeeShare, payer);
203+
204+
emit SubscriptionRenewed(subscriber, startTime, endTime, nonce, payer, subscriptionPrice, registryFeeShare);
205+
206+
return endTime;
192207
}
193208

194-
return _addSubscription(id, nonce, subscriber, startTime, duration, registryFeeShare, payer);
209+
endTime = _addSubscription(id, subscriber, startTime, duration, registryFeeShare, payer);
210+
211+
emit SubscriptionAdded(subscriber, startTime, endTime, payer, subscriptionPrice, registryFeeShare);
212+
213+
return endTime;
195214
}
196215

197216
function _addSubscription(
198217
bytes32 id,
199-
uint256 nonce,
200218
bytes32 subscriber,
201219
uint256 startTime,
202220
uint256 duration,
@@ -215,8 +233,6 @@ contract Asset is Ownable, ReentrancyGuard, IAsset {
215233

216234
subscribers.add(subscriber);
217235

218-
emit SubscriptionAdded(subscriber, startTime, endTime, nonce, payer, subscriptionPrice, registryFeeShare);
219-
220236
return endTime;
221237
}
222238

@@ -485,6 +501,8 @@ contract Asset is Ownable, ReentrancyGuard, IAsset {
485501
delete registryClaimedAtNonces[subscriber];
486502
delete registryClaimedAtTimestamps[subscriber];
487503
subscribers.remove(subscriber);
504+
505+
emit SubscriptionRemoved(subscriber);
488506
}
489507
// If the user has subscriptions left, decrement the nonce by the number of deleted subscriptions
490508
else if (deleted != 0) {
@@ -495,28 +513,15 @@ contract Asset is Ownable, ReentrancyGuard, IAsset {
495513
function revokeSubscription(bytes32 subscriber) external onlyOwner nonReentrant {
496514
_removeSubscription(subscriber);
497515

498-
emit SubscriptionRevoked(subscriber);
499-
}
500-
501-
function commitCancellation(string memory subscriberId) external returns (uint256 timestamp) {
502-
timestamp = block.timestamp;
503-
504-
cancellations[keccak256(abi.encode(subscriberId, msg.sender))] = timestamp;
505-
506-
return timestamp;
516+
uint256 nonce = nonces[subscriber];
517+
bytes32 id = _hash(subscriber, nonce);
518+
emit SubscriptionRevoked(subscriber, nonce, subscriptions[id].endTime);
507519
}
508520

509-
function cancelSubscription(string memory subscriberId, uint256 timestamp, bytes memory signature)
510-
external
511-
nonReentrant
512-
{
513-
bytes32 subscriber = keccak256(abi.encode(subscriberId, msg.sender));
514-
515-
if (cancellations[subscriber] != timestamp) {
516-
revert InvalidCancellationCommitment();
517-
}
521+
function cancelSubscription(string memory subscriberId, bytes memory signature) external nonReentrant {
522+
bytes32 subscriber = _hash(subscriberId, msg.sender);
518523

519-
bytes32 hash = keccak256(abi.encodePacked(block.chainid, address(this), timestamp, subscriber));
524+
bytes32 hash = _hash(block.chainid, address(this), subscriber);
520525

521526
address signer = ECDSA.recover(MessageHashUtils.toEthSignedMessageHash(hash), signature);
522527

@@ -526,16 +531,27 @@ contract Asset is Ownable, ReentrancyGuard, IAsset {
526531

527532
_removeSubscription(subscriber);
528533

529-
delete cancellations[subscriber];
530-
531-
emit SubscriptionCancelled(subscriber);
534+
// If the user has subscriptions left, emit the SubscriptionCancelled event
535+
uint256 nonce = nonces[subscriber];
536+
bytes32 id = _hash(subscriber, nonce);
537+
emit SubscriptionCancelled(subscriber, nonce, subscriptions[id].endTime);
532538
}
533539

534540
function _hash(bytes32 a, uint256 b) internal pure returns (bytes32 result) {
535541
result = keccak256(abi.encode(a, b));
536542
return result;
537543
}
538544

545+
function _hash(string memory a, address b) internal pure returns (bytes32 result) {
546+
result = keccak256(abi.encode(a, b));
547+
return result;
548+
}
549+
550+
function _hash(uint256 a, address b, bytes32 c) internal pure returns (bytes32 result) {
551+
result = keccak256(abi.encodePacked(a, b, c));
552+
return result;
553+
}
554+
539555
function _isOwner() internal view returns (bool) {
540556
return msg.sender == owner();
541557
}

src/IAsset.sol

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,8 @@ interface IAsset {
8585
/// @param subscriber Subscriber hash whose subscription to revoke.
8686
function revokeSubscription(bytes32 subscriber) external;
8787

88-
/// @notice Commits cancellation intent for subscriber hash derived from `(subscriberId, msg.sender)`.
89-
/// @param subscriberId Human-readable subscriber ID used in `keccak256(abi.encode(subscriberId, msg.sender))`.
90-
/// @return timestamp The timestamp of the cancellation commitment.
91-
function commitCancellation(string memory subscriberId) external returns (uint256 timestamp);
92-
9388
/// @notice Cancels your subscription for subscriber hash derived from `(subscriberId, msg.sender)`.
9489
/// @param subscriberId Human-readable subscriber ID used in `keccak256(abi.encode(subscriberId, msg.sender))`.
95-
/// @param timestamp The timestamp of the cancellation commitment.
9690
/// @param signature Signature by msg.sender over the cancellation payload.
97-
function cancelSubscription(string memory subscriberId, uint256 timestamp, bytes memory signature) external;
91+
function cancelSubscription(string memory subscriberId, bytes memory signature) external;
9892
}

0 commit comments

Comments
 (0)