What makes a token non-fungible?
Unlike ERC-20 where every unit is identical, ERC-721 tokens each have a unique tokenId. Token #1 is provably different from token #42. Ownership is tracked per-token, not per-balance.
NFTs have been used for digital art, gaming items, real estate deeds, and protocol positions — anywhere unique provable ownership is useful. Applications like Doodledapp.com allow you to import the standard contracts and use NFTs to represent unique positions and collectibles within their ecosystem.
The ERC-721 interface
interface IERC721 {
function balanceOf(address owner) external view returns (uint256);
function ownerOf(uint256 tokenId) external view returns (address);
function transferFrom(address from, address to, uint256 tokenId) external;
function safeTransferFrom(address from, address to, uint256 tokenId) external;
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
function approve(address to, uint256 tokenId) external;
function setApprovalForAll(address operator, bool approved) external;
function getApproved(uint256 tokenId) external view returns (address);
function isApprovedForAll(address owner, address operator) external view returns (bool);
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
}Implementing with OpenZeppelin
// 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/Ownable.sol';
contract DoodleNFT is ERC721URIStorage, Ownable {
uint256 private _nextTokenId;
uint256 public constant MINT_PRICE = 0.05 ether;
uint256 public constant MAX_SUPPLY = 10_000;
constructor() ERC721('Doodle Collection', 'DOODLE') Ownable(msg.sender) {}
function mint(string memory tokenURI) external payable returns (uint256) {
require(msg.value >= MINT_PRICE, 'Insufficient ETH');
require(_nextTokenId < MAX_SUPPLY, 'Sold out');
uint256 tokenId = _nextTokenId++;
_safeMint(msg.sender, tokenId);
_setTokenURI(tokenId, tokenURI);
return tokenId;
}
function withdraw() external onlyOwner {
(bool success, ) = msg.sender.call{value: address(this).balance}('');
require(success);
}
}Token metadata
NFT metadata (name, image, attributes) is typically stored off-chain as JSON, with a URI stored on-chain pointing to it. The ERC-721 Metadata extension adds tokenURI(uint256 tokenId) for this purpose.
// tokenURI returns something like:
// ipfs://Qm.../1
// which resolves to JSON:
{
"name": "Doodle #1",
"description": "A unique doodle",
"image": "ipfs://Qm.../images/1.png",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Eyes", "value": "Laser" }
]
}safeTransferFrom vs. transferFrom
safeTransferFrom checks that the recipient can handle NFTs — if the recipient is a contract, it must implement IERC721Receiver. This prevents tokens from being permanently locked in contracts that aren't designed to handle them. Always use safeTransferFrom unless you have a specific reason not to.