Subscription NFTs
Abstract
This standard is an extension of EIP-721. It proposes an additional interface for NFTs to be used as recurring, expirable subscriptions. The interface includes functions to renew and cancel the subscription.
Motivation
NFTs are commonly used as accounts on decentralized apps or membership passes to communities, events, and more. However, it is currently rare to see NFTs like these that have a finite expiration date. The “permanence” of the blockchain often leads to memberships that have no expiration dates and thus no required recurring payments. However, for many real-world applications, a paid subscription is needed to keep an account or membership valid.
The most prevalent on-chain application that makes use of the renewable subscription model is the Ethereum Name Service (ENS), which utilizes a similar interface to the one proposed below. Each domain can be renewed for a certain period of time, and expires if payments are no longer made. A common interface will make it easier for future projects to develop subscription-based NFTs. In the current Web2 world, it’s hard for a user to see or manage all of their subscriptions in one place. With a common standard for subscriptions, it will be easy for a single application to determine the number of subscriptions a user has, see when they expire, and renew/cancel them as requested.
Additionally, as the prevalence of secondary royalties from NFT trading disappears, creators will need new models for generating recurring income. For NFTs that act as membership or access passes, pivoting to a subscription-based model is one way to provide income and also force issuers to keep providing value.
Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
interface IERC5643 {
/// @notice Emitted when a subscription expiration changes
/// @dev When a subscription is canceled, the expiration value should also be 0.
event SubscriptionUpdate(uint256 indexed tokenId, uint64 expiration);
/// @notice Renews the subscription to an NFT
/// Throws if `tokenId` is not a valid NFT
/// @param tokenId The NFT to renew the subscription for
/// @param duration The number of seconds to extend a subscription for
function renewSubscription(uint256 tokenId, uint64 duration) external payable;
/// @notice Cancels the subscription of an NFT
/// @dev Throws if `tokenId` is not a valid NFT
/// @param tokenId The NFT to cancel the subscription for
function cancelSubscription(uint256 tokenId) external payable;
/// @notice Gets the expiration date of a subscription
/// @dev Throws if `tokenId` is not a valid NFT
/// @param tokenId The NFT to get the expiration date of
/// @return The expiration date of the subscription
function expiresAt(uint256 tokenId) external view returns(uint64);
/// @notice Determines whether a subscription can be renewed
/// @dev Throws if `tokenId` is not a valid NFT
/// @param tokenId The NFT to get the expiration date of
/// @return The renewability of a the subscription
function isRenewable(uint256 tokenId) external view returns(bool);
}
The expiresAt(uint256 tokenId)
function MAY be implemented as pure
or view
.
The isRenewable(uint256 tokenId)
function MAY be implemented as pure
or view
.
The renewSubscription(uint256 tokenId, uint64 duration)
function MAY be implemented as external
or public
.
The cancelSubscription(uint256 tokenId)
function MAY be implemented as external
or public
.
The SubscriptionUpdate
event MUST be emitted whenever the expiration date of a subscription is changed.
The supportsInterface
method MUST return true
when called with 0x8c65f84d
.
Rationale
This standard aims to make on-chain subscriptions as simple as possible by adding the minimal required functions and events for implementing on-chain subscriptions. It is important to note that in this interface, the NFT itself represents ownership of a subscription, there is no facilitation of any other fungible or non-fungible tokens.
Subscription Management
Subscriptions represent agreements to make advanced payments in order to receive or participate in something. In order to facilitate these agreements, a user must be able to renew or cancel their subscriptions hence the renewSubscription
and cancelSubscription
functions. It also important to know when a subscription expires - users will need this information to know when to renew, and applications need this information to determine the validity of a subscription NFT. The expiresAt
function provides this functionality. Finally, it is possible that a subscription may not be renewed once expired. The isRenewable
function gives users and applications that information.
Easy Integration
Because this standard is fully EIP-721 compliant, existing protocols will be able to facilitate the transfer of subscription NFTs out of the box. With only a few functions to add, protocols will be able to fully manage a subscription’s expiration, determine whether a subscription is expired, and see whether it can be renewed.
Backwards Compatibility
This standard can be fully EIP-721 compatible by adding an extension function set.
The new functions introduced in this standard add minimal overhead to the existing EIP-721 interface, which should make adoption straightforward and quick for developers.
Test Cases
The following tests require Foundry.
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/ERC5643.sol";
contract ERC5643Mock is ERC5643 {
constructor(string memory name_, string memory symbol_) ERC5643(name_, symbol_) {}
function mint(address to, uint256 tokenId) public {
_mint(to, tokenId);
}
}
contract ERC5643Test is Test {
event SubscriptionUpdate(uint256 indexed tokenId, uint64 expiration);
address user1;
uint256 tokenId;
ERC5643Mock erc5643;
function setUp() public {
tokenId = 1;
user1 = address(0x1);
erc5643 = new ERC5643Mock("erc5369", "ERC5643");
erc5643.mint(user1, tokenId);
}
function testRenewalValid() public {
vm.warp(1000);
vm.prank(user1);
vm.expectEmit(true, true, false, true);
emit SubscriptionUpdate(tokenId, 3000);
erc5643.renewSubscription(tokenId, 2000);
}
function testRenewalNotOwner() public {
vm.expectRevert("Caller is not owner nor approved");
erc5643.renewSubscription(tokenId, 2000);
}
function testCancelValid() public {
vm.prank(user1);
vm.expectEmit(true, true, false, true);
emit SubscriptionUpdate(tokenId, 0);
erc5643.cancelSubscription(tokenId);
}
function testCancelNotOwner() public {
vm.expectRevert("Caller is not owner nor approved");
erc5643.cancelSubscription(tokenId);
}
function testExpiresAt() public {
vm.warp(1000);
assertEq(erc5643.expiresAt(tokenId), 0);
vm.startPrank(user1);
erc5643.renewSubscription(tokenId, 2000);
assertEq(erc5643.expiresAt(tokenId), 3000);
erc5643.cancelSubscription(tokenId);
assertEq(erc5643.expiresAt(tokenId), 0);
}
}
Reference Implementation
Implementation: ERC5643.sol
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "./IERC5643.sol";
contract ERC5643 is ERC721, IERC5643 {
mapping(uint256 => uint64) private _expirations;
constructor(string memory name_, string memory symbol_) ERC721(name_, symbol_) {}
function renewSubscription(uint256 tokenId, uint64 duration) external payable {
require(_isApprovedOrOwner(msg.sender, tokenId), "Caller is not owner nor approved");
uint64 currentExpiration = _expirations[tokenId];
uint64 newExpiration;
if (currentExpiration == 0) {
newExpiration = uint64(block.timestamp) + duration;
} else {
if (!_isRenewable(tokenId)) {
revert SubscriptionNotRenewable();
}
newExpiration = currentExpiration + duration;
}
_expirations[tokenId] = newExpiration;
emit SubscriptionUpdate(tokenId, newExpiration);
}
function cancelSubscription(uint256 tokenId) external payable {
require(_isApprovedOrOwner(msg.sender, tokenId), "Caller is not owner nor approved");
delete _expirations[tokenId];
emit SubscriptionUpdate(tokenId, 0);
}
function expiresAt(uint256 tokenId) external view returns(uint64) {
return _expirations[tokenId];
}
function isRenewable(uint256 tokenId) external pure returns(bool) {
return true;
}
function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
return interfaceId == type(IERC5643).interfaceId || super.supportsInterface(interfaceId);
}
}
Security Considerations
This EIP standard does not affect ownership of an NFT and thus can be considered secure.
Copyright
Copyright and related rights waived via CC0.