Menu

french flag

Tokens ERC20 et ERC721

Tokens ERC20 et ERC721

A large proportion of decentralised applications use tokens to work properly. While coins are inherent to each blockchain (Ether for Ethereum, for example, Sol for Solana, etc.), tokens are tokens that are created on an existing blockchain using smart contracts. So, using a smart contract, it is possible to create a token called a “HackndoToken” whose symbol would be “HND”, for example. This token could exist in a limited number, and we could even ensure that each HND token is unique.

These tokens can be transferred from one address to another, they can be created, destroyed, kept in a “safe”, etc. However, if everyone creates their token in their own way, with their own rules, it would quickly become a merry mess. Some tokens might have a transfer function to transfer a token, others might use send(), sendTo(), transferToken(), or even functionToTransferATokenToSomeoneLikeYouuuuu(). In short, things wouldn’t work out. It wouldn’t be possible to exchange one token for another without listing all the functions of all the existing tokens.

That’s why, as with every emerging technology, a standard must be used to facilitate communication between applications, between tokens. Several improvements to Ethereum (Ethereum Improvement Proposal - EIP) have therefore been proposed in order to define different token standards depending on the application’s needs.

Fungible tokens - ERC20

The improvement proposal #20 describes a “classic” token standard. This proposal has been accepted, and the details of this standard are available at this address. As it was issue #20 that was at the origin of this standardisation, this standard is called ERC20 (ERC for Ethereum Request for Comments).

When I say that it’s a “classic” token, I mean that it’s a token with basic properties. It has a name, a symbol, and can be transferred from one address to another. All tokens (from the same contract) are equivalent, just as two bus tickets from the same town are equivalent. These tickets, like these tokens (or like Ethers), are interchangeable. They are therefore called fungible tokens.

In reality, having a name or symbol isn’t even required. It’s only convenient for humans, to help distinguish between tokens other than by their address. It’s a bit like URLs, which are much easier to remember than IP addresses. These two pieces of information, if used, must be accessible via the following methods:

function name() public view returns (string)
function symbol() public view returns (string)

Another optional information can be provided: the number of decimals supported by the token. If the “HND” token has 8 decimals, then in order to have 1 HND you actually need to have 100,000,000! It’s like euros: you need 100 cents to get one full euro. You would then need 10^8 fractions of HND to get 1 HND. So if a user has 150,000,000 HND, a web application will indicate that they have 1.5 (150,000,000 / 100,000,000).

This information can then be accessed using the following method:

function decimals() public view returns (uint8)

In addition to these three pieces of information, which make them easier to use by humans, these tokens must implement the following functions:

// This function must return the total supply of tokens (whether or not they have been distributed).
// If there is only 1 HND, with 8 decimals, this function returns 100,000,000.
function totalSupply() public view returns (uint256)

// Returns the number of tokens owned by an address.
function balanceOf(address _owner) public view returns (uint256 balance)

// Transfers tokens to another address.
function transfer(address _to, uint256 _value) public returns (bool success)

// Transfers tokens from a source address to a destination address.
// For this to work, the source address must have given prior authorisation to the // address performing the transfer.
// performing this `transferFrom` to transfer tokens (it would be too easy otherwise ;))
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)

// This function can be used to delegate the spending of a set number of tokens to an account.
// of tokens. This function must be called before the delegated account can
// use the `transferFrom()` function.
function approve(address _spender, uint256 _value) public returns (bool success)

// This function returns the number of tokens that an account can spend on behalf of another account.
function allowance(address _owner, address _spender) public view returns (uint256 remaining)

Example

With all these functions in mind, you can create a brand new token from scratch with Solidity!

Please note that this example is given for information only. It is absolutely not suitable for production.

// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.8.2 <0.9.0;

