DEV Community

Cover image for ๐ŸŽฎ Blockchain NFT, ERC1155 From Basics To Production ๐Ÿš€
Truong Phung
Truong Phung

Posted on

๐ŸŽฎ Blockchain NFT, ERC1155 From Basics To Production ๐Ÿš€

1. ERC721 VS ERC1155 ๐Ÿ†šโœจ

Feature ERC721 (NFT) ERC1155 (Multi-Token)
Uniqueness Each token is unique (1:1). Supports both unique and fungible tokens.
Token Type Designed for single token types. Handles multiple token types in one contract.
Efficiency Less efficient for batch operations. Optimized for batch transfers and minting.
Use Case Ideal for collectibles, artwork, single assets. Suited for games, items with variations, or mixed assets.
Metadata URI Metadata stored per token (via tokenURI). Shared or type-specific metadata (via uri).
Transfer Events Emits Transfer for each token. Emits TransferSingle or TransferBatch.
Gas Costs Higher due to individual operations. Lower for batch operations.

2. ERC1155 Interface and Implementation ๐Ÿ‘พ

Here is the ERC-1155 interface as defined in the Ethereum EIP-1155 and a simplified implementation without external dependencies.

ERC-1155 Interface

The ERC-1155 standard interface is defined in Solidity as follows:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC1155 {
    event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value);
    event TransferBatch(address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values);
    event ApprovalForAll(address indexed account, address indexed operator, bool approved);
    event URI(string value, uint256 indexed id);

    function balanceOf(address account, uint256 id) external view returns (uint256);
    function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids) external view returns (uint256[] memory);
    function setApprovalForAll(address operator, bool approved) external;
    function isApprovedForAll(address account, address operator) external view returns (bool);
    function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes calldata data) external;
    function safeBatchTransferFrom(address from, address to, uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data) external;
}
Enter fullscreen mode Exit fullscreen mode

Simplified Implementation

