Writing an ERC-1155 token from scratch using Solidity

Writing an ERC-1155 token from scratch using Solidity

ยท

16 min read

We hear about fungible (ERC-20) and non-fungible (ERC-721) tokens daily. But what if I told you that there exists a semi-fungible token? In a semi-fungible token contract, you can find tokens with copies (just like fungible tokens) and multiple tokens in the contract (just like non-fungible tokens). These semi-fungible tokens are based on the ERC-1155 standard.

Semi-fungible tokens have a lot of use cases, the most popular being token-gated communities. The ERC-1155 token shines here, unlike ERC-721, because many people need to hold the access NFT, and creating many NFTs under the ERC-721 contract does not make much sense. On the other hand, if you use ERC-1155 tokens for such use cases, you can just mint a new semi-fungible token and create copies of it for people to hold. Using ERC-20 is also an option, although using ERC-1155 opens up the opportunity of creating different types of tokens representing tiers in your community.

In this article, we will see how you can write an ERC-1155 token from scratch without using any libraries, and finally, we will test if it works!

Prefer video tutorials? I've got you covered! Check out my video on writing an ERC-1155 token from scratch! Also, would you be interested in learning how to write an ERC-721 token from scratch?

Let's start with the craziness ๐Ÿš€

Setting up the development environment

Creating a hardhat project

We will use hardhat in this tutorial to compile and manage our contract. Navigate to a safe directory, and run the following command to initialize the hardhat project initialization wizard:

npx hardhat

This should load up the initializer. Choose your configuration; in this case, I'll be choosing JavaScript (although we won't be writing scripts in this tutorial, although if you wish to do so, this choice might be important for you). Finally, choose to install the dependencies for your hardhat project.

Creating the contract file

Hardhat creates a file named Lock.sol within the contracts folder with some example code for you. You can delete the file and create a new file named ERC1155.sol. Then you can initialize a simple contract.

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

contract ERC1155 {

}

Writing the contract

Defining the states

Our contract will have states that will store important data about the tokens and owners. Following are the states we will be using in our contract.

// token id => (address => balance)
mapping(uint256 => mapping(address => uint256)) internal _balances;
// owner => (operator => yes/no)
mapping(address => mapping(address => bool)) internal _operatorApprovals;
// token id => token uri
mapping(uint256 => string) internal _tokenUris;
// token id => supply
mapping(uint256 => uint256) public totalSupply;

uint256 public nextTokenIdToMint;
string public name;
string public symbol;
address public owner;

Now, let's see what each state is responsible for storing.

  • _balances: this internal state uses nested mapping and stores user balances for each token ID.

  • _operatorApprovals: this internal state uses nested mapping and stores operator approvals for each wallet. These operators are allowed to transfer tokens on the owner's behalf.

  • _tokenUris: this internal state keeps track of the token URI for each token ID. The token URIs are typically IPFS URIs that store the token's metadata, for example- image, name, description, etc.

  • totalSupply: this state keeps track of the number of tokens minted per token ID.

  • nextTokenIdToMint: this state keeps track of the next token ID to mint in case a new token is supposed to be minted.

  • name: name of the collection.

  • symbol: symbol of the collection.

  • owner: owner of the contract. We will set it as the contract deployer once we cover the constructor part.

Defining the events

The ERC-1155 standard has three events. Apps can listen to these events to detect any changes in the contract state. Let's define these events in our contract.

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);

Let's see what the above events do.

  • TransferSingle: emitted when a single token is transferred by someone.

  • TransferBatch: emitted when someone performs a batch transfer.

  • ApprovalForAll: emitted when someone sets an operator.

Creating the constructor

The constructor will input data about the contract and then initialize all the data. The constructor function is run when the smart contract is first deployed.

constructor(string memory _name, string memory _symbol) {
    owner = msg.sender;
    name = _name;
    symbol = _symbol;
    nextTokenIdToMint = 0;
}

In the above code, we are taking input (name and symbol of the collection) from the deployer in the form of constructor. We are then setting the owner, name, nextTokenIdToMint and symbol states. The owner of contract is set to msg.sender which is the contract deployer.

Creating the balanceOf() function

Let's write a function that returns the balance of a user for a given token ID.

function balanceOf(address _owner, uint256 _tokenId) public view returns(uint256) {
    require(_owner != address(0), "Add0");
    return _balances[_tokenId][_owner];
}

