What if a Netflix-style subscription lived on the blockchain — as an NFT you actually own, can renew, and can even transfer to someone else? That is exactly what the NFTSubscription contract in this project does. In under 50 lines of Solidity, it turns an ERC-721 token into a 30-day access pass that users buy, renew, and that any dApp can check with a single view call.
This article walks through the full contract (nftacess.sol), explains every important code block, and closes with the design trade-offs and security considerations you should know about.
1. The Idea: NFTs as Subscriptions
Traditional subscriptions (Web2) live in a company’s database: you pay, a row gets updated, and your access disappears the moment the company decides so. An NFT subscription flips this model:
- Ownership — the pass is a token in your wallet, not a row in someone’s database.
- Transferability — because it’s a standard ERC-721, you can sell or gift the remaining subscription time.
- Composability — any other contract or frontend can verify your access on-chain with one call, no API keys required.
- Expiry — each token carries a timestamp; once it passes, access is denied, but the NFT itself survives and can be renewed.
The mental model: the NFT is the membership card, and the contract stamps an expiry date on the back of it.
2. Contract Overview
Here is the complete contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract NFTSubscription is ERC721 {
uint256 public tokenId;
uint256 public constant PRICE = 0.01 ether;
uint256 public constant DURATION = 30 days;
mapping(uint256 => uint256) public expiry;
constructor() ERC721("Subscription NFT", "SUB") {}
function subscribe() external payable {
require(msg.value == PRICE, "Wrong payment");
tokenId++;
_mint(msg.sender, tokenId);
expiry[tokenId] = block.timestamp + DURATION;
}
function renew(uint256 _tokenId) external payable {
require(ownerOf(_tokenId) == msg.sender, "Not owner");
require(msg.value == PRICE, "Wrong payment");
if (expiry[_tokenId] < block.timestamp) {
expiry[_tokenId] = block.timestamp + DURATION;
} else {
expiry[_tokenId] += DURATION;
}
}
function isActive(uint256 _tokenId) external view returns (bool) {
return expiry[_tokenId] > block.timestamp;
}
function withdraw() external {
payable(msg.sender).transfer(address(this).balance);
}
}
Four functions, one mapping, and OpenZeppelin’s battle-tested ERC-721 doing the heavy lifting. Let’s break it down piece by piece.
3. State Variables and Setup
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract NFTSubscription is ERC721 {
uint256 public tokenId;
uint256 public constant PRICE = 0.01 ether;
uint256 public constant DURATION = 30 days;
mapping(uint256 => uint256) public expiry;
constructor() ERC721("Subscription NFT", "SUB") {}
What’s happening here:
-
import ERC721.sol— instead of writing the NFT standard from scratch, the contract inherits from OpenZeppelin’s audited implementation. This gives us_mint,ownerOf,transferFrom, approvals, and all standard events for free. -
uint256 public tokenId— a simple counter that serves as the ID for the next minted pass. It starts at0and is incremented before each mint, so the first pass is token#1. -
PRICE = 0.01 ether— the cost of one subscription period. Markedconstant, so it is baked into the bytecode at compile time — reading it costs no storage access, and it can never be changed after deployment. -
DURATION = 30 days— Solidity’s time units make this readable:30 daysliterally compiles to2,592,000seconds. One payment buys one such period. -
mapping(uint256 => uint256) public expiry— the heart of the contract. It maps each token ID to a Unix timestamp of when that subscription expires. This is what turns a plain NFT into a time-limited pass. -
constructor() ERC721("Subscription NFT", "SUB")— sets the collection name and symbol that wallets and marketplaces will display. Nothing else is needed at deployment.
Key insight: the expiry lives in a mapping keyed by token ID, not by owner address. That means the remaining subscription time travels with the NFT when it is transferred — sell the token, and the buyer inherits whatever time is left.
4. Subscribing — Minting the Pass
function subscribe() external payable {
require(msg.value == PRICE, "Wrong payment");
tokenId++;
_mint(msg.sender, tokenId);
expiry[tokenId] = block.timestamp + DURATION;
}
This is the entry point for new users. Line by line:
-
external payable— the function accepts ETH/CFX. Withoutpayable, any transaction sending value would revert. -
require(msg.value == PRICE, "Wrong payment")— strict equality check. Sending too little or too much reverts. This keeps the accounting dead simple: there is never any change to refund. -
tokenId++— increments the counter first, so IDs go1, 2, 3, .... Each subscriber gets a unique, sequential pass. -
_mint(msg.sender, tokenId)— OpenZeppelin’s internal mint. It assigns the new token to the caller and emits the standardTransfer(address(0), msg.sender, tokenId)event, so indexers and wallets pick it up automatically. -
expiry[tokenId] = block.timestamp + DURATION— stamps the expiry: now + 30 days. From this moment, the pass is active.
Flow of a subscription:
User sends 0.01 ETH ──► subscribe()
│
├─ payment == PRICE? ── no ──► revert "Wrong payment"
│
├─ mint NFT #N to user
│
└─ expiry[N] = now + 30 days ✅ pass active
5. Renewing — Extending the Clock
function renew(uint256 _tokenId) external payable {
require(ownerOf(_tokenId) == msg.sender, "Not owner");
require(msg.value == PRICE, "Wrong payment");
if (expiry[_tokenId] < block.timestamp) {
expiry[_tokenId] = block.timestamp + DURATION;
} else {
expiry[_tokenId] += DURATION;
}
}
Renewal is the most thoughtful part of the contract, because it handles two distinct situations correctly:
Guard checks
-
ownerOf(_tokenId) == msg.sender— only the current holder can renew. As a bonus,ownerOfreverts for non-existent tokens, so you cannot renew a pass that was never minted. -
msg.value == PRICE— same strict-payment rule assubscribe.
The expired vs. active branch
This if/else is the detail that prevents users from being cheated — in either direction:
| Scenario | Logic used | Why |
|---|---|---|
| Subscription already expired | expiry = now + 30 days |
The clock restarts from today. Without this, a user who let their pass lapse for 6 months would pay 0.01 ETH and get an expiry still in the past (old expiry + 30 days) — paying for nothing. |
| Subscription still active | expiry += 30 days |
Time is stacked on top of the remaining balance. Renewing 5 days early doesn’t cost you those 5 days — you end up with 35 days total. |
This is exactly how well-behaved Web2 subscriptions work, reproduced in four lines of Solidity. A user can also call renew several times in a row to prepay multiple months at once.
6. Checking Access — The Gatekeeper Function
function isActive(uint256 _tokenId) external view returns (bool) {
return expiry[_tokenId] > block.timestamp;
}
One line, but it is the entire utility of the system. isActive answers: "is this pass still valid right now?"
- It’s a
viewfunction — reading it via RPC costs no gas, so a frontend can call it on every page load for free. - For a token that was never minted,
expiry[_tokenId]is0, which is always<= block.timestamp, so it safely returnsfalse. - Note the deliberate separation of concerns: an expired NFT is not burned. The user keeps the token; it just stops granting access until renewed. Ownership and validity are independent.
7. Withdrawing Funds
function withdraw() external {
payable(msg.sender).transfer(address(this).balance);
}
All subscription payments accumulate in the contract’s balance. withdraw sends the entire balance to the caller.
Important: as written, this function has no access control — anyone, not just the deployer, can call it and drain the collected funds. This is the single most critical issue in the contract and must be fixed before any real deployment. See the next section.
8. How a dApp Uses This
The beauty of this pattern is how simple integration becomes. A backend or frontend gates content with two read-only calls:
// ethers.js example
const sub = new ethers.Contract(CONTRACT_ADDRESS, ABI, provider);
async function hasAccess(userAddress, tokenId) {
const owner = await sub.ownerOf(tokenId);
const active = await sub.isActive(tokenId);
return owner.toLowerCase() === userAddress.toLowerCase() && active;
}
And another smart contract can gate its own functions the same way:
interface INFTSubscription {
function ownerOf(uint256 tokenId) external view returns (address);
function isActive(uint256 tokenId) external view returns (bool);
}
contract PremiumFeature {
INFTSubscription public sub;
modifier onlySubscriber(uint256 tokenId) {
require(sub.ownerOf(tokenId) == msg.sender, "Not pass holder");
require(sub.isActive(tokenId), "Subscription expired");
_;
}
}
No OAuth, no API keys, no centralized session store — the chain is the auth server.
9. Security Notes and Improvements
This contract is a great learning skeleton, but before production use, address the following:
Critical
-
Unprotected
withdraw— anyone can steal the funds. Fix by inheritingOwnable:
import "@openzeppelin/contracts/access/Ownable.sol";
contract NFTSubscription is ERC721, Ownable {
constructor() ERC721("Subscription NFT", "SUB") Ownable(msg.sender) {}
function withdraw() external onlyOwner {
(bool ok, ) = payable(owner()).call{value: address(this).balance}("");
require(ok, "Transfer failed");
}
}
Note this also replaces transfer with call. transfer forwards only 2,300 gas, which can break withdrawals to smart-contract wallets (e.g., multisigs); call is the modern recommended pattern.
🟡 Recommended
-
Emit events —
subscribeandrenewchange important state silently. Events likeSubscribed(address user, uint256 tokenId, uint256 expiry)andRenewed(uint256 tokenId, uint256 newExpiry)make off-chain indexing and analytics vastly easier. -
One user, many passes — nothing stops a single wallet from minting multiple subscriptions. Depending on the product this may be fine (passes are tradable inventory) or undesirable (add a
balanceOf(msg.sender) == 0check). -
Fixed price forever —
PRICEisconstant, so it can never be adjusted for market conditions. A production version might use an owner-settable price variable instead. -
Renewal by anyone? — currently only the owner can renew. Some designs deliberately allow anyone to renew a token (e.g., a company sponsoring an employee’s pass). That would just mean removing the
ownerOfcheck — a product decision, not a bug. -
Consider EIP-5643 — there is an emerging standard, ERC-5643: Subscription NFTs, which formalizes exactly this pattern (
renewSubscription,expiresAt,isRenewable). Adopting it makes the contract interoperable with tooling that already understands subscription NFTs.
10. Conclusion
NFTSubscription packs a complete subscription business model into a minimal ERC-721 extension:
| Function | Role |
|---|---|
subscribe() |
Pay 0.01 ETH → mint pass valid 30 days |
renew(tokenId) |
Pay 0.01 ETH → add 30 days (fairly, whether expired or active) |
isActive(tokenId) |
Free on-chain access check for any dApp |
withdraw() |
Collect revenue (must be access-controlled before deployment) |
The core design lesson: decouple ownership from validity. The NFT never expires — only its access right does, tracked by a single mapping(uint256 => uint256). That one mapping is enough to turn any NFT collection into a renewable, transferable, on-chain subscription service.
Resources
GitHub repo: https://github.com/Vikash-8090-Yadav/cfxnftacess
Yt tutorial: https://youtu.be/_3ndWsD2klA
Conflux docs: https://doc.confluxnetwork.org/docs/
Key insight: the expiry lives in a mapping keyed by token ID, not by owner address. That means the remaining subscription time travels with the NFT when it is transferred — sell the token, and the buyer inherits whatever time is left.
Critical