contract HackndoToken {

    // When state variables are declared as public, getters are automatically created by Solidity
    // For example, we can call the HackndoToken.symbol() function;
    string public symbol;
    string public name;
    uint8 public decimals;
    uint256 public totalSupply;

    // Find out how many tokens each address has
    mapping(address => uint) private balances;

    // Allows you to find out which address has authorised which addresses to spend how many tokens on its behalf
    // For example :
    // allowed[address1][address2] = 10
    // This means that address2 can spend 10 tokens from address1, instead of address1.
    mapping(address => mapping(address => uint)) private allowed;

    // Events must be emitted for certain actions
    event Transfer(address indexed from, address indexed to, uint tokens);
    event Approval(address indexed tokenOwner, address indexed spender, uint tokens);

    // When the contract is created, information about the tokens will be supplied
    constructor(string memory _symbol, string memory _name, uint8 _decimals, uint256 _totalSupply) {
        symbol = _symbol;
        name = _name;
        decimals = _decimals;
        totalSupply = _totalSupply;
        balances[msg.sender] = totalSupply;
        emit Transfer(address(0), msg.sender, _totalSupply);
    }

    // Returns the number of tokens owned by an address.
    function balanceOf(address _address) public view returns (uint balance) {
        return balances[_address];
    }



    // Transfers tokens to another address
    function transfer(address to, uint value) public returns (bool success) {
        // To transfer tokens, you need to have enough of them
        require(balances[msg.sender] >= value, "INSUFFICIANT_FUNDS");
        
        // The tokens are deleted from the sender
        balances[msg.sender] = balances[msg.sender] - value;

        // And we add them to the receiver
        balances[to] = balances[to] + value;

        // An event is sent to register the transfer
        emit Transfer(msg.sender, to, value);
        return true;
    }

    // Delegate the spending of a defined number of tokens to an account
    function approve(address spender, uint value) public returns (bool success) {
        // Whoever calls the function authorises "spender" to spend "value" tokens on their behalf
        allowed[msg.sender][spender] = value;
        emit Approval(msg.sender, spender, value);
        return true;
    }

    // Spend tokens on behalf of another account, provided that the person calling the function has been authorised to do so
    function transferFrom(address from, address to, uint value) public returns (bool success) {
        // We check that the person calling the function is authorised to spend the same number of tokens
        require(allowed[from][msg.sender] >= value, "NO_APPROVAL");

        // Then we check that the account spending the tokens has enough tokens
        require(balances[from] >= value, "INSUFFICIANT_FUNDS");

        // Authorisation is decremented by the number of tokens used
        allowed[from][msg.sender] = allowed[from][msg.sender] - value;

        // Then we exchange the number of tokens from one account to another
        balances[from] = balances[from] - value;
        balances[to] = balances[to] + value;
        emit Transfer(from, to, value);
        return true;
    }

    // Allows you to know how many tokens "spender" can use on behalf of "tokenOwner".
    function allowance(address tokenOwner, address spender) public view returns (uint remaining) {
        return allowed[tokenOwner][spender];
    }
}

This contract can be compiled and deployed on the Ethereum blockchain to create a new token. Incredible, isn’t it?

This type of token (this example, or another) can represent just about anything. It could be the equivalent of money in a video game, skill points, shares in a company (centralised or not), etc.

In this example, we’ve written a token from scratch. However, to avoid mistakes, and to ensure that standardisation goes as smoothly as possible, you shouldn’t reinvent the wheel, and you should use an audited and proven version, such as the one proposed by OpenZeppelin.

Non-Fungible Tokens (NFT) - ERC721

Non-Fungible Tokens (NFT) are a category of tokens that are specifically non-fungible. This means that two tokens, even though they come from the same smart contract, are different. This concept can be compared to cards that can be collected. Although the same company publishes cards representing, for example, the best hackers on the planet, each card represents a particular hacker. It may be from the same collection, but it is not equivalent to another card representing another person.

To standardise tokens with this notion of uniqueness, a new Ethereum enhancement request has been made, the #721, and this new standard is described in the Ethereum documentation, called ERC721.

To ensure that each token from the same smart contract is unique, a new variable is introduced, tokenId. This variable must be unique for each token in a smart contract in order to comply with the ERC721 standard.

In addition to this variable, the following methods must be implemented:

// Allows you to find out how many NFTs an account has
function balanceOf(address _owner) external view returns (uint256);

// Determines the owner of a particular NFT
function ownerOf(uint256 _tokenId) external view returns (address);

// Send an NFT from one address to another, making sure that the destination address
// is either an EOA account or a contract that can handle NFTs
// We'll explain how in the rest of this article
function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;
function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;

// Send an NFT from one address to another
function transferFrom(address _from, address _to, uint256 _tokenId) external payable;

// Authorise an account to manage an NFT on its behalf
function approve(address _approved, uint256 _tokenId) external payable;

// Authorise an account to manage ALL its NFTs on its behalf
function setApprovalForAll(address _operator, bool _approved) external;

// Check who can manage a particular NFT
function getApproved(uint256 _tokenId) external view returns (address);

// Check whether an account has full delegation for another account's NFTs
function isApprovedForAll(address _owner, address _operator) external view returns (bool);

There are a number of functions that are very similar to ERC20 token functions (balanceOf, transferFrom, approve for example). However, two other methods deserve a little more detail.

safeTransferFrom

The first is safeTransferFrom(). This method exists to prevent NFTs being sent to contracts which do not know how to handle NFTs. If this were the case, as the destination contract had not been created to handle NFTs, there would be no function to handle the newly received NFT. This would mean that this NFT could not be bought by anyone, or recovered in any way whatsoever. It would be locked into this contract forever. It’s easy to imagine that, when you offer a limited collection of something, you want to avoid losing it in the wild, unusable and non-exchangeable.

To avoid this kind of problem, when the safeTransferFrom() function is called to send tokens to a contract, the destination contract must use a special function, onERC721Received.

function onERC721Received(
  address operator,
  address from,
  uint256 tokenId,
  bytes calldata data
) external returns (bytes4);

This function will be called by the token contract, and expects a very specific response. If this function does not exist in the destination contract (or if the function exists but does not return what is expected) then the NFT transfer will be cancelled. So, to receive NFTs via safeTransferFrom, a contract must have explicitly included this function. As this function only exists to validate a safeTransferFrom, as a general rule, if a contract contains this function, it means that it is also capable of managing NFTs.

onERC721Received