In the above code, we are taking _owner and _tokenId as parameters and fetching the balance from the _balances mapping. Remember that both parameters are required to fetch the balance.

Creating the balanceOfBatch() function

One of the reason why the ERC-1155 standard is interesting is because of the use of batch operations. You can pass in a bunch of address and token IDs in the function parameters and receive the balance for the addresses for given token IDs in the form of an array.

function balanceOfBatch(address[] memory _accounts, uint256[] memory _tokenIds) public view returns(uint256[] memory) {
    require(_accounts.length == _tokenIds.length, "accounts id length mismatch");
    // create an array dynamically
    uint256[] memory balances = new uint256[](_accounts.length);

    for(uint256 i = 0; i < _accounts.length; i++) {
        balances[i] = balanceOf(_accounts[i], _tokenIds[i]);
    }

    return balances;
}

The way this function works is you pass two arrays to the function, _accounts and _tokenIds. These arrays must be of the same length! While looping through the number of elements in the array, balance will be computed for every nth element. For example if the index is 2 then balance will be calculated for address _accounts[2] for token ID _tokenIds[2].

In the above code we are doing the following:

  • Checking if the two received arrays are equal, else revert the transaction.

  • Creating an array balances dynamically with the same length as the arrays received.

  • Looping through the array and fetching balances for every address for respective token IDs and adding it to the balances array.

  • Returning the balances array.

Creating the setApprovalForAll() function

This function will be used to set operators that can transfer tokens on a user's behalf.

function setApprovalForAll(address _operator, bool _approved) public {
    _operatorApprovals[msg.sender][_operator] = _approved;
}

In the above code, we are taking _operator and _approved as parameters and manipulating the _operatorApprovals mapping. Depending on the value of _approved the operator will be set as active/inactive. Inactive operators cannot transfer any tokens on the user's behalf while active operators can.

Creating the isApprovedForAll() function

This function will return if an operator is active for a given user.

function isApprovedForAll(address _account, address _operator) public view returns(bool) {
    return _operatorApprovals[_account][_operator];
}

In the above code, we are simply accessing the _operatorApprovals mapping and returning the status of the operator.

About safe transfer functions

Unlike ERC-721 tokens, we have no choice of using unsafe transfers in ERC-1155 tokens. Safe transfers are made so that the tokens don't end up in a contract that isn't meant to receive these tokens. Contracts that don't intend to receive these tokens might end up locking these tokens forever with no means of getting the tokens back.

Although how does our contract knows that the recipient contract is supposed to receive ERC-1155 tokens? By checking if the recipient contract has either onERC1155Received() or onERC1155Received() functions. If the function returns the function selector as a bytes4 response, it can be concluded that the contract indeed intends to collect tokens. Now, let's create an interface so that calling those two functions on the recipient contract can be easier. Create a new file in the contracts directory named IERC1155Receiver.sol and have the following contents:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.9;

interface IERC1155Receiver {
    function onERC1155Received(
        address operator,
        address from,
        uint256 id,
        uint256 value,
        bytes calldata data
    ) external returns (bytes4);

    function onERC1155BatchReceived(
        address operator,
        address from,
        uint256[] calldata ids,
        uint256[] calldata values,
        bytes calldata data
    ) external returns (bytes4);
}

Interface only has the function declarations so that we can use these to later contact the function on the recipient contract. Now, let's import this file into our ERC1155.sol file:

import "./IERC1155Receiver.sol";

Now, we can implement the safety check functions. There are private functions so only functions within the contract can call these functions:

function _doSafeTransferAcceptanceCheck(
    address operator,
    address from,
    address to,
    uint256 id,
    uint256 amount,
    bytes memory data
) private {
    if (to.code.length > 0) {
        try IERC1155Receiver(to).onERC1155Received(operator, from, id, amount, data) returns (bytes4 response) {
            if (response != IERC1155Receiver.onERC1155Received.selector) {
                revert("ERC1155: ERC1155Receiver rejected tokens");
            }
        } catch Error(string memory reason) {
            revert(reason);
        } catch {
            revert("ERC1155: transfer to non-ERC1155Receiver implementer");
        }
    }
}

function _doSafeBatchTransferAcceptanceCheck(
    address operator,
    address from,
    address to,
    uint256[] memory ids,
    uint256[] memory amounts,
    bytes memory data
) private {
    if (to.code.length > 0) {
        try IERC1155Receiver(to).onERC1155BatchReceived(operator, from, ids, amounts, data) returns (
            bytes4 response
        ) {
            if (response != IERC1155Receiver.onERC1155BatchReceived.selector) {
                revert("ERC1155: ERC1155Receiver rejected tokens");
            }
        } catch Error(string memory reason) {
            revert(reason);
        } catch {
            revert("ERC1155: transfer to non-ERC1155Receiver implementer");
        }
    }
}

