How to read a smart contract and assess its safety

This will be a quick and dirty guide, but it might be very useful for some of you. Let’s get going!

Let’s use this contract as an example

https://etherscan.io/address/0x301144b43d8dcba1b3e9f70ed7338d0751d700a3#code

Going through the first transactions of the contract we can find out that there were two ways to mint the NFTs, as an airdrop or as a free mint.

We will look at both functions, but let’s start with the Airdrop.

The “Airdrop” method is not exactly the function name inside the contract, but the name of an event emitted by the contract. We can find the exact function by checking the transaction details:

Alright, airdrop(address to, uint256[] tokenIds) is what we’re after

We can use the search function inside the Contract tab in etherscan

The airdrop function simply calls _safeMint in a loop for a number of tokens given by the user.

the _safeMint function is usually coming from an OpenZeppelin standard contract, but it’s worth checking it.

/**
     * @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);
        require(
            _checkOnERC721Received(address(0), to, tokenId, _data),
            "ERC721: transfer to non ERC721Receiver implementer"
        );
    }

it does two things:

  1. It calls _mint
  2. it requires _checkOnERC721Received. If this function will return false, the transaction will revert even if _mint was already called.

You can think of it like

if(_checkOnERC721Received)
    _mint

_mint and _checkOnERC721Received might still do something nasty, so we have to check both of them

function _mint(address to, uint256 tokenId) internal virtual {
        require(to != address(0), "ERC721: mint to the zero address");
        require(!_exists(tokenId), "ERC721: token already minted");

        _beforeTokenTransfer(address(0), to, tokenId);
        _owners.push(to);

        emit Transfer(address(0), to, tokenId);
    }

_mint requires a valid address and valid token, transfers the token to the user using _beforeTokenTransfer, adds the user to an _owners list and then emits a Transfer event.

The only thing which might do anything malicious is _beforeTokenTransfer, but that’s not the case because we know that _beforeTokenTransfer is not actually a function implemented in the smart contract, but an ERC721 hook. It is a “native” function of the EVM.

Later edit: _exists() could also be malicious here, so let’s check it

 function _exists(uint256 tokenId) internal view virtual returns (bool) {
        return tokenId < _owners.length && _owners[tokenId] != address(0);
    }

Nothing weird. It checks if the token id is in range and has an owner.

Going back to _checkOnERC721Received

 function _checkOnERC721Received(
        address from,
        address to,
        uint256 tokenId,
        bytes memory _data
    ) private returns (bool) {
        if (to.isContract()) {
            try
                IERC721Receiver(to).onERC721Received(
                    _msgSender(),
                    from,
                    tokenId,
                    _data
                )
            returns (bytes4 retval) {
                return retval == IERC721Receiver.onERC721Received.selector;
            } catch (bytes memory reason) {
                if (reason.length == 0) {
                    revert(
                        "ERC721: transfer to non ERC721Receiver implementer"
                    );
                } else {
                    assembly {
                        revert(add(32, reason), mload(reason))
                    }
                }
            }
        } else {
            return true;
        }
    }

This is a function which checks if the receiver address is capable of receiving ERC721. I won’t explain it in detail, but the implementation of our smart contract matches the implementation from OpenZeppelin so we can assume it’s safe.

We’re done with airdrop, let’s get back to Free Mint

Checking the transaction details for a Free Mint event transaction to find the function name

And here is our code

function freeMint(uint numberOfTokens) public nonReentrant {
    require(saleIsActive, "Sale must be active to mint");
    require(freeWunkAllowance[msg.sender].add(numberOfTokens) < MAX_FREE_PER_USER_WUNKS, "Your mint would exceed the total of 5 Wunks allowed for free.");
    require(totalSupply().add(numberOfTokens) < FREE_WUNKS, "Purchase would exceed free supply");
    
    for(uint i = 0; i < numberOfTokens; i++) {
      _safeMint(msg.sender, totalSupply());
    }

    freeWunkAllowance[msg.sender] += numberOfTokens;
  }

This function has several requirements

  1. saleIsActive is a boolean variable, it can not execute anything malicious, it must be true
  2. freeWunkAllowance[msg.sender].add(numberOfTokens) < MAX_FREE_PER_USER_WUNKS checks a number in a list, it can not execute anything malicious. In this case, it checks a maximum allowance per user
  3. totalSupply().add(numberOfTokens) < FREE_WUNKS checks the total supply. The totalSupply() function is called. It might have malicious code inside
function totalSupply() public view virtual override returns (uint256) {
        return _owners.length;
    }

Nothing malicious here, it just checks the number of owners.

We already know _safeMint is ok from our previous analysis of airdrop.

The last line simply updates the allowance list (I assume you can have an allowance of 3 for example, and can claim the NFTs in 3 different freeMint transactions. In this case the smart contract must keep track of your current allowance).

Both airdrop and freeMint are fine at first glance, I would consider the smart contract safe, but keep in mind that there have been exploits with really complex bugs which were not at all obvious and would have passed such a brief analysis.

Hints / Cheat Sheet

  • address.transfer or address.send are the functions used to send Ethereum between accounts. Take a good look at it if you find them.
  • To transfer another ERC20 token, the smart contract must import the ERC20 contract, allow and transfer. That would look similar to this

address dai_token = 0xaD6D458402F60fD3Bd25163575031ACDce07538D;

IERC20(dai_token).approve(msg.sender, tokens);
 
IERC20(dai_token).transferFrom(
        msg.sender,
        address(this),
        tokens
    );
  • A similar mechanism is used for ERC721 and ERC1155 (NFTs). Be very vigilent with transferFrom and safeTransferFrom!!!

Related Articles

Responses

Your email address will not be published.