Token Content Subscription System Development
Token-gating content is not Patreon on blockchain. Principal difference: payments through smart contract without middleman, creator receives income directly, subscription can be NFT with secondary market, access verified on-chain without centralized authorization server.
Implementing this correctly is harder than it looks — especially for verifying access to off-chain content (video, articles, files).
Subscription Models: ERC-20 vs ERC-721 vs ERC-1155
ERC-20 staking: user stakes certain amount of creator's tokens — gets access. Simple model, but stake freezes liquidity.
ERC-721 subscription NFT: each active subscription — separate NFT with expiry timestamp in metadata or contract. Sellable, transferable. Ideal for "lifetime" subscriptions or limited tiers.
ERC-1155 timed access: semi-fungible tokens tied to period. E.g., tokenId = 202504 — access for April 2025. Saves gas with batch transfers, logical for monthly subscriptions.
contract ContentSubscription {
struct SubscriptionTier {
uint256 pricePerMonth; // in wei or ERC-20 tokens
address paymentToken; // address(0) = native ETH
uint256 maxSubscribers; // 0 = unlimited
string contentCID; // IPFS CID for encrypted content
bytes32 encryptionKeyHash; // hash of encryption key for this tier
}
struct Subscription {
uint256 tierId;
uint256 expiresAt;
uint256 startedAt;
bool autoRenew;
}
mapping(uint256 => SubscriptionTier) public tiers;
mapping(address => mapping(uint256 => Subscription)) public subscriptions;
address public creator;
uint256 public platformFeeBps = 250; // 2.5%
modifier onlyCreator() {
require(msg.sender == creator, "Not creator");
_;
}
function subscribe(uint256 tierId, uint256 months, bool autoRenew) external payable {
SubscriptionTier memory tier = tiers[tierId];
uint256 totalCost = tier.pricePerMonth * months;
if (tier.paymentToken == address(0)) {
require(msg.value >= totalCost, "Insufficient ETH");
} else {
IERC20(tier.paymentToken).transferFrom(msg.sender, address(this), totalCost);
}
Subscription storage sub = subscriptions[msg.sender][tierId];
// Renew existing or new subscription
uint256 currentExpiry = sub.expiresAt > block.timestamp
? sub.expiresAt
: block.timestamp;
sub.expiresAt = currentExpiry + months * 30 days;
sub.tierId = tierId;
sub.autoRenew = autoRenew;
if (sub.startedAt == 0) sub.startedAt = block.timestamp;
_distributeRevenue(tier, totalCost);
emit Subscribed(msg.sender, tierId, sub.expiresAt);
}
function isSubscribed(address user, uint256 tierId) external view returns (bool) {
return subscriptions[user][tierId].expiresAt > block.timestamp;
}
function _distributeRevenue(SubscriptionTier memory tier, uint256 amount) internal {
uint256 platformFee = amount * platformFeeBps / 10000;
uint256 creatorAmount = amount - platformFee;
if (tier.paymentToken == address(0)) {
payable(creator).transfer(creatorAmount);
payable(platform).transfer(platformFee);
} else {
IERC20(tier.paymentToken).transfer(creator, creatorAmount);
IERC20(tier.paymentToken).transfer(platform, platformFee);
}
}
}
Verifying Access to Encrypted Content
Can't store content on-chain and pointless. Standard scheme: content encrypted and uploaded to IPFS, encryption key given only to verified subscribers.
Problem: who gives the key? If centralized server — no point in blockchain. Solution — Lit Protocol or Threshold Network: decentralized node network stores key shards and grants access only on on-chain condition.
import { LitNodeClient, checkAndSignAuthMessage } from '@lit-protocol/lit-node-client';
// Access condition: active subscription to tier 1
const accessControlConditions = [
{
contractAddress: SUBSCRIPTION_CONTRACT_ADDRESS,
standardContractType: '',
chain: 'ethereum',
method: 'isSubscribed',
parameters: [':userAddress', '1'], // tierId = 1
returnValueTest: {
comparator: '=',
value: 'true'
}
}
];
// Encrypt content (creator does on upload)
async function encryptContent(content) {
const client = new LitNodeClient();
await client.connect();
const authSig = await checkAndSignAuthMessage({ chain: 'ethereum' });
const { encryptedString, symmetricKey } = await LitJsSdk.encryptString(content);
const encryptedSymmetricKey = await client.saveEncryptionKey({
accessControlConditions,
symmetricKey,
authSig,
chain: 'ethereum'
});
return {
encryptedContent: encryptedString,
encryptedKey: encryptedSymmetricKey
};
}
// Decrypt (user on read)
async function decryptContent(encryptedContent, encryptedKey) {
const client = new LitNodeClient();
await client.connect();
const authSig = await checkAndSignAuthMessage({ chain: 'ethereum' });
const symmetricKey = await client.getEncryptionKey({
accessControlConditions,
toDecrypt: encryptedKey,
chain: 'ethereum',
authSig
});
return await LitJsSdk.decryptString(encryptedContent, symmetricKey);
}
Monetization Models for Creator
| Model | Description | Pros | Cons |
|---|---|---|---|
| Flat subscription | Fixed monthly price | Predictable income | No premium tiers |
| Tiered access | Basic / Pro / VIP | Flexible, maximizes revenue | Harder to manage |
| Pay-per-content | Each piece separately | Monetizes hit content | User friction |
| NFT membership | Lifetime subscription as NFT | Secondary market = extra income | One-time payment |
| Hybrid | Base tier + individual buys | Optimal balance | Most complex |
NFT membership deserves attention: creator gets royalty from secondary sales (ERC-2981). If early holder resells their spot — creator gets 5–10% of resale price automatically.
Auto-Renewal via Chainlink Automation
Auto-renewal needs external trigger — smart contract can't call itself by schedule.
Chainlink Automation checks condition checkUpkeep() and calls performUpkeep() if needed:
contract AutoRenewSubscription is AutomationCompatibleInterface {
function checkUpkeep(bytes calldata) external view override
returns (bool upkeepNeeded, bytes memory performData)
{
// Find expiring subscriptions with autoRenew=true
address[] memory toRenew = _findExpiringSubscriptions();
upkeepNeeded = toRenew.length > 0;
performData = abi.encode(toRenew);
}
function performUpkeep(bytes calldata performData) external override {
address[] memory toRenew = abi.decode(performData, (address[]));
for (uint256 i = 0; i < toRenew.length; i++) {
_attemptRenewal(toRenew[i]);
}
}
function _attemptRenewal(address subscriber) internal {
// Try to charge, if sufficient balance — renew
// On failure — event, user notification
}
}
Development Timeline
Basic subscription system with one tier, Lit Protocol integration and simple frontend — 4–6 weeks. Full-featured platform with tiered access, NFT membership, auto-renewal and creator analytics — 10–14 weeks.







