DEV Community

Cover image for ๐Ÿ–ผ๏ธ Blockchain NFT, ERC721 From Basics To Production ๐Ÿš€
Truong Phung
Truong Phung

Posted on

๐Ÿ–ผ๏ธ 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Features:

  1. No External Libraries: Completely standalone implementation.
  2. Core ERC721 Features:
    • Minting (_mint)
    • Burning (_burn)
    • Transfers (transferFrom)
    • Approvals and operator management.
  3. 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))
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Differences ๐Ÿค”

1. Encapsulation and Flexibility

  • Simplified Version: name and symbol are public, with automatically generated getter functions for external access.
  • OpenZeppelin: name and symbol are private, with explicit getter functions (name() and symbol()) defined for controlled external access. The virtual keyword allows derived contracts to override the name and symbol 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 implement onERC721Received.

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 via supportsInterface.

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 like concat, toString, for efficient conversions.
    • Reduces redundant storage reads.
    • Implements packed storage techniques in certain cases.

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 and UUPSUpgradeable).

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 or AccessControl 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Features โœจ

  1. ERC721 Standards:

    • Inherits from ERC721, which includes core NFT functionality.
    • Extends ERC721Enumerable for total supply and token enumeration.
  2. Metadata Handling:

    • Implements ERC721URIStorage for storing token-specific metadata.
  3. Access Control:

    • Uses Ownable to restrict minting and administrative actions to the contract owner.
  4. Pausing:

    • Includes Pausable to enable and disable transfers in case of emergencies.
  5. Burn Functionality:

    • Allows tokens to be burned with metadata cleared.
  6. 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 function foo.
    • Contract B inherits from A and overrides foo.
    • Contract C inherits from both A and B.
  • When C inherits from both A and B, it will use the foo implementation from B because B is closer in the inheritance hierarchy. The override(A, B) syntax in C ensures that the correct method is called according to the C3 Linearization.

  • So, in summary, contract C will inherit from the same contract A through both direct inheritance and through contract B.

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:

  1. 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 .
  2. 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 .
  3. 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 .
  4. 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

  5. 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.

  6. 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
    .

  7. 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! ๐Ÿ˜ƒ

Top comments (0)