Both of the functions, _doSafeTransferAcceptanceCheck() and _doSafeBatchTransferAcceptanceCheck() do similar things so we covered them in one block. Here's what these functions do:

  • Check if the recipient is a contract. If the recipient is a contract to.code.length > 0 will always be true. If the recipient is not a contract, no checks will be performed.

  • We are using the interface and passing it the to address and trying to invoke the functions on the recipient contract.

  • If intended selector is returned, the transaction will be successful or will be reverted.

Creating the _transfer() function

This is an internal function that will transfer user tokens without any checks, and that's why this function is internal. We will be wrapping this function around on the safeTransferFrom() and safeBatchTransferFrom() functions. The reason we are creating this function is to avoid repeating code.

function _transfer(address _from, address _to, uint256[] memory _ids, uint256[] memory _amounts) internal {
    require(_to != address(0), "transfer to address 0");

    for (uint256 i = 0; i < _ids.length; i++) {
        uint256 id = _ids[i];
        uint256 amount = _amounts[i];

        uint256 fromBalance = _balances[id][_from];
        require(fromBalance >= amount, "insufficient balance for transfer");
        _balances[id][_from] -= amount;
        _balances[id][_to] += amount;
    }
}

The above function supports only batch transfers. The way we will be doing single transfers is converting the single transfers into batch and run through this function. Let's see what is the above function is doing:

  • Checking if the transfer is being done to address 0, if so, revert.

  • Running a loop for the number of token IDs and performing transfers for each entry in the array.

  • In the loop, we are manipulating balances in the _balances mapping.

Creating the safeTransferFrom() function

This functions will be used to transfer tokens and run safety checks:

function safeTransferFrom(address _from, address _to, uint256 _id, uint256 _amount, bytes memory _data) public {
    require(_from == msg.sender || isApprovedForAll(_from, msg.sender), "not authorized");
    // create an array
    uint256[] memory ids = new uint256[](1);
    uint256[] memory amounts = new uint256[](1);
    ids[0] = _id;
    amounts[0] = _amount;
    // transfer
    _transfer(_from, _to, ids, amounts);
    emit TransferSingle(msg.sender, _from, _to, _id, _amount);
    // safe transfer checks
    _doSafeTransferAcceptanceCheck(msg.sender, _from, _to, _id, _amount, _data);
}

In the above code, we are running checks on whether the _from is the transaction initiator or the transaction initiator is the operator for _from. If not, we revert. Then, we are creating singleton array with only one element and we fill in the _id and _amount in the 0th index. We then run the _transfer() function, emit the TransferSingle event, and then run the safety checks using the _doSafeTransferAcceptanceCheck() function.

You might be wondering- why is the safety checks run after transferring the tokens? Won't it violate the entire purpose and still transfer the tokens? No, that's not how it works. We are transferring tokens beforehand because then the recipient contract can do their checks and run operations when the functions on the recipient contract are invoked. Although, if there is no response from the contract, transaction gets reverted, meaning the state on the blockchain will not change and the transaction never took place.

Creating the safeBatchTransferFrom() function

This function is pretty similar to safeTransferFrom() function, except here we don't need to create dynamic arrays, we simply pass everything to _transfer() function and emit the TransferBatch event:

function safeBatchTransferFrom(address _from, address _to, uint256[] memory _ids, uint256[] memory _amounts, bytes memory _data) public {
    require(_from == msg.sender || isApprovedForAll(_from, msg.sender), "not authorized");
    require(_ids.length == _amounts.length, "length mismatch");

    _transfer(_from, _to, _ids, _amounts);
    emit TransferBatch(msg.sender, _from, _to, _ids, _amounts);

    _doSafeBatchTransferAcceptanceCheck(msg.sender, _from, _to, _ids, _amounts, _data);
}

Creating the mintTo() function

This function is not required by the ERC-1155 standard, although it's essential to add in our contract as this function will help us mint new tokens into circulation.

