People don't understand NFTs (or tokens)

Posted on Jun 22, 2022

When speaking to people about non-fungible tokens (NFTs) (or regular “fungible” tokens) on the Ethereum blockchain, I find they generally have a mental visualisation in some way that these items are almost like collectible gold coins - they “move around”, albeit in a digital sense, and they’re all made alike. However, this is not the case, and understanding that this isn’t the case is paramount when trying to understand DeFi projects and protocols, wallets, UX, and security.

In Ethereum (or to be specific, the EVM - Ethereum Virtual Machine), there are only three core things: Ether (the “currency”), transactions, and contracts. Blocks are just signed bundles of transactions, and everything else (tokens, protocols, etc.) are nothing more than contracts 1

# So, what really are tokens / NFTs?

Tokens / NFTs are just standard interfaces for contracts to conform to.

This is the most important thing to understand. They are standards, and as such, can be implemented any which way. Specifically, tokens and NFTs are usually the ERC20 and ERC721 standards respectively. These standards are what allow you to trade any token you want on Uniswap, or any NFT on OpenSea.

What this ultimately means is, even if the token contract appears to be a regular ERC20 token, unless the code is verified and audited, you can’t be sure what it’s doing.

If we look at the (trimmed down) code for an ERC20 fungible token:

#...
event Transfer:
    sender: indexed(address)
    receiver: indexed(address)
    value: uint256
# ...
balanceOf: public(HashMap[address, uint256])
# ...

@external
def transfer(_to : address, _value : uint256) -> bool:
    """
    @dev Transfer token for a specified address
    @param _to The address to transfer to.
    @param _value The amount to be transferred.
    """
    # NOTE: vyper does not allow underflows
    #       so the following subtraction would revert on insufficient balance
    self.balanceOf[msg.sender] -= _value
    self.balanceOf[_to] += _value
    log Transfer(msg.sender, _to, _value)
    return True

All tokens are just this - a map of Ethereum address <> balance, and some functions to manipulate that in various ways (which is actually quite similar to how the banking system works - transfers are just subtracting balance for one account, and increasing it in another).

Now let’s take a look at ERC721:

# ...
event Transfer:
    sender: indexed(address)
    receiver: indexed(address)
    tokenId: indexed(uint256)
# ...

# @dev Mapping from NFT ID to the address that owns it.
idToOwner: HashMap[uint256, address]

# @dev Mapping from NFT ID to approved address.
idToApprovals: HashMap[uint256, address]

# @dev Mapping from owner address to count of his tokens.
ownerToNFTokenCount: HashMap[address, uint256]

# @dev Mapping from owner address to mapping of operator addresses.
ownerToOperators: HashMap[address, HashMap[address, bool]]

# ...

@view
@external
def ownerOf(_tokenId: uint256) -> address:
    """
    @dev Returns the address of the owner of the NFT.
         Throws if `_tokenId` is not a valid NFT.
    @param _tokenId The identifier for an NFT.
    """
    owner: address = self.idToOwner[_tokenId]
    # Throws if `_tokenId` is not a valid NFT
    assert owner != ZERO_ADDRESS
    return owner

@internal
def _transferFrom(_from: address, _to: address, _tokenId: uint256, _sender: address):
    """
    @dev Execute transfer of a NFT.
         Throws unless `msg.sender` is the current owner, an authorized operator, or the approved
         address for this NFT. (NOTE: `msg.sender` not allowed in private function so pass `_sender`.)
         Throws if `_to` is the zero address.
         Throws if `_from` is not the current owner.
         Throws if `_tokenId` is not a valid NFT.
    """
    # Check requirements
    assert self._isApprovedOrOwner(_sender, _tokenId)
    # Throws if `_to` is the zero address
    assert _to != ZERO_ADDRESS
    # Clear approval. Throws if `_from` is not the current owner
    self._clearApproval(_from, _tokenId)
    # Remove NFT. Throws if `_tokenId` is not a valid NFT
    self._removeTokenFrom(_from, _tokenId)
    # Add NFT
    self._addTokenTo(_to, _tokenId)
    # Log the transfer
    log Transfer(_from, _to, _tokenId)

The same thing - just a map of “token ID” (the number, like #8159, that you see on OpenSea for each item) to the address of the owner.

# Poisonous transfer functions

Let’s take an example of why this matters. There is an excellent demonstration by Nathan Worsley titled Salmonella: Wrecking sandwich traders for fun and profit. While the details are interesting, what really matters is this: 68 ETH 2 taken from a bot that was attempting to front-run transactions for profit, because the bot wasn’t built to understand this principle. All Nathan had to do was add a few extra lines of code to the transfer function of the contract.

# So how does this affect me, and how can I protect myself?

This means that any interaction with a token or NFT could be potentially dangerous to your funds.

As for protecting yourself, unfortunately, this is hard. There are two options:

  1. Read the code for every token you interact with yourself
  2. Rely on audits / community trust

Generally, if you didn’t already know all of the above, you’re probably better off with (2). Only interact with projects that you trust, make sure you’re always on the correct website, and be very cautious of signing transactions, in the same way most people are nowadays generally suspicious of cold calls/emails/texts asking you for detailed information, or to click a link.

Ethereum is a dark forest. Stay safe out there.


This raises the interesting discussion of self-custody - are non-crypto-native-software-engineering-professional people capable of it, and if they aren’t (they aren’t!), what is the point of crypto/web3?

It’s a discussion probably best saved for another time, but suffice it to say, it doesn’t render blockchain technology pointless. If people cannot self-custody, they can trust another party to custody for them - this is, essentially, a bank. Unlike the banks of the traditional system, however, if you:

  1. Are persona-non-grata to the banks for any reason
  2. Live in a country that the US government has decided shouldn’t be allowed access to the Western financial system
  3. Have a desire to keep your funds available and irrepressible, such as for example, being a political opponent under an authoritarian regime

You can use self-custody to maintain your fundamental human right to transact with others. This has value far beyond what the mainstream narrative is often willing to admit.


  1. Quick aside: smart contracts

    First of all, it pays to understand what a “smart contract” is.

    A smart contract is just a piece of code which can be deployed to the blockchain.

    For example, here’s a rudimentary registry contract to mimick ENS:

    # This language is called Vyper
    # You may have heard of Solidity - this is an alternative to that
    
    registry: HashMap[Bytes[100], address]
    
    @external
    def register(name: Bytes[100], owner: address):
        assert self.registry[name] == ZERO_ADDRESS  # check name has not been set yet.
        self.registry[name] = owner
    
    @view
    @external
    def lookup(name: Bytes[100]) -> address:
        return self.registry[name]
    

    You can then interact with this code by sending transactions from your wallet. ↩︎

  2. See how much this is valued at right now. $74,893.16 at time of writing. ↩︎