The presence of onERC721Received does not guarantee that the contract can handle NFTs. You could very well create a contract that only implements onERC721Received, and nothing else. The call to this callback is more of a safeguard against silly errors.

setApprovalForAll

The other function that deserves attention is setApprovalForAll, simply because it can be dangerous. When a user uses this function to approve a destination address, it allows the destination to manage ALL of the user’s NFT collection. When we say “manage”, we mean that the destination can send the user’s NFTs to arbitrary destination addresses. He could send them to the address null (0x0), which would cause these NFTs to be lost forever, or even send them to himself. Once the transfer is complete, the user has no way of recovering them.

This function is dangerous and should only be used if you have absolute trust in the recipient (if it’s an EOA) or absolute understanding of the code (if the destination is a smart contract).

Example

Here is an example of an implementation of ERC721 from scratch showing a simplistic implementation of the functions.

Please note that this example is for illustrative purposes only. It is absolutely not suitable for production.

// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.8.2 <0.9.0;

// This interface must be declared in order to be able to call the callback when
// a safeTransferFrom
interface IERC721Receiver {
    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4);
}

contract HackndoToken {

    // When state variables are declared and made public, getters are automatically created by Solidity
    string public symbol;
    string public name;

    uint private tokenId;

    // Each NFT is assigned an owner
    mapping(uint256 => address) private owners;

    // Each address is assigned the number of NFTs it owns
    mapping(address => uint256) private balances;

    // Allows you to find out which address has delegation rights over which NFT
    mapping(uint256 => address) private tokenApprovals;

    // Addresses which have full rights over the NFTs of other addresses
    mapping(address => mapping(address => bool)) private operatorApprovals;



    // When the contract is created, information about the NFT will be supplied
    constructor(string memory _symbol, string memory _name) {
        symbol = _symbol;
        name = _name;
    }

    // This function is not included in the standard, but it allows you to create
    // NFT at will, with no restrictions! Enjoy :)
    function mint(address _to) public {
        owners[tokenId] = _to;
        balances[_to]++;
        // As an NFT is created, the identifier must be incremented
        // so that the next NFT is different
        tokenId++;
    }

    // Allows you to find out how many NFTs an account has
    function balanceOf(address _address) public view returns (uint balance) {
        return balances[_address];
    }

    // Determines the owner of a particular NFT
    function ownerOf(uint256 _tokenId) external view returns (address) {
        // You cannot have a tokenId greater than the last one created
        require(_tokenId < tokenId, "NOT_EXISTANT");
        return owners[_tokenId];
    }

    // Send an NFT from one address to another, while ensuring that the destination address
    // is either an EOA account or a contract that can handle NFTs
    function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes memory data) public payable {
        transferFrom(_from, _to, _tokenId);

        // If it's a contract, call the onERC721Received() callback.
        // If there is an error, or if the return value is not what was expected, we revert();
        // -> If there is a revert(), then the transferFrom() function previously called will be cancelled
        if (_to.code.length > 0) {
            try IERC721Receiver(_to).onERC721Received(msg.sender, _from, _tokenId, data) returns (bytes4 retval) {
                if (retval != IERC721Receiver.onERC721Received.selector) {
                    revert();
                }
            } catch (bytes memory) {
                revert();
            }
        }

    }

    function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable {
        safeTransferFrom(_from, _to, _tokenId, "");
    }

    // Send an NFT from one address to another
    function transferFrom(address _from, address _to, uint256 _tokenId) public payable {
        // Either it owns the NFT, or it has been approved for this NFT, or it has been approved for all the NFTs (including this one) in another account
        require(owners[_tokenId] == msg.sender || tokenApprovals[_tokenId] == msg.sender || operatorApprovals[_from][msg.sender], "NO_APPROVAL");
        // The transfer source must own the NFT
        require(owners[_tokenId] == _from, "NOT_OWNER");
        owners[_tokenId] = _to;
    }

    // Authorise an account to manage an NFT on its behalf
    function approve(address _approved, uint256 _tokenId) external payable {
        require(owners[_tokenId] == msg.sender, "NOT_OWNER");
        tokenApprovals[_tokenId] = _approved;
    }

    // Allow an account to manage ALL its NFTs on its behalf
    function setApprovalForAll(address _operator, bool _approved) external {
        operatorApprovals[msg.sender][_operator] = _approved;
    }

    // Check who can manage this particular NFT
    function getApproved(uint256 _tokenId) external view returns (address) {
        return tokenApprovals[_tokenId];
    }

    // Check whether an account has full delegation for another account's NFTs
    function isApprovedForAll(address _owner, address _operator) external view returns (bool) {
        return operatorApprovals[_owner][_operator];
    }
}

As with ERC20, there is a version of ERC721 proposed by OpenZeppelin which means you don’t have to start from scratch and can use a solid, tried and tested code base.

Conclusion

These two standards are the most widely known and used, but they are far from being the only ones in existence. In fact, tokens can be used for so many applications that standards develop (and sometimes die) as new ideas for their use are put forward.

Understanding how these tokens work is essential for any good auditor, as they are extremely common in decentralised applications, or dApps.


hackndo logo
Author : Pixis
Blog author, follow me on twitter or discord