function mintTo(address _to, uint256 _tokenId, string memory _uri, uint256 _amount) public {
    require(owner == msg.sender, "not authorized");

    uint256 tokenIdToMint;

    if (_tokenId == type(uint256).max) {
        tokenIdToMint = nextTokenIdToMint;
        nextTokenIdToMint += 1;
        _tokenUris[tokenIdToMint] = _uri;
    } else {
        require(_tokenId < nextTokenIdToMint, "invalid id");
        tokenIdToMint = _tokenId;
    }

    _balances[tokenIdToMint][_to] += _amount;
    totalSupply[tokenIdToMint] += _amount;

    emit TransferSingle(msg.sender, address(0), _to, _tokenId, _amount);
}

The above code is hard to digest when looked at initially, let's look at what it does.

  • We are checking if the function caller is the owner of the contract, if not, we revert as we don't want anyone else to mint tokens on this contract.

  • We are checking if the token ID provided in the function parameter is the max of uint256 type. If it is, it's an indicator that we want to mint new tokens instead of minting additional supply into existing tokens. So, we take note of nextTokenIdToMint in a temporary variable, increment nextTokenIdToMint and set the token URI received in the parameter by manipulating the _tokenURIs mapping.

  • If additional supply is intended, we check if the token ID is already minted, if not, we revert. We set tokenIdToMint to the _tokenId received in the parameters.

  • We then manipulate balance of _to using the _balances mapping and we also change the total supply of the token ID by manipulating the totalSupply mapping.

  • We then emit a TransferSingle event where from is address 0, which means the tokens just got minted.

Creating the uri() function

This function will return the token URI for any given token ID.

function uri(uint256 _tokenId) public view returns(string memory) {
    return _tokenUris[_tokenId];
}

Completed code

If you cannot fix the pieces together, here's the complete code for the contract:

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

import "./IERC1155Receiver.sol";

