🖼️ Blockchain NFT, ERC721 From Basics To Production 🚀
1. ERC721 Interface and Implementation 🖼️
1. ERC721 Interface
The ERC721 standard defines a non-fungible token (NFT), allowing developers to create unique and indivisible assets. Below is the Solidity interface definition for ERC721, as defined in the EIP-721 proposal. ETHEREUM.ORG
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC721 {
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);
function balanceOf(address owner) external view returns (uint256 balance);
function ownerOf(uint256 tokenId) external view returns (address owner);
function safeTransferFrom(address from, address to, uint256 tokenId) external;
function transferFrom(address from, address to, uint256 tokenId) external;
function approve(address to, uint256 tokenId) external;
function getApproved(uint256 tokenId) external view returns (address operator);
function setApprovalForAll(address operator, bool _approved) external;
function isApprovedForAll(address owner, address operator) external view returns (bool);
function safeTransferFrom(
address from,
address to,
uint256 tokenId,
bytes calldata data
) external;
}
2. Simplified Implementation 🧪
Here’s a minimal ERC721 implementation without dependencies:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SimpleERC721 {
// Token name and symbol
string public name;
string public symbol;
// Mapping from token ID to owner
mapping(uint256 => address) private _owners;
// Mapping owner address to token count
mapping(address => uint256) private _balances;
// Mapping from token ID to approved address
mapping(uint256 => address) private _tokenApprovals;
// Mapping from owner to operator approvals
mapping(address => mapping(address => bool)) private _operatorApprovals;
// Events
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);
constructor(string memory _name, string memory _symbol) {
name = _name;
symbol = _symbol;
}
// ERC721 functions
function balanceOf(address owner) public view returns (uint256) {
require(owner != address(0), "Zero address");
return _balances[owner];
}
function ownerOf(uint256 tokenId) public view returns (address) {
address owner = _owners[tokenId];
require(owner != address(0), "Token does not exist");
return owner;
}
function transferFrom(
address from,
address to,
uint256 tokenId
) public {
require(ownerOf(tokenId) == from, "Not the owner");
require(
msg.sender == from || getApproved(tokenId) == msg.sender || isApprovedForAll(from, msg.sender),
"Not approved"
);
require(to != address(0), "Transfer to zero address");
// Clear approvals
_approve(address(0), tokenId);
// Update balances and ownership
_balances[from] -= 1;
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
}
function approve(address to, uint256 tokenId) public {
address owner = ownerOf(tokenId);
require(msg.sender == owner || isApprovedForAll(owner, msg.sender), "Not approved");
_approve(to, tokenId);
}
function getApproved(uint256 tokenId) public view returns (address) {
require(_owners[tokenId] != address(0), "Token does not exist");
return _tokenApprovals[tokenId];
}
function setApprovalForAll(address operator, bool approved) public {
_operatorApprovals[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}
function isApprovedForAll(address owner, address operator) public view returns (bool) {
return _operatorApprovals[owner][operator];
}
function _approve(address to, uint256 tokenId) private {
_tokenApprovals[tokenId] = to;
emit Approval(ownerOf(tokenId), to, tokenId);
}
function _mint(address to, uint256 tokenId) internal {
require(to != address(0), "Mint to zero address");
require(_owners[tokenId] == address(0), "Token already minted");
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(address(0), to, tokenId);
}
function _burn(uint256 tokenId) internal {
address owner = ownerOf(tokenId);
// Clear approvals
_approve(address(0), tokenId);
_balances[owner] -= 1;
delete _owners[tokenId];
emit Transfer(owner, address(0), tokenId);
}
}
Key Features:
- No External Libraries: Completely standalone implementation.
- Core ERC721 Features:
- Minting (
_mint
) - Burning (
_burn
) - Transfers (
transferFrom
) - Approvals and operator management.
- Compliance: Implements all mandatory features of the ERC721 standard.
This basic implementation is ideal for understanding the standard and its mechanisms. For production use, additional checks (like safeTransferFrom
) and optimizations are recommended.
3. Production-Grade ERC721 ✅
Here’s an overview of OpenZeppelin’s production-grade ERC721 (Openzeppelin release-v5.0
) implementation along with the main differences from the previous simplified version and why those differences are meaningful.
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.0.0) (token/ERC721/ERC721.sol)
pragma solidity ^0.8.20;
import {IERC721} from "./IERC721.sol";
import {IERC721Receiver} from "./IERC721Receiver.sol";
import {IERC721Metadata} from "./extensions/IERC721Metadata.sol";
import {Context} from "../../utils/Context.sol";
import {Strings} from "../../utils/Strings.sol";
import {IERC165, ERC165} from "../../utils/introspection/ERC165.sol";
import {IERC721Errors} from "../../interfaces/draft-IERC6093.sol";
/**
* @dev Implementation of https://eips.ethereum.org/EIPS/eip-721[ERC721] Non-Fungible Token Standard, including
* the Metadata extension, but not including the Enumerable extension, which is available separately as
* {ERC721Enumerable}.
*/
abstract contract ERC721 is Context, ERC165, IERC721, IERC721Metadata, IERC721Errors {
using Strings for uint256;
// Token name
string private _name;
// Token symbol
string private _symbol;
mapping(uint256 tokenId => address) private _owners;
mapping(address owner => uint256) private _balances;
mapping(uint256 tokenId => address) private _tokenApprovals;
mapping(address owner => mapping(address operator => bool)) private _operatorApprovals;
/**
* @dev Initializes the contract by setting a `name` and a `symbol` to the token collection.
*/
constructor(string memory name_, string memory symbol_) {
_name = name_;
_symbol = symbol_;
}
/**
* @dev See {IERC165-supportsInterface}.
*/
function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) {
return
interfaceId == type(IERC721).interfaceId ||
interfaceId == type(IERC721Metadata).interfaceId ||
super.supportsInterface(interfaceId);
}
/**
* @dev See {IERC721-balanceOf}.
*/
function balanceOf(address owner) public view virtual returns (uint256) {
if (owner == address(0)) {
revert ERC721InvalidOwner(address(0));
}
return _balances[owner];
}
/**
* @dev See {IERC721-ownerOf}.
*/
function ownerOf(uint256 tokenId) public view virtual returns (address) {
return _requireOwned(tokenId);
}
/**
* @dev See {IERC721Metadata-name}.
*/
function name() public view virtual returns (string memory) {
return _name;
}
/**
* @dev See {IERC721Metadata-symbol}.
*/
function symbol() public view virtual returns (string memory) {
return _symbol;
}
/**
* @dev See {IERC721Metadata-tokenURI}.
*/
function tokenURI(uint256 tokenId) public view virtual returns (string memory) {
_requireOwned(tokenId);
string memory baseURI = _baseURI();
return bytes(baseURI).length > 0 ? string.concat(baseURI, tokenId.toString()) : "";
}
/**
* @dev Base URI for computing {tokenURI}. If set, the resulting URI for each
* token will be the concatenation of the `baseURI` and the `tokenId`. Empty
* by default, can be overridden in child contracts.
*/
function _baseURI() internal view virtual returns (string memory) {
return "";
}
/**
* @dev See {IERC721-approve}.
*/
function approve(address to, uint256 tokenId) public virtual {
_approve(to, tokenId, _msgSender());
}
/**
* @dev See {IERC721-getApproved}.
*/
function getApproved(uint256 tokenId) public view virtual returns (address) {
_requireOwned(tokenId);
return _getApproved(tokenId);
}
/**
* @dev See {IERC721-setApprovalForAll}.
*/
function setApprovalForAll(address operator, bool approved) public virtual {
_setApprovalForAll(_msgSender(), operator, approved);
}
/**
* @dev See {IERC721-isApprovedForAll}.
*/
function isApprovedForAll(address owner, address operator) public view virtual returns (bool) {
return _operatorApprovals[owner][operator];
}
/**
* @dev See {IERC721-transferFrom}.
*/
function transferFrom(address from, address to, uint256 tokenId) public virtual {
if (to == address(0)) {
revert ERC721InvalidReceiver(address(0));
}
// Setting an "auth" arguments enables the `_isAuthorized` check which verifies that the token exists
// (from != 0). Therefore, it is not needed to verify that the return value is not 0 here.
address previousOwner = _update(to, tokenId, _msgSender());
if (previousOwner != from) {
revert ERC721IncorrectOwner(from, tokenId, previousOwner);
}
}
/**
* @dev See {IERC721-safeTransferFrom}.
*/
function safeTransferFrom(address from, address to, uint256 tokenId) public {
safeTransferFrom(from, to, tokenId, "");
}
/**
* @dev See {IERC721-safeTransferFrom}.
*/
function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual {
transferFrom(from, to, tokenId);
_checkOnERC721Received(from, to, tokenId, data);
}
/**
* @dev Returns the owner of the `tokenId`. Does NOT revert if token doesn't exist
*
* IMPORTANT: Any overrides to this function that add ownership of tokens not tracked by the
* core ERC721 logic MUST be matched with the use of {_increaseBalance} to keep balances
* consistent with ownership. The invariant to preserve is that for any address `a` the value returned by
* `balanceOf(a)` must be equal to the number of tokens such that `_ownerOf(tokenId)` is `a`.
*/
function _ownerOf(uint256 tokenId) internal view virtual returns (address) {
return _owners[tokenId];
}
/**
* @dev Returns the approved address for `tokenId`. Returns 0 if `tokenId` is not minted.
*/
function _getApproved(uint256 tokenId) internal view virtual returns (address) {
return _tokenApprovals[tokenId];
}
/**
* @dev Returns whether `spender` is allowed to manage `owner`'s tokens, or `tokenId` in
* particular (ignoring whether it is owned by `owner`).
*
* WARNING: This function assumes that `owner` is the actual owner of `tokenId` and does not verify this
* assumption.
*/
function _isAuthorized(address owner, address spender, uint256 tokenId) internal view virtual returns (bool) {
return
spender != address(0) &&
(owner == spender || isApprovedForAll(owner, spender) || _getApproved(tokenId) == spender);
}
/**
* @dev Checks if `spender` can operate on `tokenId`, assuming the provided `owner` is the actual owner.
* Reverts if `spender` does not have approval from the provided `owner` for the given token or for all its assets
* the `spender` for the specific `tokenId`.
*
* WARNING: This function assumes that `owner` is the actual owner of `tokenId` and does not verify this
* assumption.
*/
function _checkAuthorized(address owner, address spender, uint256 tokenId) internal view virtual {
if (!_isAuthorized(owner, spender, tokenId)) {
if (owner == address(0)) {
revert ERC721NonexistentToken(tokenId);
} else {
revert ERC721InsufficientApproval(spender, tokenId);
}
}
}
/**
* @dev Unsafe write access to the balances, used by extensions that "mint" tokens using an {ownerOf} override.
*
* NOTE: the value is limited to type(uint128).max. This protect against _balance overflow. It is unrealistic that
* a uint256 would ever overflow from increments when these increments are bounded to uint128 values.
*
* WARNING: Increasing an account's balance using this function tends to be paired with an override of the
* {_ownerOf} function to resolve the ownership of the corresponding tokens so that balances and ownership
* remain consistent with one another.
*/
function _increaseBalance(address account, uint128 value) internal virtual {
unchecked {
_balances[account] += value;
}
}
/**
* @dev Transfers `tokenId` from its current owner to `to`, or alternatively mints (or burns) if the current owner
* (or `to`) is the zero address. Returns the owner of the `tokenId` before the update.
*
* The `auth` argument is optional. If the value passed is non 0, then this function will check that
* `auth` is either the owner of the token, or approved to operate on the token (by the owner).
*
* Emits a {Transfer} event.
*
* NOTE: If overriding this function in a way that tracks balances, see also {_increaseBalance}.
*/
function _update(address to, uint256 tokenId, address auth) internal virtual returns (address) {
address from = _ownerOf(tokenId);
// Perform (optional) operator check
if (auth != address(0)) {
_checkAuthorized(from, auth, tokenId);
}
// Execute the update
if (from != address(0)) {
// Clear approval. No need to re-authorize or emit the Approval event
_approve(address(0), tokenId, address(0), false);
unchecked {
_balances[from] -= 1;
}
}
if (to != address(0)) {
unchecked {
_balances[to] += 1;
}
}
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
return from;
}
/**
* @dev Mints `tokenId` and transfers it to `to`.
*
* WARNING: Usage of this method is discouraged, use {_safeMint} whenever possible
*
* Requirements:
*
* - `tokenId` must not exist.
* - `to` cannot be the zero address.
*
* Emits a {Transfer} event.
*/
function _mint(address to, uint256 tokenId) internal {
if (to == address(0)) {
revert ERC721InvalidReceiver(address(0));
}
address previousOwner = _update(to, tokenId, address(0));
if (previousOwner != address(0)) {
revert ERC721InvalidSender(address(0));
}
}
/**
* @dev Mints `tokenId`, transfers it to `to` and checks for `to` acceptance.
*
* Requirements:
*
* - `tokenId` must not exist.
* - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer.
*
* Emits a {Transfer} event.
*/
function _safeMint(address to, uint256 tokenId) internal {
_safeMint(to, tokenId, "");
}
/**
* @dev Same as {xref-ERC721-_safeMint-address-uint256-}[`_safeMint`], with an additional `data` parameter which is
* forwarded in {IERC721Receiver-onERC721Received} to contract recipients.
*/
function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual {
_mint(to, tokenId);
_checkOnERC721Received(address(0), to, tokenId, data);
}
/**
* @dev Destroys `tokenId`.
* The approval is cleared when the token is burned.
* This is an internal function that does not check if the sender is authorized to operate on the token.
*
* Requirements:
*
* - `tokenId` must exist.
*
* Emits a {Transfer} event.
*/
function _burn(uint256 tokenId) internal {
address previousOwner = _update(address(0), tokenId, address(0));
if (previousOwner == address(0)) {
revert ERC721NonexistentToken(tokenId);
}
}
/**
* @dev Transfers `tokenId` from `from` to `to`.
* As opposed to {transferFrom}, this imposes no restrictions on msg.sender.
*
* Requirements:
*
* - `to` cannot be the zero address.
* - `tokenId` token must be owned by `from`.
*
* Emits a {Transfer} event.
*/
function _transfer(address from, address to, uint256 tokenId) internal {
if (to == address(0)) {
revert ERC721InvalidReceiver(address(0));
}
address previousOwner = _update(to, tokenId, address(0));
if (previousOwner == address(0)) {
revert ERC721NonexistentToken(tokenId);
} else if (previousOwner != from) {
revert ERC721IncorrectOwner(from, tokenId, previousOwner);
}
}
/**
* @dev Safely transfers `tokenId` token from `from` to `to`, checking that contract recipients
* are aware of the ERC721 standard to prevent tokens from being forever locked.
*
* `data` is additional data, it has no specified format and it is sent in call to `to`.
*
* This internal function is like {safeTransferFrom} in the sense that it invokes
* {IERC721Receiver-onERC721Received} on the receiver, and can be used to e.g.
* implement alternative mechanisms to perform token transfer, such as signature-based.
*
* Requirements:
*
* - `tokenId` token must exist and be owned by `from`.
* - `to` cannot be the zero address.
* - `from` cannot be the zero address.
* - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer.
*
* Emits a {Transfer} event.
*/
function _safeTransfer(address from, address to, uint256 tokenId) internal {
_safeTransfer(from, to, tokenId, "");
}
/**
* @dev Same as {xref-ERC721-_safeTransfer-address-address-uint256-}[`_safeTransfer`], with an additional `data` parameter which is
* forwarded in {IERC721Receiver-onERC721Received} to contract recipients.
*/
function _safeTransfer(address from, address to, uint256 tokenId, bytes memory data) internal virtual {
_transfer(from, to, tokenId);
_checkOnERC721Received(from, to, tokenId, data);
}
/**
* @dev Approve `to` to operate on `tokenId`
*
* The `auth` argument is optional. If the value passed is non 0, then this function will check that `auth` is
* either the owner of the token, or approved to operate on all tokens held by this owner.
*
* Emits an {Approval} event.
*
* Overrides to this logic should be done to the variant with an additional `bool emitEvent` argument.
*/
function _approve(address to, uint256 tokenId, address auth) internal {
_approve(to, tokenId, auth, true);
}
/**
* @dev Variant of `_approve` with an optional flag to enable or disable the {Approval} event. The event is not
* emitted in the context of transfers.
*/
function _approve(address to, uint256 tokenId, address auth, bool emitEvent) internal virtual {
// Avoid reading the owner unless necessary
if (emitEvent || auth != address(0)) {
address owner = _requireOwned(tokenId);
// We do not use _isAuthorized because single-token approvals should not be able to call approve
if (auth != address(0) && owner != auth && !isApprovedForAll(owner, auth)) {
revert ERC721InvalidApprover(auth);
}
if (emitEvent) {
emit Approval(owner, to, tokenId);
}
}
_tokenApprovals[tokenId] = to;
}
/**
* @dev Approve `operator` to operate on all of `owner` tokens
*
* Requirements:
* - operator can't be the address zero.
*
* Emits an {ApprovalForAll} event.
*/
function _setApprovalForAll(address owner, address operator, bool approved) internal virtual {
if (operator == address(0)) {
revert ERC721InvalidOperator(operator);
}
_operatorApprovals[owner][operator] = approved;
emit ApprovalForAll(owner, operator, approved);
}
/**
* @dev Reverts if the `tokenId` doesn't have a current owner (it hasn't been minted, or it has been burned).
* Returns the owner.
*
* Overrides to ownership logic should be done to {_ownerOf}.
*/
function _requireOwned(uint256 tokenId) internal view returns (address) {
address owner = _ownerOf(tokenId);
if (owner == address(0)) {
revert ERC721NonexistentToken(tokenId);
}
return owner;
}
/**
* @dev Private function to invoke {IERC721Receiver-onERC721Received} on a target address. This will revert if the
* recipient doesn't accept the token transfer. The call is not executed if the target address is not a contract.
*
* @param from address representing the previous owner of the given token ID
* @param to target address that will receive the tokens
* @param tokenId uint256 ID of the token to be transferred
* @param data bytes optional data to send along with the call
*/
function _checkOnERC721Received(address from, address to, uint256 tokenId, bytes memory data) private {
if (to.code.length > 0) {
try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
if (retval != IERC721Receiver.onERC721Received.selector) {
revert ERC721InvalidReceiver(to);
}
} catch (bytes memory reason) {
if (reason.length == 0) {
revert ERC721InvalidReceiver(to);
} else {
/// @solidity memory-safe-assembly
assembly {
revert(add(32, reason), mload(reason))
}
}
}
}
}
}
Key Differences 🤔
1. Encapsulation and Flexibility
- Simplified Version:
name
andsymbol
are public, with automatically generated getter functions for external access. - OpenZeppelin:
name
andsymbol
are private, with explicit getter functions (name()
andsymbol()
) defined for controlled external access. Thevirtual
keyword allows derived contracts to override thename
andsymbol
getter functions for more complex behavior (e.g., dynamically returning values).
Why it makes sense: Using private
with custom getters enhances encapsulation and enables flexibility to add logic in the future, aligning with best practices for secure and maintainable contract design. This approach aligns closely with OpenZeppelin's implementation of ERC721Metadata
and complies with the ERC721 standard. Public variables are simpler but limit potential extensibility.
2. Standards and Extensibility
- Simplified Version: Implements only core ERC721 functions (
balanceOf
,ownerOf
,transferFrom
, etc.). - OpenZeppelin: Implements not only the core ERC721 but also its extensions (
IERC721Receiver.sol
,IERC721Metadata
, ) .
Why it makes sense: Extensions allow the contract to receive NFTs (Non-Fungible Tokens) being sent to it from another contract that using the transferFrom
function of the ERC721 standard, and manage metadata (e.g., tokenURI
), which are vital in most production scenarios, useful for NFT projects needing dynamic or on-chain updates.
3. Safe Transfers & Mint
- Simplified Version: Only
transferFrom
is implemented, risking tokens being sent to non-compliant contracts. - OpenZeppelin: Adds
safeTransferFrom
, which ensures token transfers to contracts only if they implementonERC721Received
.
Why it makes sense: Protects tokens from being locked forever in non-ERC721-compliant contracts.
4. ERC165 Interface Support
- Simplified Version: Does not implement ERC165 interface detection.
- OpenZeppelin: Extends
ERC165
to enable interface detection viasupportsInterface
.
Why it matters: ERC165 compliance allows external applications to query and confirm whether the contract supports specific standards (e.g., ERC721, ERC721Metadata). This ensures compatibility across the ecosystem.
5. Gas Optimizations
- Simplified Version: Lacks optimized gas-saving techniques.
- OpenZeppelin: Utilizes various optimizations:
- Uses
Strings
library with method likeconcat
,toString
, for efficient conversions. - Reduces redundant storage reads.
- Implements packed storage techniques in certain cases.
- Uses
Why it matters: Gas optimization is critical for ensuring cost efficiency, especially in high-frequency minting or transfer scenarios.
6. Compliance and Modularity
- Simplified Version: Hardcoded and standalone.
- OpenZeppelin: Fully modular, allowing the inclusion of other standards or upgrades (e.g.,
Ownable
,Pausable
,AccessControl
).
Why it makes sense: Simplifies development by leveraging tested, reusable components.
7. Enumerable Tokens (Option)
- Simplified Version: Does not track tokens globally or by owner.
- OpenZeppelin: Supports the optional
IERC721Enumerable
extension, enabling:- Querying the total supply of tokens.
- Enumerating all token IDs owned by an address.
- Enumerating all token IDs globally.
Why it matters: Enumeration is critical for marketplaces or applications needing to list or search tokens.
8. Security Enhancements
- Simplified Version: Does not address potential reentrancy or other advanced vulnerabilities.
- OpenZeppelin: Includes:
- Reentrancy protection. (Check-Effect-Interaction)
- Fallback mechanisms for failing external calls. (Try-Catch)
- Comprehensive input validation.
Why it matters: Robust security practices are essential to protect funds, ensure token integrity, and prevent exploits.
9. Standard Errors Handling
- Simplified Version: Use traditional error handling, with
require
and text message. - OpenZeppelin: Use IERC721Errors which is an interface in
OpenZeppelin's ERC721
implementation that defines a set of error messages (ERC721InvalidOwner
,ERC721NonexistentToken
,ERC721IncorrectOwner
...) that can be used to handle errors and exceptions in the contract. The purpose of IERC721Errors is to provide a standardized way of handling errors and exceptions in the contract, making it easier to debug and maintain the contract.
Why it matters: By using these error messages, the contract can provide more informative and user-friendly error messages, making it easier for users to understand and debug errors.
10. Upgradeable Proxy Support (Option)
- Simplified Version: Static implementation without upgrade paths.
- OpenZeppelin: Compatible with proxy patterns (e.g.,
TransparentUpgradeableProxy
andUUPSUpgradeable
).
Why it matters: Upgradability allows developers to patch vulnerabilities or introduce new features without redeploying the entire contract.
11. Integration with Access Control
- Simplified Version: No access control mechanisms.
- OpenZeppelin: Integrates seamlessly with
Ownable
orAccessControl
for role-based permissioning (e.g., only an admin can mint or burn).
Why it matters: Access control is critical in production to prevent unauthorized actions and centralize key operations.
2. Making Your Own NFTs 🚀
Here's a typical usage of an ERC721 NFT contract using OpenZeppelin version 5.0.0
. This implementation includes features like metadata, minting, access control, and pausing functionality, which are commonly used in production environments.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
contract MyNFT is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable, Pausable {
uint256 private _tokenIdCounter;
constructor() ERC721("MyNFT", "MNFT") ERC721Enumerable() ERC721URIStorage() Ownable(msg.sender) Pausable() {}
// Minting function with metadata URI
function mintNFT(address to, string memory uri) public onlyOwner {
uint256 tokenId = _tokenIdCounter;
_tokenIdCounter += 1;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
}
// Pausing functionality
function pause() public onlyOwner {
_pause();
}
function unpause() public onlyOwner {
_unpause();
}
// Need to be overriden because two or more base classes define function with same name and parameter types.
function _update(address to, uint256 tokenId,address auth) internal override(ERC721, ERC721Enumerable) whenNotPaused returns (address) {
return super._update(to, tokenId, auth);
}
// Need to be overriden because two or more base classes define function with same name and parameter types.
function _increaseBalance(address account, uint128 amount) internal override(ERC721, ERC721Enumerable) {
super._increaseBalance(account, amount);
}
// Override burn functionality to clear metadata
function _burn(uint256 tokenId)
internal
override(ERC721)
{
super._burn(tokenId);
}
// Need to be overriden because two or more base classes define function with same name and parameter types.
function tokenURI(uint256 tokenId)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}
// Need to be overriden because two or more base classes define function with same name and parameter types.
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC721Enumerable, ERC721URIStorage)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
Key Features ✨
- ERC721 Standards:
- Inherits from
ERC721
, which includes core NFT functionality. - Extends
ERC721Enumerable
for total supply and token enumeration.
- Metadata Handling:
- Implements
ERC721URIStorage
for storing token-specific metadata.
- Access Control:
- Uses
Ownable
to restrict minting and administrative actions to the contract owner.
- Pausing:
- Includes
Pausable
to enable and disable transfers in case of emergencies.
- Burn Functionality:
- Allows tokens to be burned with metadata cleared.
- Scalable Minting:
- A
_tokenIdCounter
ensures unique token IDs.
Usage Notes 📝
- The contract's metadata (e.g., image or file URIs) should be hosted off-chain using decentralized storage like IPFS.
- Pausing functionality helps mitigate risks during exploits.
- The
supportsInterface
method ensures compatibility with tools that query the contract's features.
For production, ensure:
- Proper testing with tools like Hardhat or Foundry.
- Security audits to catch vulnerabilities.
- Deployment on a reliable blockchain network with sufficient monitoring.
Techinal Notes ⚙️
- The contract is using multiple inheritance, and uses a linearization algorithm called C3 Linearization to resolve it.
- In Solidity, if contract C inherits from both contract A and contract B, and contract B also inherits from contract A, then contract C will inherit from the same contract A. This is known as the
Diamond Problem
in multiple inheritance. Following is an example to illustrate this:pragma solidity ^0.8.20; contract A { function foo() public pure virtual returns (string memory) { return "A"; } } contract B is A { function foo() public pure virtual override returns (string memory) { return "B"; } } contract C is A, B { // C inherits from both A and B, but B also inherits from A // The C3 Linearization ensures that the correct method is called function foo() public pure override(A, B) returns (string memory) { return super.foo(); } }
- In this example:
- Contract
A
defines a functionfoo
. - Contract
B
inherits fromA
and overridesfoo
. - Contract
C
inherits from bothA
andB
.
- Contract
- When
C
inherits from bothA
andB
, it will use the foo implementation fromB
becauseB
is closer in the inheritance hierarchy. Theoverride(A, B)
syntax inC
ensures that the correct method is called according to the C3 Linearization. - So, in summary, contract
C
will inherit from the same contractA
through both direct inheritance and through contractB
.
3. NFT Content Formats & Storage 🗂️
1. Common Formats of NFT Content 📦
NFTs (Non-Fungible Tokens) often use digital files as their underlying assets, including:
1. Images
- Formats: JPEG, PNG, SVG, or WebP.
- Use Cases: Profile pictures (PFPs), digital art, and collectibles.
2. GIFs and Videos
- Formats: GIF, MP4, AVI, or MOV.
- Use Cases: Motion-based art, animations, or short clips.
3. Audio
- Formats: MP3, WAV, or FLAC.
- Use Cases: Music NFTs, podcasts, or sound effects.
4. 3D Models
- Formats: GLTF, OBJ, or FBX.
- Use Cases: Virtual reality assets, game items, or digital sculptures.
5. Documents or Texts
- Formats: PDFs, HTML, or even plain text.
- Use Cases: eBooks, event tickets, or licenses.
2. Common Ways to Store NFT Content 💾
NFT content storage can occur on-chain, off-chain, or a hybrid of both:
1. On-Chain Storage
- Entire content is stored directly on the blockchain.
- Ensures immutability and decentralization but is costly due to high storage fees.
- Example: Text-based NFTs or very small-sized assets.
2. Off-Chain Storage
- Content is hosted on external servers or distributed networks, while the blockchain holds a reference (URI).
- Common Methods:
- IPFS (InterPlanetary File System): Decentralized, secure, and resistant to tampering.
- Centralized Servers: Quick but risky due to single-point-of-failure.
3. Hybrid Storage
- Metadata and references stored on-chain.
- Actual files stored off-chain (e.g., on IPFS or Arweave).
The choice of storage depends on the balance between cost, security, and permanence. IPFS is a widely used standard for storing NFT content in decentralized systems.
3. Popular IPFS-based services 🌐
Popular IPFS-based services used for hosting NFT metadata include:
-
Pinata: Widely used for its robust pinning and metadata management features. Pinata offers a user-friendly interface, API integrations, and high reliability for decentralized metadata storage, making it a favorite for NFT projects and marketplaces like OpenSea PINATA | THE INTERNET'S FILE API, THIRDWEB .
-
Filebase: A service that combines IPFS with easy-to-use integrations and a reliable infrastructure. It offers features like cross-cloud storage and simplified data management for developers looking to store NFT assets securely PINATA | THE INTERNET'S FILE API .
-
Infura: Known for its scalability and integration with Ethereum, Infura also supports IPFS, enabling developers to manage decentralized storage seamlessly alongside blockchain operations PINATA | THE INTERNET'S FILE API THIRDWEB .
-
NFT.Storage: Specifically designed for NFTs, this service is free and built on IPFS and Filecoin. It is highly reliable for long-term storage and is often recommended for projects prioritizing decentralization and cost-effectiveness PINATA | THE INTERNET'S FILE API, THIRDWEB
-
Fleek: Provides tools for hosting IPFS files with seamless integration into web applications. Offers a user-friendly experience for deploying and managing IPFS-based content, particularly for decentralized web and NFT projects FLEEK | BUILD AND DEPLOY PERFORMANT APPS PINATA | THE INTERNET'S FILE API.
-
Estuary: Combines IPFS and Filecoin for storage, aiming to provide developers with reliable, long-term data storage options. Focused on content availability and redundancy PINATA | THE INTERNET'S FILE API .
-
Textile: Offers decentralized storage solutions with enhanced developer tools for integrating IPFS in NFT and blockchain applications. Includes features like encryption and sharing, making it ideal for private or sensitive content IPFS BLOG & NEWS.
These services provide decentralized storage solutions essential for maintaining the integrity, accessibility, and security of NFT metadata while leveraging the benefits of IPFS.
If you found this helpful, let me know by leaving a 👍 or a comment!, or if you think this post could help someone, feel free to share it! Thank you very much! 😃
All rights reserved