Abstract
Recurring Subscription NFT is an extension of ERC-721 token that enables recurring subscription feature by charging the NFT holder ERC-20 tokens via any token approval methods.
The interface suggests specifications that allow users to renew/extend subscriptions, service provider to charge users automatically for recurring subscription by their subscription data and EIP-712 signature. Also an optional function for users to cancel their subscription by revoking the respective token approval.
Motivation
NFTs are commonly used as ownership verification on decentralized apps or membership passes to communities, events, and more. However, these use cases often fall into either being a token of ownership that have no expiration dates, or a one-off ticket for verifying identity/reservation thus no recurring payments involved for both cases. However for many real-world applications with paid subscriptions, they would prefer a middle ground - to keep an account or membership valid until the user stops paying for the subscription.
Currently there exists no standard implementation for subscription service that allows:
-
service provider to easily configure a subscription
-
users to setup a seamless payment flow via gasless pre-approval for any ERC-20 with an expiry date
-
the above while restricting the service provider from only automatically charging by each cycle instead of a full amount to keep users’ subscriptions active
On the other hand 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.
Overall this standard offers a more efficient approach for both users and the platform. Rather than forcing users to pay a huge lump sum to tie them into a long subscription plan, or having users locked a fixed amount of funds in advance waiting to be charged each month, users can just spend their funds as they please, and keep enough funds to be deducted by the service provider for the next cycle of subscription without going through a manual subscription process - basically like a debit card for recurring subscription, and more similar to how an auto subscription would work in web2.
Specification
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
interface IERC8027 {
/**
* @param paymentToken Token to pay for the subscription, use address(0) for native token
* @param serviceProvider The address of the service provider to receive the payment
* @param billingInterval The interval of each subscription billing in seconds
* @param planPrices Array of prices for different plans, the respective index of the price refers to `planIdx`,
* length > 1 indicates multiple plans available
*/
struct SubscriptionConfig {
address paymentToken;
address serviceProvider;
uint64 billingInterval;
uint256[] planPrices;
}
/**
* @param planIdx The index of the subscription plan from `planPrices`
* @param expiryTs The latest timestamp which the subscription is valid until
*/
struct Subscription {
uint128 planIdx;
uint128 expiryTs;
}
struct RecurringSubscriptionData {
uint256 tokenId;
uint128 planIdx;
uint64 numOfIntervals;
bytes tokenApprovalData;
bytes extraVerificationData;
}
/**
* @notice Emitted when a subscription is extended
* @dev When a subscription is extended, the expiration timestamp is extended for `billingInterval * numOfIntervals`
* @param tokenId The NFT to extend the subscription for
* @param planIdx The plan index to indicate which plan to extend the subscription for
* @param oldExpiryTs The old expiration timestamp of the subscription
* @param newExpiryTs The new expiration timestamp of the subscription
*/
event SubscriptionExtended(uint256 indexed tokenId, uint128 planIdx, uint128 oldExpiryTs, uint128 newExpiryTs);
/**
* @notice Emitted when service provider charges a user for a recurring subscription
* @dev When a recurring subscription is charged, the expiration timestamp is extended for ONE `billingInterval` only
* @param tokenId The NFT to charge the recurring subscription for
*/
event RecurringSubscriptionCharged(uint256 indexed tokenId);
/// @notice Thrown when paymentToken is address(0) but not enough native token is sent as msg.value
error InsufficientPayment();
/// @notice Thrown when the subscription is not renewable for all tokens or a given tokenId
error SubscriptionNotRenewable();
/// @notice Thrown when the tokenId does not exist
error InvalidTokenId();
/// @notice Thrown when the number of intervals is not greater than 0
error InvalidNumOfIntervals();
/// @notice Thrown when the plan index exceeds the number of plans
error InvalidPlanIdx();
/// @notice Thrown when the payment of native token or ERC20 token failed
error TransferFailed();
/**
* @notice Manually renews a subscription for an NFT by directly transferring native token or ERC20 token to the service provider
* @param tokenId The NFT to renew the subscription for
* @param planIdx The plan index to indicate which plan to subscribe to
* @param numOfIntervals The number of `interval` to extend the subscription for
*/
function renewSubscription(uint256 tokenId, uint128 planIdx, uint64 numOfIntervals) external payable;
/**
* @notice Charges the subscription for an NFT by transferring ERC20 payment token from user to the service provider,
* usually called by the service provider automatically and recurringly for each `billingInterval`
* after a subscription is signaled by a user via signing token approval off-chain
* @param data A packed struct of subscription data
*/
function chargeRecurringSubscription(RecurringSubscriptionData memory data) external;
/**
* @notice Determines whether a NFT's subscription can be renewed
* @dev Returns false if `tokenId` does not exist
* @param tokenId The NFT to check the renewability of
* @return The renewability of a NFT's subscription
*/
function isRenewable(uint256 tokenId) external view returns (bool);
/**
* @notice Gets the expiration date of a NFT's subscription
* @dev Returns 0 if `tokenId` does not exist
* @param tokenId The NFT to get the expiration date of
* @return The `expiryTs` of the NFT's subscription
*/
function expiresAt(uint256 tokenId) external view returns (uint128);
/**
* @notice Gets the price to renew a subscription for a number of `interval` for a given tokenId.
* @dev Returns 0 if `numOfIntervals` is 0 or `planIdx` is not a valid plan index
* @param planIdx The plan index to indicate which plan to subscribe to
* @param numOfIntervals The number of `interval` to renew the subscription for
* @return The price to renew the subscription
*/
function getRenewalPrice(uint128 planIdx, uint64 numOfIntervals) external view returns (uint256);
/**
* @notice Gets the subscription details for a given tokenId
* @dev Returns empty `Subscription` if `tokenId` does not exist
* @param tokenId The NFT to get the subscription for
* @return The packed struct of `Subscription`
*/
function getSubscriptionDetails(uint256 tokenId) external view returns (Subscription memory);
/**
* @notice Gets the subscription config
* @return The packed struct of `SubscriptionConfig`
*/
function getSubscriptionConfig() external view returns (SubscriptionConfig memory);
}
Rationale
This standard aims to make on-chain recurring subscriptions as generic and as easy to integrate with as possible by having minimal configurations. Here are few design choices being made to fulfill these purposes.
-
The
SubscriptionConfigallows multiple plans to be configured - the standard supports multiple plans to enable tierd subscription plans and only using ERC-20 as payment token will enable recurring subscription due to its approval feature. The NFT itself represents ownership of a subscription and there is no facilitation of how the NFT should be minted or transferred. -
RecurringSubscriptionDatapassed inchargeRecurringSubscriptionis designed to be generic in order to integrate with any ERC-20 approval methods:struct RecurringSubscriptionData { uint256 tokenId; uint128 planIdx; uint64 numOfIntervals; bytes tokenApprovalData; bytes extraVerificationData; }tokenid: Token id of the Subscription NFTplanIdx: Index of the plan inplanPricesarraynumOfIntervals: The number ofbillingIntervalfor verifying total pre-approval amount of the recurring subscriptiontokenApprovalDataEncoded data to handle different token approval logic such as Permit/Permit2extraVerificationDataEncoded data to handle extra verification logic for example a signed EIP-712 signature if more verifications are needed. -
For actual implementation in
ERC8027.sol, customizations on:-
verifying token approval and charging automatically should be done by overriding
_verifyApprovalAndChargeto integrate with any common ERC-20 approval methods e.g. ERC-2612, ERC-3009 and Permit2 -
verifing inputs passed from the function caller should be done by overriding
_verifyInputs -
paying for manual subscription should be done by overriding
_pay -
extending subscription should be done by overriding
_extendSubscription
-
Considerations on token pre-approval via Account Abstraction(AA)
Even though we could somehow achieve something similar to gasless pre-approval with the help of EIP-7702, user’s EOA will need to delegate to an external contract to act as an smart account which would impose extra security vectors that integrators need to consider, and the subscription feature is still lacking nonetheless.
More importantly this interface is in fact compatitable with such smart accounts as long as they are able to receive NFT i.e. with onERC721Received implemented thus this interface provides a standardized framework while being AA-compatible for apps to offer subscription features in a more composable way.
Subscription Management
-
Manual subscription: Users should be able to renew their subscriptions by directly transferring either native or ERC-20 tokens to the service provider thus the
renewSubscriptionfunction. Users will specify the index of subscription plan i.e.planIdxand the number of interval to subscribe for i.e.numOfIntervals. -
Recurring subscription: Normally users will start by signing a EIP-712 signature off-chain to pre-approve the Recurring Subscription NFT contract the total funds needed for their subscription, ideally on a platform where service provider can gather their signature and subscription data to be used to call
chargeRecurringSubscription. The service provider will then be able to charge subscription fee automatically bybillingIntervalviachargeRecurringSubscriptionto start the subscription for users, no extra subscription fee should be sent to service provider as per the standard’s implementation. If the users don’t want to continue the subscription they can either callcancelAutoSubscriptionto revoke token approval if this is available in the approval implementation that integrates with this interface, or they can directly move the funds out from the wallet they initially signed the approval with. -
expiresAtfunction allows users and applications to directly confirm the validity of a Subscription NFT by checking its expiration date, andgetSubscriptionDetailsfunction will get both the expiration date and the subscription plan the Subscription NFT belongs to. -
getRenewalPricehelps users and applications to calculate the price of the subscription given the plan it belongs and the number of intervals the subscription will continue for. -
isRenewablefunction gives users and applications the information whether a subscription for a certain NFT or all NFTs could be renewed once expired. -
getSubscriptionConfigwill give users and applications the information about the configuration of the subscription such as which payment token, which service provider will receive the subscription fee, how long is each interval is, and the price for each plan. -
Finally it’s important to know that only using ERC-20 as payment token can enable recurring subscription as we cannot directly transfer native token on behalf of users without introducing external dependencies.
Backwards Compatibility
This standard can be fully ERC-721 compatible by adding an extension function set, and payment can be integrated with any ERC-20 out of the box.
This standard is also fully compatible with smart accounts that utilize Account Abstraction standards such as EIP-4337 and EIP-7702 as long as they are able to receive NFT i.e. with onERC721Received implemented or with ERC721Holder.sol from OpenZeppelin inherited.
Reference Implementation
ERC8027.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import { IERC165 } from "@openzeppelin/contracts/interfaces/IERC165.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import { IERC8027 } from "./IERC8027.sol";
abstract contract ERC8027 is IERC8027, ERC721 {
mapping(uint256 tokenId => Subscription) internal _subscriptions;
SubscriptionConfig internal _subscriptionConfig;
constructor(
string memory name_,
string memory symbol_,
SubscriptionConfig memory subscriptionConfig
) ERC721(name_, symbol_) {
_subscriptionConfig = subscriptionConfig;
}
/* MANUAL SUBSCRIPTION RENEWAL BY USER */
/// @inheritdoc IERC8027
function renewSubscription(uint256 tokenId, uint128 planIdx, uint64 numOfIntervals) public payable {
SubscriptionConfig memory config = _subscriptionConfig;
_verifyInputs(tokenId, planIdx, config.planPrices.length, numOfIntervals);
_pay(config, planIdx, numOfIntervals);
_extendSubscription(tokenId, planIdx, config.billingInterval, numOfIntervals);
}
/* RECURRING SUBSCRIPTION RENEWAL BY SERVICE PROVIDER */
/// @inheritdoc IERC8027
function chargeRecurringSubscription(RecurringSubscriptionData calldata data) public {
SubscriptionConfig memory config = _subscriptionConfig;
_verifyInputs(data.tokenId, data.planIdx, config.planPrices.length, data.numOfIntervals);
_verifyApprovalAndCharge(data);
_extendSubscription(data.tokenId, data.planIdx, config.billingInterval, 1);
}
/* INTERNALS */
/**
* @dev Ensures the inputs are valid.
* 1. the tokenId exists
* 2. the planIdx is less than the number of plans
* 3. the numOfIntervals is greater than 0
*/
function _verifyInputs(
uint256 tokenId,
uint128 planIdx,
uint256 numOfPlans,
uint64 numOfIntervals
) internal virtual {
require(_ownerOf(tokenId) != address(0), InvalidTokenId());
require(planIdx < numOfPlans, InvalidPlanIdx());
require(numOfIntervals > 0, InvalidNumOfIntervals());
}
/**
* @dev Internal function to pay for the subscription, supports both native token and ERC20 token payment.
* If the payment token is address(0), the function will use msg.value to pay for the subscription.
* If the payment token is not address(0), the function will transfer the payment token from the caller to the service provider.
*/
function _pay(SubscriptionConfig memory config, uint128 planIdx, uint64 numOfIntervals) internal virtual {
uint256 planPrice = _calcRenewalPrice(config.planPrices[planIdx], numOfIntervals);
if (config.paymentToken == address(0)) {
require(msg.value == planPrice, InsufficientPayment());
(bool success,) = payable(config.serviceProvider).call{ value: planPrice }("");
require(success, TransferFailed());
} else {
IERC20(config.paymentToken).transferFrom(msg.sender, config.serviceProvider, planPrice);
}
}
/**
* @dev Extends the subscription for `tokenId` for `duration` seconds.
* If the `tokenId` does not exist, an error will be thrown.
* If a token is not renewable, an error will be thrown.
* Emits a {SubscriptionExtended} event after the subscription is extended.
*/
function _extendSubscription(
uint256 tokenId,
uint128 planIdx,
uint64 billingInterval,
uint64 numOfIntervals
) internal virtual {
uint128 expiryTs = _subscriptions[tokenId].expiryTs;
uint128 newExpiryTs;
if ((expiryTs == 0) || (expiryTs < block.timestamp)) {
newExpiryTs = uint128(block.timestamp) + billingInterval * numOfIntervals;
} else {
// subscribe in the middle of the billing cycle, as `expiryTs` must be block.timestamp + multiples of `billingInterval`
// so just increment by `billingInterval * numOfIntervals`
require(_isRenewable(tokenId), SubscriptionNotRenewable());
newExpiryTs = expiryTs + billingInterval * numOfIntervals;
}
_subscriptions[tokenId] = Subscription({ planIdx: planIdx, expiryTs: newExpiryTs });
emit SubscriptionExtended(tokenId, planIdx, expiryTs, newExpiryTs);
}
/**
* @dev Internal function to verify token approval and charge the recurring subscription.
* NOTE: Implementing contracts are expected to override this function to integrate with any token approval logic.
*/
function _verifyApprovalAndCharge(RecurringSubscriptionData memory data) internal virtual {
emit RecurringSubscriptionCharged(data.tokenId);
}
/**
* @dev Internal function to determine renewability.
* NOTE: Implementing contracts are expected to override this function if renewability should be disabled
* for all or some tokens.
*/
function _isRenewable(uint256) internal view virtual returns (bool) {
return true;
}
/**
* @dev Internal function to set the subscription config, should only be called by the owner.
*/
function _setSubscriptionConfig(SubscriptionConfig calldata subscriptionConfig) internal virtual {
_subscriptionConfig = subscriptionConfig;
}
/**
* @dev Calculates the price to renew a subscription for `numOfIntervals` * `interval` seconds for
* a given tokenId.
*/
function _calcRenewalPrice(uint256 price, uint64 numOfIntervals) internal pure virtual returns (uint256) {
return price * numOfIntervals;
}
/* GETTERS */
/// @inheritdoc IERC8027
function isRenewable(uint256 tokenId) public view virtual returns (bool) {
if (_ownerOf(tokenId) == address(0)) return false;
return _isRenewable(tokenId);
}
/// @inheritdoc IERC8027
function expiresAt(uint256 tokenId) public view virtual returns (uint128) {
if (_ownerOf(tokenId) == address(0)) return 0;
return _subscriptions[tokenId].expiryTs;
}
/// @inheritdoc IERC8027
function getRenewalPrice(uint128 planIdx, uint64 numOfIntervals) public view virtual returns (uint256) {
if (numOfIntervals == 0 || planIdx >= _subscriptionConfig.planPrices.length) return 0;
return _calcRenewalPrice(_subscriptionConfig.planPrices[planIdx], numOfIntervals);
}
/// @inheritdoc IERC8027
function getSubscriptionDetails(uint256 tokenId) public view virtual returns (Subscription memory) {
if (_ownerOf(tokenId) == address(0)) return Subscription({ planIdx: 0, expiryTs: 0 });
return _subscriptions[tokenId];
}
/// @inheritdoc IERC8027
function getSubscriptionConfig() public view virtual returns (SubscriptionConfig memory) {
return _subscriptionConfig;
}
/// @inheritdoc IERC165
function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721) returns (bool) {
return interfaceId == type(IERC8027).interfaceId || super.supportsInterface(interfaceId);
}
}
Example: ERC8027Permit2.sol - ERC8027.sol that integrates with Permit2
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import { IERC165 } from "@openzeppelin/contracts/interfaces/IERC165.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import { IAllowanceTransfer, IPermit2 } from "@permit2/interfaces/IPermit2.sol";
import { ERC8027 } from "./ERC8027.sol";
import { IERC8027 } from "./IERC8027.sol";
struct TokenApprovalData {
IPermit2.PermitSingle permitSingle;
bytes signature;
}
/// @notice Emitted when a user cancels the upcoming subscription by revoking permit2 allowance
/// @dev When a subscription is canceled, the subscription will last until the `expiryTs` timestamp
/// @param tokenId The NFT to cancel the auto subscription for
event RecurringSubscriptionCancelled(uint256 indexed tokenId);
/// @notice Thrown when the token specified in the permit is not same as the payment token
error PaymentTokenMismatch();
/// @notice Thrown when the expiration of the permit doesn't last until the current timestamp + `billingInterval * numOfIntervals`
error AllowanceExpireTooEarly();
/// @notice Thrown when the spender of the permit is not this contract
error InvalidSpender();
/// @notice Thrown when the service provider charges before the `expiryTs` of the subscriber's subscription
error ChargeTooEarly();
/// @notice Thrown when the service provider charges for subscription that uses native token as `paymentToken` in `SubscriptionConfig`
error OnlyERC20ForAutoRenewal();
/// @notice Thrown when the payment of native token or ERC20 token failed
error TransferFailed();
abstract contract ERC8027Permit2 is ERC8027 {
IPermit2 public immutable PERMIT2;
constructor(
string memory name_,
string memory symbol_,
SubscriptionConfig memory subscriptionConfig,
address permit2
) ERC8027(name_, symbol_, subscriptionConfig) {
PERMIT2 = IPermit2(permit2);
}
/* OPTIONAL FUNCTIONS */
function cancelAutoSubscription(uint256 tokenId) public virtual {
require(_ownerOf(tokenId) != address(0), InvalidTokenId());
SubscriptionConfig memory config = _subscriptionConfig;
IPermit2.TokenSpenderPair[] memory approvals = new IPermit2.TokenSpenderPair[](1);
approvals[0] = IAllowanceTransfer.TokenSpenderPair(config.paymentToken, config.serviceProvider);
PERMIT2.lockdown(approvals);
emit RecurringSubscriptionCancelled(tokenId);
}
/* INTERNALS */
/**
* @dev Ensures the permit is valid beforehand by making sure:
* 1. this contract is the spender
* 2. the payment token is the same as the allowed token
* 3. the amount is the same as the price of the subscription plan times the number of intervals
* 4. the expiration is greater or equal to the current timestamp + `interval * numOfIntervals`
*/
function _verifyApprovalAndCharge(RecurringSubscriptionData memory data) internal override {
// TODO: see if we need the extraVerificationData to verify the whole data as a eip712 signature
TokenApprovalData memory tokenApprovalData = abi.decode(data.tokenApprovalData, (TokenApprovalData));
IPermit2.PermitSingle memory permit = tokenApprovalData.permitSingle;
SubscriptionConfig memory config = _subscriptionConfig;
require(permit.details.token == config.paymentToken, PaymentTokenMismatch());
require(
permit.details.amount == _calcRenewalPrice(config.planPrices[data.planIdx], data.numOfIntervals),
InsufficientPayment()
);
require(
permit.details.expiration >= block.timestamp + config.billingInterval * data.numOfIntervals,
AllowanceExpireTooEarly()
);
require(permit.spender == address(this), InvalidSpender());
address nftOwner = _ownerOf(data.tokenId);
PERMIT2.permit(nftOwner, tokenApprovalData.permitSingle, tokenApprovalData.signature);
Subscription memory subscription = _subscriptions[data.tokenId];
require(block.timestamp > subscription.expiryTs, ChargeTooEarly());
require(config.paymentToken != address(0), OnlyERC20ForAutoRenewal());
// NOTE: only charge for one interval to keep the subscription automatic
try PERMIT2.transferFrom(
nftOwner, config.serviceProvider, uint160(config.planPrices[subscription.planIdx]), config.paymentToken
) {
_extendSubscription(data.tokenId, subscription.planIdx, config.billingInterval, 1);
emit RecurringSubscriptionCharged(data.tokenId);
} catch {
revert TransferFailed();
}
}
}
Security Considerations
-
This EIP standard does not affect ownership of an NFT
-
When integrating with Permit-like token approval, to ensure no extra subscription fee would be sent to the service provider, caution should be taken to make sure:
-
the spender of the Permit is restricted to only the contract that applies this standard
-
payment receiver is restricted to the service provider
-
implementation in
_verifyApprovalAndChargeshould verifychargeRecurringSubscriptioncan only be called by each cycle of interval
-