contract ERC1155 {
    // token id => (address => balance)
    mapping(uint256 => mapping(address => uint256)) internal _balances;
    // owner => (operator => yes/no)
    mapping(address => mapping(address => bool)) internal _operatorApprovals;
    // token id => token uri
    mapping(uint256 => string) internal _tokenUris;
    // token id => supply
    mapping(uint256 => uint256) public totalSupply;

    uint256 public nextTokenIdToMint;
    string public name;
    string public symbol;
    address public owner;

    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);

    constructor(string memory _name, string memory _symbol) {
        owner = msg.sender;
        name = _name;
        symbol = _symbol;
        nextTokenIdToMint = 0;
    }

    function balanceOf(address _owner, uint256 _tokenId) public view returns(uint256) {
        require(_owner != address(0), "Add0");
        return _balances[_tokenId][_owner];
    }

    function balanceOfBatch(address[] memory _accounts, uint256[] memory _tokenIds) public view returns(uint256[] memory) {
        require(_accounts.length == _tokenIds.length, "accounts id length mismatch");
        // create an array dynamically
        uint256[] memory balances = new uint256[](_accounts.length);

        for(uint256 i = 0; i < _accounts.length; i++) {
            balances[i] = balanceOf(_accounts[i], _tokenIds[i]);
        }

        return balances;
    }

    function setApprovalForAll(address _operator, bool _approved) public {
        _operatorApprovals[msg.sender][_operator] = _approved;
    }

    function isApprovedForAll(address _account, address _operator) public view returns(bool) {
        return _operatorApprovals[_account][_operator];
    }

    function safeTransferFrom(address _from, address _to, uint256 _id, uint256 _amount, bytes memory _data) public {
        require(_from == msg.sender || isApprovedForAll(_from, msg.sender), "not authorized");
        // create an array
        uint256[] memory ids = new uint256[](1);
        uint256[] memory amounts = new uint256[](1);
        ids[0] = _id;
        amounts[0] = _amount;
        // transfer
        _transfer(_from, _to, ids, amounts);
        emit TransferSingle(msg.sender, _from, _to, _id, _amount);
        // safe transfer checks
        _doSafeTransferAcceptanceCheck(msg.sender, _from, _to, _id, _amount, _data);
    }

    function safeBatchTransferFrom(address _from, address _to, uint256[] memory _ids, uint256[] memory _amounts, bytes memory _data) public {
        require(_from == msg.sender || isApprovedForAll(_from, msg.sender), "not authorized");
        require(_ids.length == _amounts.length, "length mismatch");

        _transfer(_from, _to, _ids, _amounts);
        emit TransferBatch(msg.sender, _from, _to, _ids, _amounts);

        _doSafeBatchTransferAcceptanceCheck(msg.sender, _from, _to, _ids, _amounts, _data);
    }

    function uri(uint256 _tokenId) public view returns(string memory) {
        return _tokenUris[_tokenId];
    }

    function mintTo(address _to, uint256 _tokenId, string memory _uri, uint256 _amount) public {
        require(owner == msg.sender, "not authorized");

        uint256 tokenIdToMint;

        if (_tokenId == type(uint256).max) {
            tokenIdToMint = nextTokenIdToMint;
            nextTokenIdToMint += 1;
            _tokenUris[tokenIdToMint] = _uri;
        } else {
            require(_tokenId < nextTokenIdToMint, "invalid id");
            tokenIdToMint = _tokenId;
        }

        _balances[tokenIdToMint][_to] += _amount;
        totalSupply[tokenIdToMint] += _amount;

        emit TransferSingle(msg.sender, address(0), _to, _tokenId, _amount);
    }

    // INTERNAL FUNCTIONS

    function _transfer(address _from, address _to, uint256[] memory _ids, uint256[] memory _amounts) internal {
        require(_to != address(0), "transfer to address 0");

        for (uint256 i = 0; i < _ids.length; i++) {
            uint256 id = _ids[i];
            uint256 amount = _amounts[i];

            uint256 fromBalance = _balances[id][_from];
            require(fromBalance >= amount, "insufficient balance for transfer");
            _balances[id][_from] -= amount;
            _balances[id][_to] += amount;
        }
    }

    function _doSafeTransferAcceptanceCheck(
        address operator,
        address from,
        address to,
        uint256 id,
        uint256 amount,
        bytes memory data
    ) private {
        if (to.code.length > 0) {
            try IERC1155Receiver(to).onERC1155Received(operator, from, id, amount, data) returns (bytes4 response) {
                if (response != IERC1155Receiver.onERC1155Received.selector) {
                    revert("ERC1155: ERC1155Receiver rejected tokens");
                }
            } catch Error(string memory reason) {
                revert(reason);
            } catch {
                revert("ERC1155: transfer to non-ERC1155Receiver implementer");
            }
        }
    }

    function _doSafeBatchTransferAcceptanceCheck(
        address operator,
        address from,
        address to,
        uint256[] memory ids,
        uint256[] memory amounts,
        bytes memory data
    ) private {
        if (to.code.length > 0) {
            try IERC1155Receiver(to).onERC1155BatchReceived(operator, from, ids, amounts, data) returns (
                bytes4 response
            ) {
                if (response != IERC1155Receiver.onERC1155BatchReceived.selector) {
                    revert("ERC1155: ERC1155Receiver rejected tokens");
                }
            } catch Error(string memory reason) {
                revert(reason);
            } catch {
                revert("ERC1155: transfer to non-ERC1155Receiver implementer");
            }
        }
    }
}

Testing the contract

Typically to deploy and test the contract functions, you'll need to write scripts and add private keys to your environment variables to deploy and interact with the contract. However, thirdweb deploy provides an easy and interactive workflow for deploying your contracts on the blockchain without exposing your private keys - and it's FREE! You don't need any additional setup- run the following command in the terminal to begin deploying the contract.

npx thirdweb@latest deploy

This command will do all the hard work- compiling the contract and uploading the data to IPFS, and then you will be redirected to your browser to pass in the parameters to deploy the contract finally.

As you can see, thirdweb detects that our contract is an ERC-1155 contract with the extensions ERC1155Enumerable and ERC1155Mintable.

Enter your desired _name and _symbol. Choose your desired network and click on deploy now. This should initiate a transaction on your wallet provider (for ex, MetaMask, WalletConnect, etc.), and on approval, your transaction will be deployed on the blockchain. No private keys involved. After deploying, you can go to the NFTs tab on the top menu and click on Mint. Then you can see a drawer like this:

You can enter all the data, and thirdweb will automatically upload all your assets to IPFS, generate a JSON file, upload it to IPFS and invoke the mintTo() function for you! And all of this is FREE! For example, I just used this to mint my favourite Naruto character- Madara Uchiha.

You can now test the other functionality by calling the contract functions in the Explorer tab. Check out my video if you want to see me test it a bit more.

Conclusion

In this article, we saw how to write an ERC-1155 token from scratch. I'd say every Solidity developer must do this at least once, as this makes you understand the basics of Solidity a lot better than the regular to-do list contracts.

If you have any suggestions or questions, feel free to leave them in the comments below or contact me on other platforms.

ย