Below is a basic implementation of the ERC-1155 standard without external libraries like OpenZeppelin:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SimpleERC1155 {
    // Mapping from token ID to owner balances
    mapping(uint256 => mapping(address => uint256)) private _balances;

    // Mapping from owner to operator approvals
    mapping(address => mapping(address => bool)) private _operatorApprovals;

    // Events
    event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value);
    event TransferBatch(address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values);
    event ApprovalForAll(address indexed account, address indexed operator, bool approved);
    event URI(string value, uint256 indexed id);

    // Return the balance of `id` tokens for `account`
    function balanceOf(address account, uint256 id) public view returns (uint256) {
        require(account != address(0), "ERC1155: address zero is not a valid owner");
        return _balances[id][account];
    }

    // Return the balances for multiple accounts and IDs
    function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids) external view returns (uint256[] memory) {
        require(accounts.length == ids.length, "ERC1155: accounts and ids length mismatch");

        uint256[] memory batchBalances = new uint256[](accounts.length);
        for (uint256 i = 0; i < accounts.length; i++) {
            batchBalances[i] = balanceOf(accounts[i], ids[i]);
        }

        return batchBalances;
    }

    // Approve or revoke approval for an operator
    function setApprovalForAll(address operator, bool approved) external {
        require(msg.sender != operator, "ERC1155: setting approval status for self");
        _operatorApprovals[msg.sender][operator] = approved;
        emit ApprovalForAll(msg.sender, operator, approved);
    }

    // Check if an operator is approved for a given account
    function isApprovedForAll(address account, address operator) public view returns (bool) {
        return _operatorApprovals[account][operator];
    }

    // Transfer tokens safely
    function safeTransferFrom(
        address from,
        address to,
        uint256 id,
        uint256 amount,
        bytes calldata data
    ) external {
        require(to != address(0), "ERC1155: transfer to the zero address");
        require(
            from == msg.sender || isApprovedForAll(from, msg.sender),
            "ERC1155: caller is not owner nor approved"
        );

        _balances[id][from] -= amount;
        _balances[id][to] += amount;

        emit TransferSingle(msg.sender, from, to, id, amount);

        // Optionally validate receiver
        _doSafeTransferAcceptanceCheck(msg.sender, from, to, id, amount, data);
    }

    // Batch transfer tokens safely
    function safeBatchTransferFrom(
        address from,
        address to,
        uint256[] calldata ids,
        uint256[] calldata amounts,
        bytes calldata data
    ) external {
        require(ids.length == amounts.length, "ERC1155: ids and amounts length mismatch");
        require(to != address(0), "ERC1155: transfer to the zero address");
        require(
            from == msg.sender || isApprovedForAll(from, msg.sender),
            "ERC1155: caller is not owner nor approved"
        );

        for (uint256 i = 0; i < ids.length; i++) {
            _balances[ids[i]][from] -= amounts[i];
            _balances[ids[i]][to] += amounts[i];
        }

        emit TransferBatch(msg.sender, from, to, ids, amounts);

        // Optionally validate receiver
        _doSafeBatchTransferAcceptanceCheck(msg.sender, from, to, ids, amounts, data);
    }

    // Mint new tokens
    function mint(
        address to,
        uint256 id,
        uint256 amount,
        bytes memory data
    ) external {
        require(to != address(0), "ERC1155: mint to the zero address");

        _balances[id][to] += amount;

        emit TransferSingle(msg.sender, address(0), to, id, amount);

        _doSafeTransferAcceptanceCheck(msg.sender, address(0), to, id, amount, data);
    }

    // Burn existing tokens
    function burn(
        address from,
        uint256 id,
        uint256 amount
    ) external {
        require(from != address(0), "ERC1155: burn from the zero address");
        require(
            from == msg.sender || isApprovedForAll(from, msg.sender),
            "ERC1155: caller is not owner nor approved"
        );

        uint256 currentBalance = _balances[id][from];
        require(currentBalance >= amount, "ERC1155: burn amount exceeds balance");
        _balances[id][from] -= amount;

        emit TransferSingle(msg.sender, from, address(0), id, amount);
    }

    // Placeholder for safe transfer acceptance check
    function _doSafeTransferAcceptanceCheck(
        address operator,
        address from,
        address to,
        uint256 id,
        uint256 amount,
        bytes memory data
    ) private pure {
        // Custom logic for safe transfer check, e.g., interface checks
    }

    // Placeholder for safe batch transfer acceptance check
    function _doSafeBatchTransferAcceptanceCheck(
        address operator,
        address from,
        address to,
        uint256[] memory ids,
        uint256[] memory amounts,
        bytes memory data
    ) private pure {
        // Custom logic for safe batch transfer check
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Notes

  1. Simplified Implementation:

    • This implementation focuses on the basic functionality of ERC-1155. It does not include advanced features like metadata (URI handling) or reentrancy guards.
  2. Receiver Checks:

    • The _doSafeTransferAcceptanceCheck and _doSafeBatchTransferAcceptanceCheck methods are placeholders for checks like IERC1155Receiver.
  3. Minting:

    • The mint function increases the balance of the to address for a given token id and amount. Emits a TransferSingle event with the from address as address(0) (indicating token creation).
  4. Burning:

    • The burn function reduces the balance of the from address for a given token id and amount.
    • Ensures the caller is either the token holder or an approved operator.
    • Emits a TransferSingle event with the to address as address(0) (indicating token destruction).
  5. Batch Minting and Burning (Optional):
    You can extend this to include batch minting and burning by adding mintBatch and burnBatch functions following the same principles as safeBatchTransferFrom.

  6. Gas Optimization:

    • The implementation may not be fully gas-optimized compared to professional libraries like OpenZeppelin.

This implementation is minimal and suitable for learning or quick prototyping. For production, consider adding access control (e.g., onlyOwner for minting) and using consider using well-tested libraries like OpenZeppelin's ERC-1155 implementation.

3. Production-Grade ERC1155 โœ…

Hereโ€™s an overview of OpenZeppelinโ€™s production-grade ERC1155 (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/ERC1155/ERC1155.sol)

pragma solidity ^0.8.20;

import {IERC1155} from "./IERC1155.sol";
import {IERC1155Receiver} from "./IERC1155Receiver.sol";
import {IERC1155MetadataURI} from "./extensions/IERC1155MetadataURI.sol";
import {Context} from "../../utils/Context.sol";
import {IERC165, ERC165} from "../../utils/introspection/ERC165.sol";
import {Arrays} from "../../utils/Arrays.sol";
import {IERC1155Errors} from "../../interfaces/draft-IERC6093.sol";

/**
 * @dev Implementation of the basic standard multi-token.
 * See https://eips.ethereum.org/EIPS/eip-1155
 * Originally based on code by Enjin: https://github.com/enjin/erc-1155
 */
abstract contract ERC1155 is Context, ERC165, IERC1155, IERC1155MetadataURI, IERC1155Errors {
    using Arrays for uint256[];
    using Arrays for address[];

    mapping(uint256 id => mapping(address account => uint256)) private _balances;

    mapping(address account => mapping(address operator => bool)) private _operatorApprovals;

    // Used as the URI for all token types by relying on ID substitution, e.g. https://token-cdn-domain/{id}.json
    string private _uri;

    /**
     * @dev See {_setURI}.
     */
    constructor(string memory uri_) {
        _setURI(uri_);
    }

    /**
     * @dev See {IERC165-supportsInterface}.
     */
    function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) {
        return
            interfaceId == type(IERC1155).interfaceId ||
            interfaceId == type(IERC1155MetadataURI).interfaceId ||
            super.supportsInterface(interfaceId);
    }

    /**
     * @dev See {IERC1155MetadataURI-uri}.
     *
     * This implementation returns the same URI for *all* token types. It relies
     * on the token type ID substitution mechanism
     * https://eips.ethereum.org/EIPS/eip-1155#metadata[defined in the EIP].
     *
     * Clients calling this function must replace the `\{id\}` substring with the
     * actual token type ID.
     */
    function uri(uint256 /* id */) public view virtual returns (string memory) {
        return _uri;
    }

    /**
     * @dev See {IERC1155-balanceOf}.
     */
    function balanceOf(address account, uint256 id) public view virtual returns (uint256) {
        return _balances[id][account];
    }

    /**
     * @dev See {IERC1155-balanceOfBatch}.
     *
     * Requirements:
     *
     * - `accounts` and `ids` must have the same length.
     */
    function balanceOfBatch(
        address[] memory accounts,
        uint256[] memory ids
    ) public view virtual returns (uint256[] memory) {
        if (accounts.length != ids.length) {
            revert ERC1155InvalidArrayLength(ids.length, accounts.length);
        }

        uint256[] memory batchBalances = new uint256[](accounts.length);

        for (uint256 i = 0; i < accounts.length; ++i) {
            batchBalances[i] = balanceOf(accounts.unsafeMemoryAccess(i), ids.unsafeMemoryAccess(i));
        }

        return batchBalances;
    }

    /**
     * @dev See {IERC1155-setApprovalForAll}.
     */
    function setApprovalForAll(address operator, bool approved) public virtual {
        _setApprovalForAll(_msgSender(), operator, approved);
    }

    /**
     * @dev See {IERC1155-isApprovedForAll}.
     */
    function isApprovedForAll(address account, address operator) public view virtual returns (bool) {
        return _operatorApprovals[account][operator];
    }

    /**
     * @dev See {IERC1155-safeTransferFrom}.
     */
    function safeTransferFrom(address from, address to, uint256 id, uint256 value, bytes memory data) public virtual {
        address sender = _msgSender();
        if (from != sender && !isApprovedForAll(from, sender)) {
            revert ERC1155MissingApprovalForAll(sender, from);
        }
        _safeTransferFrom(from, to, id, value, data);
    }

    /**
     * @dev See {IERC1155-safeBatchTransferFrom}.
     */
    function safeBatchTransferFrom(
        address from,
        address to,
        uint256[] memory ids,
        uint256[] memory values,
        bytes memory data
    ) public virtual {
        address sender = _msgSender();
        if (from != sender && !isApprovedForAll(from, sender)) {
            revert ERC1155MissingApprovalForAll(sender, from);
        }
        _safeBatchTransferFrom(from, to, ids, values, data);
    }

    /**
     * @dev Transfers a `value` amount of tokens of type `id` from `from` to `to`. Will mint (or burn) if `from`
     * (or `to`) is the zero address.
     *
     * Emits a {TransferSingle} event if the arrays contain one element, and {TransferBatch} otherwise.
     *
     * Requirements:
     *
     * - If `to` refers to a smart contract, it must implement either {IERC1155Receiver-onERC1155Received}
     *   or {IERC1155Receiver-onERC1155BatchReceived} and return the acceptance magic value.
     * - `ids` and `values` must have the same length.
     *
     * NOTE: The ERC-1155 acceptance check is not performed in this function. See {_updateWithAcceptanceCheck} instead.
     */
    function _update(address from, address to, uint256[] memory ids, uint256[] memory values) internal virtual {
        if (ids.length != values.length) {
            revert ERC1155InvalidArrayLength(ids.length, values.length);
        }

        address operator = _msgSender();

        for (uint256 i = 0; i < ids.length; ++i) {
            uint256 id = ids.unsafeMemoryAccess(i);
            uint256 value = values.unsafeMemoryAccess(i);

            if (from != address(0)) {
                uint256 fromBalance = _balances[id][from];
                if (fromBalance < value) {
                    revert ERC1155InsufficientBalance(from, fromBalance, value, id);
                }
                unchecked {
                    // Overflow not possible: value <= fromBalance
                    _balances[id][from] = fromBalance - value;
                }
            }

            if (to != address(0)) {
                _balances[id][to] += value;
            }
        }

        if (ids.length == 1) {
            uint256 id = ids.unsafeMemoryAccess(0);
            uint256 value = values.unsafeMemoryAccess(0);
            emit TransferSingle(operator, from, to, id, value);
        } else {
            emit TransferBatch(operator, from, to, ids, values);
        }
    }

    /**
     * @dev Version of {_update} that performs the token acceptance check by calling
     * {IERC1155Receiver-onERC1155Received} or {IERC1155Receiver-onERC1155BatchReceived} on the receiver address if it
     * contains code (eg. is a smart contract at the moment of execution).
     *
     * IMPORTANT: Overriding this function is discouraged because it poses a reentrancy risk from the receiver. So any
     * update to the contract state after this function would break the check-effect-interaction pattern. Consider
     * overriding {_update} instead.
     */
    function _updateWithAcceptanceCheck(
        address from,
        address to,
        uint256[] memory ids,
        uint256[] memory values,
        bytes memory data
    ) internal virtual {
        _update(from, to, ids, values);
        if (to != address(0)) {
            address operator = _msgSender();
            if (ids.length == 1) {
                uint256 id = ids.unsafeMemoryAccess(0);
                uint256 value = values.unsafeMemoryAccess(0);
                _doSafeTransferAcceptanceCheck(operator, from, to, id, value, data);
            } else {
                _doSafeBatchTransferAcceptanceCheck(operator, from, to, ids, values, data);
            }
        }
    }

    /**
     * @dev Transfers a `value` tokens of token type `id` from `from` to `to`.
     *
     * Emits a {TransferSingle} event.
     *
     * Requirements:
     *
     * - `to` cannot be the zero address.
     * - `from` must have a balance of tokens of type `id` of at least `value` amount.
     * - If `to` refers to a smart contract, it must implement {IERC1155Receiver-onERC1155Received} and return the
     * acceptance magic value.
     */
    function _safeTransferFrom(address from, address to, uint256 id, uint256 value, bytes memory data) internal {
        if (to == address(0)) {
            revert ERC1155InvalidReceiver(address(0));
        }
        if (from == address(0)) {
            revert ERC1155InvalidSender(address(0));
        }
        (uint256[] memory ids, uint256[] memory values) = _asSingletonArrays(id, value);
        _updateWithAcceptanceCheck(from, to, ids, values, data);
    }

    /**
     * @dev xref:ROOT:erc1155.adoc#batch-operations[Batched] version of {_safeTransferFrom}.
     *
     * Emits a {TransferBatch} event.
     *
     * Requirements:
     *
     * - If `to` refers to a smart contract, it must implement {IERC1155Receiver-onERC1155BatchReceived} and return the
     * acceptance magic value.
     * - `ids` and `values` must have the same length.
     */
    function _safeBatchTransferFrom(
        address from,
        address to,
        uint256[] memory ids,
        uint256[] memory values,
        bytes memory data
    ) internal {
        if (to == address(0)) {
            revert ERC1155InvalidReceiver(address(0));
        }
        if (from == address(0)) {
            revert ERC1155InvalidSender(address(0));
        }
        _updateWithAcceptanceCheck(from, to, ids, values, data);
    }

    /**
     * @dev Sets a new URI for all token types, by relying on the token type ID
     * substitution mechanism
     * https://eips.ethereum.org/EIPS/eip-1155#metadata[defined in the EIP].
     *
     * By this mechanism, any occurrence of the `\{id\}` substring in either the
     * URI or any of the values in the JSON file at said URI will be replaced by
     * clients with the token type ID.
     *
     * For example, the `https://token-cdn-domain/\{id\}.json` URI would be
     * interpreted by clients as
     * `https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0.json`
     * for token type ID 0x4cce0.
     *
     * See {uri}.
     *
     * Because these URIs cannot be meaningfully represented by the {URI} event,
     * this function emits no events.
     */
    function _setURI(string memory newuri) internal virtual {
        _uri = newuri;
    }

    /**
     * @dev Creates a `value` amount of tokens of type `id`, and assigns them to `to`.
     *
     * Emits a {TransferSingle} event.
     *
     * Requirements:
     *
     * - `to` cannot be the zero address.
     * - If `to` refers to a smart contract, it must implement {IERC1155Receiver-onERC1155Received} and return the
     * acceptance magic value.
     */
    function _mint(address to, uint256 id, uint256 value, bytes memory data) internal {
        if (to == address(0)) {
            revert ERC1155InvalidReceiver(address(0));
        }
        (uint256[] memory ids, uint256[] memory values) = _asSingletonArrays(id, value);
        _updateWithAcceptanceCheck(address(0), to, ids, values, data);
    }

    /**
     * @dev xref:ROOT:erc1155.adoc#batch-operations[Batched] version of {_mint}.
     *
     * Emits a {TransferBatch} event.
     *
     * Requirements:
     *
     * - `ids` and `values` must have the same length.
     * - `to` cannot be the zero address.
     * - If `to` refers to a smart contract, it must implement {IERC1155Receiver-onERC1155BatchReceived} and return the
     * acceptance magic value.
     */
    function _mintBatch(address to, uint256[] memory ids, uint256[] memory values, bytes memory data) internal {
        if (to == address(0)) {
            revert ERC1155InvalidReceiver(address(0));
        }
        _updateWithAcceptanceCheck(address(0), to, ids, values, data);
    }

    /**
     * @dev Destroys a `value` amount of tokens of type `id` from `from`
     *
     * Emits a {TransferSingle} event.
     *
     * Requirements:
     *
     * - `from` cannot be the zero address.
     * - `from` must have at least `value` amount of tokens of type `id`.
     */
    function _burn(address from, uint256 id, uint256 value) internal {
        if (from == address(0)) {
            revert ERC1155InvalidSender(address(0));
        }
        (uint256[] memory ids, uint256[] memory values) = _asSingletonArrays(id, value);
        _updateWithAcceptanceCheck(from, address(0), ids, values, "");
    }

    /**
     * @dev xref:ROOT:erc1155.adoc#batch-operations[Batched] version of {_burn}.
     *
     * Emits a {TransferBatch} event.
     *
     * Requirements:
     *
     * - `from` cannot be the zero address.
     * - `from` must have at least `value` amount of tokens of type `id`.
     * - `ids` and `values` must have the same length.
     */
    function _burnBatch(address from, uint256[] memory ids, uint256[] memory values) internal {
        if (from == address(0)) {
            revert ERC1155InvalidSender(address(0));
        }
        _updateWithAcceptanceCheck(from, address(0), ids, values, "");
    }

    /**
     * @dev Approve `operator` to operate on all of `owner` tokens
     *
     * Emits an {ApprovalForAll} event.
     *
     * Requirements:
     *
     * - `operator` cannot be the zero address.
     */
    function _setApprovalForAll(address owner, address operator, bool approved) internal virtual {
        if (operator == address(0)) {
            revert ERC1155InvalidOperator(address(0));
        }
        _operatorApprovals[owner][operator] = approved;
        emit ApprovalForAll(owner, operator, approved);
    }

    /**
     * @dev Performs an acceptance check by calling {IERC1155-onERC1155Received} on the `to` address
     * if it contains code at the moment of execution.
     */
    function _doSafeTransferAcceptanceCheck(
        address operator,
        address from,
        address to,
        uint256 id,
        uint256 value,
        bytes memory data
    ) private {
        if (to.code.length > 0) {
            try IERC1155Receiver(to).onERC1155Received(operator, from, id, value, data) returns (bytes4 response) {
                if (response != IERC1155Receiver.onERC1155Received.selector) {
                    // Tokens rejected
                    revert ERC1155InvalidReceiver(to);
                }
            } catch (bytes memory reason) {
                if (reason.length == 0) {
                    // non-ERC1155Receiver implementer
                    revert ERC1155InvalidReceiver(to);
                } else {
                    /// @solidity memory-safe-assembly
                    assembly {
                        revert(add(32, reason), mload(reason))
                    }
                }
            }
        }
    }

    /**
     * @dev Performs a batch acceptance check by calling {IERC1155-onERC1155BatchReceived} on the `to` address
     * if it contains code at the moment of execution.
     */
    function _doSafeBatchTransferAcceptanceCheck(
        address operator,
        address from,
        address to,
        uint256[] memory ids,
        uint256[] memory values,
        bytes memory data
    ) private {
        if (to.code.length > 0) {
            try IERC1155Receiver(to).onERC1155BatchReceived(operator, from, ids, values, data) returns (
                bytes4 response
            ) {
                if (response != IERC1155Receiver.onERC1155BatchReceived.selector) {
                    // Tokens rejected
                    revert ERC1155InvalidReceiver(to);
                }
            } catch (bytes memory reason) {
                if (reason.length == 0) {
                    // non-ERC1155Receiver implementer
                    revert ERC1155InvalidReceiver(to);
                } else {
                    /// @solidity memory-safe-assembly
                    assembly {
                        revert(add(32, reason), mload(reason))
                    }
                }
            }
        }
    }

    /**
     * @dev Creates an array in memory with only one value for each of the elements provided.
     */
    function _asSingletonArrays(
        uint256 element1,
        uint256 element2
    ) private pure returns (uint256[] memory array1, uint256[] memory array2) {
        /// @solidity memory-safe-assembly
        assembly {
            // Load the free memory pointer
            array1 := mload(0x40)
            // Set array length to 1
            mstore(array1, 1)
            // Store the single element at the next word after the length (where content starts)
            mstore(add(array1, 0x20), element1)

            // Repeat for next array locating it right after the first array
            array2 := add(array1, 0x40)
            mstore(array2, 1)
            mstore(add(array2, 0x20), element2)

            // Update the free memory pointer by pointing after the second array
            mstore(0x40, add(array2, 0x40))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Differences ๐Ÿค”

Here is a comparison of the Simplified ERC1155 and OpenZeppelin's ERC1155 Implementation with explanations of why OpenZeppelin's design choices make sense.

1. Contract Modularity and Standards Compliance

  • Simplified Contract:
    • Contains all logic within a single file.
    • Lacks modularity and inheritance from interfaces or base contracts like IERC1155, IERC1155Receiver, or IERC1155MetadataURI.
    • Does not include interface identification (supportsInterface) for standards compliance.
  • OpenZeppelin Contract:
    • Uses an abstract contract design, importing interfaces and base contracts like IERC1155, IERC165, and others.
    • Implements supportsInterface to ensure compatibility with other contracts and tools.
    • Adheres to the modular ERC-1155 standard for better reusability and extensibility.

Why it makes sense: OpenZeppelin's modular design allows developers to use components as building blocks. Implementing supportsInterface ensures that the contract can be verified to comply with the ERC-1155 standard, enabling interoperability with wallets, marketplaces, and other tools.

2. Error Handling

  • Simplified Contract:
    • Relies on basic require statements for error checks.
    • Errors are generic, e.g., "ERC1155: transfer to the zero address".
  • OpenZeppelin Contract:
    • Uses custom revert reasons via structured error handling (e.g., ERC1155InvalidArrayLength, ERC1155MissingApprovalForAll).
    • Leverages low-level assembly for better gas efficiency in some cases.

Why it makes sense: Custom errors are more descriptive and save gas compared to string-based revert messages. OpenZeppelin's approach improves debugging and aligns with modern Solidity practices (introduced in 0.8.4+).

3. Batch Operations

  • Simplified Contract:
    • Implements batch transfer logic directly in safeBatchTransferFrom.
    • Lacks reusable internal functions for shared logic between batch and single transfers.
  • OpenZeppelin Contract:
    • Uses reusable internal _update and _updateWithAcceptanceCheck functions for batch and single transfers.
    • Handles batch processing more efficiently and with shared logic to minimize code duplication.

Why it makes sense: By centralizing logic in reusable functions, OpenZeppelin reduces the potential for bugs and simplifies maintenance. This design also makes batch operations more gas-efficient and easier to extend.

4. Safe Transfer Acceptance Checks

  • Simplified Contract:
    • Implements _doSafeTransferAcceptanceCheck and _doSafeBatchTransferAcceptanceCheck as placeholders without real logic.
  • OpenZeppelin Contract:
    • Implements these checks robustly, ensuring that tokens are only sent to contracts implementing IERC1155Receiver.
    • Uses try/catch to handle potential errors in the receiving contract's onERC1155Received or onERC1155BatchReceived functions.

Why it makes sense: This ensures compatibility with receiving contracts and avoids locking tokens in incompatible contracts. OpenZeppelin's safe transfer checks are critical for security and usability.

5. URI Management

  • Simplified Contract:
    • No support for URIs to associate metadata with tokens.
  • OpenZeppelin Contract:
    • Includes URI management via _setURI and uri functions.
    • Supports metadata standards with token-specific URI substitution.

Why it makes sense: Metadata is essential for NFT use cases. OpenZeppelin's support for token-specific metadata makes the contract usable in real-world applications like games and marketplaces.

6. Gas Optimization

  • Simplified Contract:
    • Relies on Solidity's default array and mapping operations, which may not be gas-optimized.
    • Contains duplicated logic in batch operations.
  • OpenZeppelin Contract:
    • Uses unsafeMemoryAccess for batch operations and _asSingletonArrays for single operations to save gas.
    • Avoids unnecessary storage reads/writes by centralizing balance updates in _update.

Why it makes sense: OpenZeppelin's optimizations reduce transaction costs, which is critical for contracts deployed on mainnet with high gas fees.

7. Mint and Burn Logic

  • Simplified Contract:
    • Implements mint and burn as external functions.
    • No batch minting or burning capabilities.
  • OpenZeppelin Contract:
    • Implements _mint, _mintBatch, _burn, and _burnBatch as internal functions.
    • Provides flexible minting and burning capabilities for single and batch operations.

Why it makes sense: Batch minting and burning are essential for scalability in multi-token standards. OpenZeppelin's approach allows developers to create more complex token issuance logic, e.g., creating collections of NFTs.

8. Reentrancy and Receiver Code Validation

  • Simplified Contract:

    • Does not validate contract recipients beyond a basic safe transfer placeholder.
  • OpenZeppelin Contract:

    • Validates recipient contracts via IERC1155Receiver.
    • Prevents reentrancy issues by requiring proper selector returns in onERC1155Received and onERC1155BatchReceived.

Why it makes sense: Reentrancy protection and recipient validation are critical for preventing exploits, such as sending tokens to contracts that cannot process them.

Conclusion

OpenZeppelin's ERC1155 is robust, modular, and aligned with best practices and ready for production-grade contracts.

4. Making Your Own NFTs ๐Ÿš€

Hereโ€™s an example of a typical ERC1155 NFT contract using OpenZeppelin version 5.0.0. The example demonstrates creating a multi-token contract for items in a game, where each token ID represents a unique item type, such as weapons or potions.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";

contract GameItems is ERC1155, Ownable , Pausable {

    mapping(uint256 => string) private customUris;

    event TokenCreated(uint256 tokenId, string uri);
    event TokensBatchCreated(uint256[] tokenIds, string[] uris);

    // Marketplaces rely on token metadata for displaying NFT details (name, image, attributes, etc.). 
    // For ERC1155 tokens, they fetch metadata dynamically using the uri function provided by the contract.
    // Marketplaces support contracts that use a URI structure like https://example.com/metadata/{id}.json, where {id} is replaced with the token ID. 
    // This ensures that adding new tokens dynamically does not require changing the contract.
    // These metadata is typically hosted off-chain for saving gas cost and dynamic udpates.
    constructor() ERC1155("https://game-items.com/metadata/{id}.json") Ownable(msg.sender) {}


    // Pause/unpause functionality
    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    // Pause transfering when necessary
    function _update(address from, address to, uint256[] memory ids, uint256[] memory values) internal override whenNotPaused {
        super._update(from,to,ids,values);
    }

    /**
     * @notice Mint new tokens to a specific address.
     * @param to Address to receive the minted tokens.
     * @param tokenId ID of the token type to mint.
     * @param amount Number of tokens to mint.
     * @param newTokenURI token-specific URIs (optional), use base-pattern if not set.
     * @param data Optional data to send with the transfer.
     */
    function mint(
        address to,
        uint256 tokenId,
        uint256 amount,
        string memory newTokenURI,
        bytes memory data
    ) public onlyOwner {
        _mint(to, tokenId, amount, data);
        setCustomUri(tokenId, newTokenURI); // Optional if using token-specific URIs
        emit TokenCreated(tokenId, uri(tokenId));
    }

    function setCustomUri(uint256 tokenId, string memory newUri) public onlyOwner {
        customUris[tokenId] = newUri;
    }

    // For ERC1155-specific URIs, override the uri function to return dynamic URIs:
    function uri(uint256 tokenId) public view override returns (string memory) {
        return bytes(customUris[tokenId]).length > 0
            ? customUris[tokenId]
            : super.uri(tokenId); // Fallback to the default URI if none set
    }

    /**
     * @notice Mint multiple token types in a batch to a specific address.
     * @param to Address to receive the minted tokens.
     * @param ids Array of token IDs to mint.
     * @param amounts Array of amounts of each token to mint.
     * @param data Optional data to send with the transfer.
     */
    function mintBatch(
        address to,
        uint256[] memory ids,
        uint256[] memory amounts,
        string[] memory uris,
        bytes memory data
    ) public onlyOwner {
        require(ids.length == uris.length, "GameItems: ids and uris length mismatch");
        _mintBatch(to, ids, amounts, data);
        for (uint256 i = 0; i < ids.length; i++) {
            // check if uri not an empty string
            if (bytes(uris[i]).length > 0) {
                setCustomUri(ids[i], uris[i]);
            }
        }
        emit TokensBatchCreated(ids, uris);
    }

    /**
     * @notice Burn tokens from a specific address.
     * @param from Address to burn the tokens from.
     * @param id ID of the token type to burn.
     * @param amount Number of tokens to burn.
     */
    function burn(
        address from,
        uint256 id,
        uint256 amount
    ) public {
        require(
            msg.sender == from || isApprovedForAll(from, msg.sender),
            "Not authorized to burn"
        );
        _burn(from, id, amount);
    }

    /**
     * @notice Burn multiple token types in a batch from a specific address.
     * @param from Address to burn the tokens from.
     * @param ids Array of token IDs to burn.
     * @param amounts Array of amounts of each token to burn.
     */
    function burnBatch(
        address from,
        uint256[] memory ids,
        uint256[] memory amounts
    ) public {
        require(
            msg.sender == from || isApprovedForAll(from, msg.sender),
            "Not authorized to burn"
        );
        _burnBatch(from, ids, amounts);
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Features and Functionality

  1. Token IDs:

    • Each item type (e.g., SWORD, SHIELD, HEALTH_POTION) is represented by a unique token ID.
    • Token IDs (storing off-chain) can be extended to include more items in the future.
  2. Metadata URI:

    • The URI follows the {id} substitution pattern to dynamically provide metadata for each token ID.
    • Example: For SWORD (ID = 1), the URI would resolve to https://game-items.com/metadata/1.json.
  3. Minting:

    • The mint and mintBatch functions allow the contract owner to create tokens and distribute them to players.
  4. Burning:

    • Players or approved operators can burn tokens, reducing the supply.
  5. Batch Operations:

    • Batch minting and burning enable efficient handling of multiple token types in a single transaction.
  6. Access Control:

    • The onlyOwner modifier ensures that minting operations are restricted to the contract owner.
  7. Token-Specific URIs

    • Allows storing token-specific URIs on-chain for dynamic metadata. For cases when we want unique, non-pattern-based URIs per token ID or even per token instance (not typical in ERC1155).
  8. Pausable Functionality

    • Allows pausing all transfers, mints, or burns during emergency scenarios.

Advantages of Using OpenZeppelin

  • Security: OpenZeppelin's implementation adheres to best practices and includes protections against common vulnerabilities.
  • Extensibility: Built-in hooks and modular design make it easy to extend functionality (e.g., royalties, supply caps).
  • Standards Compliance: The contract is fully compliant with the ERC1155 standard, ensuring compatibility with wallets, marketplaces, and other tools.

This contract can be easily adapted for other use cases like in-game assets, digital art collections, or multi-token systems in DeFi.

5. NFT Token Id ๐Ÿ†”

In popular NFT marketplaces like OpenSea, Blur, and Rarible, the tokenId is a crucial piece of data that uniquely identifies an NFT within a smart contract. Here's a breakdown of the types of data commonly used for tokenId:

1. Sequential Numbers

  • Many NFT collections use sequential numeric IDs (e.g., 1, 2, 3, etc.) to assign tokenId values.
  • Use Case: Simple and efficient for collections with predefined or limited editions (e.g., CryptoPunks, Bored Ape Yacht Club).

2. Random or Pseudorandom Numbers

  • Some collections generate tokenId values randomly or pseudorandomly at minting to ensure uniqueness and fairness.
  • Use Case: Loot-based or gaming NFTs (e.g., Loot Project), where randomness plays a role in rarity.

3. Hashes

  • A cryptographic hash (e.g., SHA-256 or Keccak256) of metadata or other identifying attributes is sometimes used as the tokenId.
  • Use Case: Projects emphasizing immutability and uniqueness, such as generative art or on-chain data storage NFTs.

4. Encoded Attributes

  • The tokenId may encode specific traits or properties (e.g., rarity, category, or type).
  • Use Case: Projects with complex trait-based NFTs (e.g., a gaming item where tokenId encodes weapon type and level).

5. Metadata-Based Assignment

  • The tokenId can directly map to off-chain or on-chain metadata stored in IPFS, Arweave, or similar systems.
  • Use Case: Ensures each NFT corresponds to a unique set of metadata.

6. Hybrid Approaches

  • Some platforms or projects combine methods, such as assigning a random number initially and sequentially assigning the minted tokens.
  • Use Case: Complex platforms blending random rarity with predictable sequencing for usability.

Summary

The tokenId is a versatile identifier whose structure depends on the use case and the NFT project. Sequential IDs are common for simplicity, but gaming and generative art NFTs often employ randomness or encoded data for more dynamic interactions.

6. Detect Existing Tokens ๐Ÿ”

Popular NFT marketplaces detect existing NFT tokens owned by new users through blockchain data querying and indexing services. Hereโ€™s how they achieve this, especially when tokens are minted on other platforms:

1. Wallet-Based Detection

When a user connects their wallet:

  • The marketplace retrieves the user's wallet address.
  • It queries the blockchain for NFTs associated with that address using standards like ERC-721 or ERC-1155.
    • Example: Searching for all tokens in contracts where the ownerOf(tokenId) function returns the userโ€™s wallet address.

2. Token and Contract Indexing

To streamline the detection process:

  • Marketplaces use or build indexing services like:
    • The Graph: A decentralized protocol for querying blockchain data efficiently.
    • Custom in-house indexing systems that constantly track NFT minting, transfers, and ownership changes.
  • These services monitor all contracts on supported blockchains (e.g., Ethereum, Polygon) for events like Transfer, which are emitted during minting or transfers.

3. Standardized Metadata

  • NFT standards (e.g., ERC-721, ERC-1155) require each token to reference metadata (e.g., token name, image, attributes) via the tokenURI function.
  • The marketplace fetches this metadata to display token details, even if minted elsewhere.

4. Multi-Chain Support

For cross-chain NFTs, marketplaces:

  • Integrate with Layer 2s (e.g., Polygon, Arbitrum) or sidechains.
  • Use blockchain bridges or cross-chain indexing services to track NFTs across chains.

5. API and RPC Providers

Marketplaces leverage APIs from providers like:

  • Alchemy or Infura for querying blockchain data.
  • Moralis or QuickNode for faster NFT-related wallet data retrieval.

6. Event Monitoring

Marketplaces track blockchain events like:

  • Transfer Events: Detect when an NFT moves into the user's wallet.
  • Approval Events: Detect tokens approved for specific platforms/contracts.

7. Pre-Indexed Collections

Popular collections are pre-indexed:

  • Marketplaces maintain a database of verified NFT collections.
  • When a user holds tokens from these collections, they are immediately recognized and displayed.

Workflow Example

  1. User Connects Wallet:
  2. The marketplace fetches the wallet address.
  3. Blockchain Query:
  4. Queries all supported contracts for tokens owned by the wallet using events or contract calls.
  5. Token Metadata Fetch:
  6. Retrieves metadata for identified tokens from IPFS, Arweave, or centralized APIs.
  7. Display:
  8. Displays the userโ€™s tokens in their profile.

Key Challenges

  • Scalability: Indexing millions of NFTs across multiple chains requires efficient infrastructure.
  • Metadata Consistency: Variations in tokenURI implementations can lead to data discrepancies.
  • Cross-Chain Tokens: Handling NFTs minted on multiple chains requires robust interoperability mechanisms.

By combining blockchain data querying, indexing, and metadata retrieval, marketplaces can accurately detect and display NFTs minted elsewhere.

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)