Overview
StoryNFT allows users to mint story collections as ERC721 NFTs. Each NFT represents a curated book/collection of stories with metadata stored on IPFS.
ERC721 : Non-fungible token standard
URI Storage : Each NFT has a unique metadata URI (IPFS hash)
Royalties : ERC2981 standard (5% default)
Mint Fee : 0.001 ETH to prevent spam
Two Mint Types : Public (paid) and admin-only (weekly winners)
Contract Address
Address
Base Sepolia Explorer
const STORY_NFT_ADDRESS = "0x6D37ebc5eAEF37ecC888689f295D114187933342" ;
Token Details
0.001 ether (1000000000000000 wei)
Contract Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20 ;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol" ;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol" ;
import "@openzeppelin/contracts/access/AccessControl.sol" ;
import "@openzeppelin/contracts/token/common/ERC2981.sol" ;
contract StoryNFT is ERC721URIStorage , AccessControl , ERC2981 {
bytes32 public constant MINTER_ROLE = keccak256 ( "MINTER_ROLE" );
uint256 private _nextTokenId;
uint256 public mintFee = 0.001 ether ;
event NFTMinted ( uint256 indexed tokenId , address indexed recipient , string uri , string collectionType );
constructor ( address defaultAdmin ) ERC721 ("EStory Collections", "ESTORY") {
_grantRole (DEFAULT_ADMIN_ROLE, defaultAdmin);
_grantRole (MINTER_ROLE, defaultAdmin);
_setDefaultRoyalty (defaultAdmin, 500 ); // 5%
}
// PUBLIC: Mint a book/collection (requires mint fee)
function mintBook ( string memory uri ) external payable {
require ( msg .value >= mintFee, "Insufficient mint fee" );
uint256 tokenId = ++ _nextTokenId;
_safeMint ( msg.sender , tokenId);
_setTokenURI (tokenId, uri);
emit NFTMinted (tokenId, msg.sender , uri, "BOOK" );
}
// ADMIN: Mint weekly winner NFT (free)
function mintWeeklyWinner ( address winner , string memory uri ) external onlyRole ( MINTER_ROLE ) {
uint256 tokenId = ++ _nextTokenId;
_safeMint (winner, tokenId);
_setTokenURI (tokenId, uri);
emit NFTMinted (tokenId, winner, uri, "WEEKLY_WINNER" );
}
// Admin functions
function setMintFee ( uint256 _fee ) external onlyRole ( DEFAULT_ADMIN_ROLE ) {
mintFee = _fee;
}
function withdraw () external onlyRole ( DEFAULT_ADMIN_ROLE ) {
uint256 balance = address ( this ).balance;
require (balance > 0 , "No balance to withdraw" );
( bool success, ) = payable ( msg.sender ).call{value : balance}( "" );
require (success, "Withdrawal failed" );
}
function setDefaultRoyalty ( address receiver , uint96 feeNumerator ) external onlyRole ( DEFAULT_ADMIN_ROLE ) {
_setDefaultRoyalty (receiver, feeNumerator);
}
// Overrides
function tokenURI ( uint256 tokenId ) public view override ( ERC721URIStorage ) returns ( string memory ) {
return super . tokenURI (tokenId);
}
function supportsInterface ( bytes4 interfaceId )
public view override ( ERC721URIStorage , AccessControl , ERC2981 ) returns ( bool )
{
return super . supportsInterface (interfaceId);
}
}
Functions
mintBook (Public)
Mint a new story collection NFT.
function mintBook ( string memory uri ) external payable
IPFS URI containing NFT metadata (e.g., ipfs://Qm...)
Must send at least mintFee (0.001 ETH) with transaction
Auto-incremented token ID starting from 1
Emitted with tokenId, recipient, uri, and collectionType: "BOOK"
Requirements :
Must send at least 0.001 ETH (reverts if msg.value < mintFee)
URI should point to valid IPFS metadata
Use Case : Users mint NFTs to represent curated story collections (books, themes, weekly highlights).
mintWeeklyWinner (Admin Only)
Mint a free NFT for weekly contest winners.
function mintWeeklyWinner ( address winner , string memory uri ) external onlyRole ( MINTER_ROLE )
IPFS URI for winner’s NFT metadata
Admin-Only : Only addresses with MINTER_ROLE can call this.Use Case : Backend calls this to reward weekly challenge winners with free commemorative NFTs.
setMintFee (Admin Only)
Update the mint fee.
function setMintFee ( uint256 _fee ) external onlyRole ( DEFAULT_ADMIN_ROLE )
New mint fee in wei (e.g., 0.001 ether = 1000000000000000)
withdraw (Admin Only)
Withdraw accumulated mint fees.
function withdraw () external onlyRole ( DEFAULT_ADMIN_ROLE )
Transfers contract ETH balance to admin.
Reverts if balance is 0 or transfer fails.
setDefaultRoyalty (Admin Only)
Update royalty settings for all NFTs.
function setDefaultRoyalty ( address receiver , uint96 feeNumerator ) external onlyRole ( DEFAULT_ADMIN_ROLE )
Address to receive royalty payments
Royalty in basis points (500 = 5%, 1000 = 10%)
ERC2981 uses basis points: 1 basis point = 0.01%. Examples:
500 = 5%
1000 = 10%
250 = 2.5%
Events
NFTMinted
event NFTMinted ( uint256 indexed tokenId , address indexed recipient , string uri , string collectionType );
Emitted when an NFT is minted.
Either "BOOK" (public mint) or "WEEKLY_WINNER" (admin mint)
Each NFT’s tokenURI() returns an IPFS URI pointing to metadata JSON:
{
"name" : "My Life in 2026" ,
"description" : "A curated collection of 52 stories from my journey through 2026" ,
"image" : "ipfs://QmImageHash..." ,
"attributes" : [
{
"trait_type" : "Story Count" ,
"value" : 52
},
{
"trait_type" : "Date Range" ,
"value" : "Jan 2026 - Dec 2026"
},
{
"trait_type" : "Word Count" ,
"value" : 12500
},
{
"trait_type" : "Collection Type" ,
"value" : "Annual Book"
}
]
}
IPFS Best Practices :
Pin metadata to IPFS via Pinata (iStory uses Pinata for persistence)
Generate image from first story or use custom book cover
Include story IDs in metadata for traceability
Use CIDv1 for content addressing: ipfs://bafybei...
Usage Examples
Mint a Book NFT
React Hook
Direct Contract Call
import { useStoryNFT } from '@/app/hooks/useStoryNFT' ;
import { parseEther } from 'viem' ;
async function mintCollection ( ipfsUri : string ) {
const { mintBook } = useStoryNFT ();
// Upload metadata to IPFS first (via /api/ipfs/upload)
const response = await fetch ( '/api/ipfs/upload' , {
method: 'POST' ,
body: JSON . stringify ({
name: "My 2026 Journey" ,
description: "52 stories from 2026" ,
image: "ipfs://QmImageHash..." ,
attributes: [ ... ]
})
});
const { ipfsUri } = await response . json ();
// Mint NFT (must send 0.001 ETH)
await mintBook ( ipfsUri , { value: parseEther ( "0.001" ) });
toast . success ( "NFT minted successfully!" );
}
Get User’s NFTs
import { useReadContract } from 'wagmi' ;
import { STORY_NFT_ABI , STORY_NFT_ADDRESS } from '@/lib/contracts' ;
const { data : balance } = useReadContract ({
address: STORY_NFT_ADDRESS ,
abi: STORY_NFT_ABI ,
functionName: 'balanceOf' ,
args: [ userAddress ]
});
console . log ( `User owns ${ balance } NFTs` );
import { publicClient } from '@/lib/wagmi' ;
import { STORY_NFT_ABI , STORY_NFT_ADDRESS } from '@/lib/contracts' ;
const tokenId = 1 n ;
// Get tokenURI
const uri = await publicClient . readContract ({
address: STORY_NFT_ADDRESS ,
abi: STORY_NFT_ABI ,
functionName: 'tokenURI' ,
args: [ tokenId ]
});
// Fetch metadata from IPFS
const response = await fetch ( uri . replace ( 'ipfs://' , 'https://ipfs.io/ipfs/' ));
const metadata = await response . json ();
console . log ( "NFT Name:" , metadata . name );
console . log ( "Description:" , metadata . description );
console . log ( "Image:" , metadata . image );
Mint Weekly Winner (Backend)
import { createWalletClient , http } from 'viem' ;
import { privateKeyToAccount } from 'viem/accounts' ;
import { baseSepolia } from 'viem/chains' ;
import { STORY_NFT_ABI , STORY_NFT_ADDRESS } from '@/lib/contracts' ;
const account = privateKeyToAccount ( process . env . MINTER_PRIVATE_KEY );
const walletClient = createWalletClient ({
account ,
chain: baseSepolia ,
transport: http ()
});
// Upload winner metadata to IPFS
const ipfsUri = await uploadWinnerMetadata ( winnerId );
// Mint free NFT
const tx = await walletClient . writeContract ({
address: STORY_NFT_ADDRESS ,
abi: STORY_NFT_ABI ,
functionName: 'mintWeeklyWinner' ,
args: [ winnerAddress , ipfsUri ]
});
await publicClient . waitForTransactionReceipt ({ hash: tx });
console . log ( "Weekly winner NFT minted!" );
Royalties (ERC2981)
StoryNFT implements ERC2981 for automatic royalty distribution on secondary sales.
function royaltyInfo ( uint256 tokenId , uint256 salePrice )
external
view
returns ( address receiver , uint256 royaltyAmount )
Address to receive royalty (default: contract admin)
5% of sale price (500 basis points)
Marketplace Integration : NFT marketplaces like OpenSea and Rarible automatically read royaltyInfo() and pay the royalty on secondary sales.Example :
NFT sells for 1 ETH
Royalty: 0.05 ETH (5%) goes to admin
Seller receives: 0.95 ETH
Security Considerations
Mint Fee Protection :require ( msg .value >= mintFee, "Insufficient mint fee" );
Prevents:
Spam minting (economic cost)
Frontrunning (user must commit ETH)
Recommendation : Set mintFee high enough to deter abuse but low enough to be accessible (current: 0.001 ETH ≈ $2-4).
URI Mutability :Token URIs are set once at mint and cannot be changed. This ensures NFT metadata is immutable. If you need mutable metadata, consider:
Using a metadata service that returns dynamic JSON
Implementing a setTokenURI() function with owner check
Using ERC721URIStorage with an admin update function
Gas Costs
Typical gas costs on Base Sepolia:
Function Gas Used Cost @ 0.1 gwei mintBook()~150,000 ~0.000015 ETH mintWeeklyWinner()~145,000 ~0.0000145 ETH setMintFee()~30,000 ~0.000003 ETH withdraw()~25,000 ~0.0000025 ETH
Base L2 Advantages :
10x cheaper gas than Ethereum mainnet
Fast block times (~2 seconds)
EVM-compatible (no code changes from mainnet)
ABI Reference
export const STORY_NFT_ABI = [
"function mintBook(string memory uri)" ,
"function balanceOf(address owner) view returns (uint256)" ,
"function tokenURI(uint256 tokenId) view returns (string)" ,
"event NFTMinted(uint256 indexed tokenId, address indexed recipient, string uri, string collectionType)"
] as const ;
Next Steps
Blockchain Storage Learn how to pin NFT metadata to IPFS via Pinata
NFT Minting Build a gallery to showcase user NFT collections