ERC721 & its quirks
Cryptocurrencies are not just about transferring digital value. They have evolved to encapsulate diverse digital assets, including collectibles, in-game items, and digital art. In the world of blockchain, one standard stands out for representing these unique, indivisible, and irreplaceable digital assets: ERC721.
So, what exactly is an ERC721?
ERC721, short for "Ethereum Request for Comments 721," is an Ethereum token standard that defines how non-fungible tokens (NFTs) should behave on the blockchain. NFTs are digital tokens representing ownership or proof of authenticity of unique assets. Unlike cryptocurrencies like Bitcoin or Ethereum, which are interchangeable and can be divided into smaller units, each ERC721 token is distinct and indivisible.
Structure:
pragma solidity ^0.4.20;
interface ERC721 /* is ERC165 */ {
event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);
event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);
function balanceOf(address _owner) external view returns (uint256);
function ownerOf(uint256 _tokenId) external view returns (address);
function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;
function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
function approve(address _approved, uint256 _tokenId) external payable;
function setApprovalForAll(address _operator, bool _approved) external;
function getApproved(uint256 _tokenId) external view returns (address);
function isApprovedForAll(address _owner, address _operator) external view returns (bool);
}
interface ERC165 {
function supportsInterface(bytes4 interfaceID) external view returns (bool);
}
Use of Functions:
balanceOf(): The function returns the balance of an ERC721 token for a given address. This address could belong to any user, it can be considered similar to a getter function to fetch the balance of a target user. It can return "0" as well so ensure the value is taken into account while conducting any arithmetic operation on the return values.
ownerOf(): The function returns the owner address of an NFT.
safeTransferFrom(): The function is used to transfer an NFT, or more precisely the ownership of an NFT from one address to another address. It takes in the following parameters: (address
from
, addressto
, uint256_tokenId
, bytesdata
)A few things to keep in mind while using this function is that:
*thefrom
address needs to be the current owner of the NFT.
*neitherfrom
norto
can be a 0 address.
*once received by the receiver, it checks if theto
address is a smart contract (code size > 0) and if so, it calls theonERC721Received
and throws if the return value is notbytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))
.The function also takes a
data
parameter in the end, this can be used to supply any kind of data depending upon the project.transferFrom(): The function simply transfers an NFT from the owner address or an address that has been approved to transfer this token to a destination address. However, this function is not recommended due to missing security controls and
safeTransferFrom
is recommended over the use of this function.approve(): The approve() function is used to approve an address, the authority to operate over an NFT. The address that gets approved, gains control over the token and can move or sell it as well. Proper controls need to be implemented while working with such functions. The function ensures by default that the
msg.sender
is the owner of the NFT token but reverts if it isn't.setApprovalForAll(): The function allows/revokes the the permissions for an external entitys' address, an operator, to manage all the assets under the ownership of
msg.sender
's address. If a booltrue
is passed, it gets approved and if a boolfalse
value is passed then it is revoked.getApproved(): The function allows us to fetch the address which is approved to manager a particular NFT token. It takes in the
_tokenId
as a single parameter.isApprovedForAll(): The function simply returns a
true
orfalse
bool value indicating whether anaddress
is approved to manage all the assets under anowner
's address.
Any wallet/broker/auction protocol must implement the wallet interface if it intends to accept the safe transfers:
interface ERC721TokenReceiver {
function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes _data) external returns(bytes4);
}
As discussed in the safeTransferFrom
function, the onERC721Received
needs to be implemented on the receiving end so that it returns the solidity selector to confirm the token transfer (bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))
). However, if any other value is returned then the transfer will simply revert. The selector can be obtained with IERC721.onERC721Received.selector
.
Another optional feature of the ERC721 standard is the metadata extension (function). This function allows your contract to be interrogated/queried for its name and details about the assets that your NFTs represent. Below are the function that should be implemented in order to implement this extension:
interface ERC721Metadata /* is ERC721 */ {
function name() external view returns (string _name);
function symbol() external view returns (string _symbol);
function tokenURI(uint256 _tokenId) external view returns (string);
}
name(): The name() function simply returns the name of the collection that you set during token creation.
symbol(): The symbol() function returns the symbol of the NFT collection that is set during token creation.
tokenURI(): The function returns a URI, a locator for a JSON file that conforms to the ERC721 Metadata JSON Schema. Sample structure of the schema:
{
"title": "Asset Metadata",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Identifies the asset to which this NFT represents",
},
"description": {
"type": "string",
"description": "Describes the asset to which this NFT represents",
},
"image": {
"type": "string",
"description": "A URI pointing to a resource with mime type image/* representing the asset to which this NFT represents. Consider making any images at a width between 320 and 1080 pixels and aspect ratio between 1.91:1 and 4:5 inclusive.",
}
}
}
Before moving on to the quirks for this ERC standard, there is something important we need to know about ERC721. One of the major functions of this standard is to mint and burn the tokens. However, ERC721 does not provide any guidelines for this and thus the protocol/project is expected to implement these by themself. If you have read my previous blog on ERC20 and its security concerns, you know that anything that provides this flexibility can be a cause of some major security issues. With that in mind, let's move ahead to the quirks.
Quirks:
ERC721 brings a whole new level of versatility to the table for developers. It offers endless opportunities for creativity and innovation. However, just like any other powerful tool, if not used carefully and securely, it can lead to unforeseen consequences. There have been instances in the past where mismanagement of these capabilities resulted in significant losses for projects and organizations.
_safeMint() vs _mint(): One of the most common issues while using the ERC721 is to ensure that a recipient is a smart contract or not. This check is necessary because if the recipient address is a smart contract that is not equipped to handle an ERC721 token, then the token will be lost forever.
As explained above, all the smart contracts that intend to be compatible with the ERC721 standard are recommended to have the
onERC721Received()
function which reverts back with the selector thus proving the compatibility. This check is implemented in the_safeMint
function which is why projects favor the use of_safeMint()
function.Note: Using
_safeMint()
opens a channel for reentrancy attacks which is why the projects/protocols should always implement a non-reentrant modifier to ensure the reentrancy is patched through.Core Functionality Blocked: Every project implements the logic of the functions in their own manner. Some projects prefer having a modifier to pause their functionalities when needed, this can be an issue as all the projects in general would not assume such an implementation.
If your project interacts with such an ERC721 project, then the functionalities in your project are likely to get affected in cases where those projects pause their contracts for some reason. Projects that might be vulnerable to such cases, prefer not to interact with such implementations and interact only with wrapped ERC721s.
One very good reference is this issue from Ajna contest.
Broken
tokenURI()
Implementation: ThetokenURI()
function takes an_id
as its parameter to fetch the URI for, this parameter is auint256
value. However, the result that gets fetched usually happens by appending theid
of the token at the end of abaseURI
. If theid
parameter is not converted properly to a string, this could return absurd responses.For example in the following code sample from a contest finding:
function tokenURI(uint256 _id) public view override returns (string memory) {
if (ownerOf[_id] == address(0))
// According to ERC721, this revert for non-existing tokens is required
revert TokenNotMinted(_id);
return string(abi.encodePacked(baseURI, _id, ".json"));
}
The function simply encodes and packs the id
, which means that raw bytes of the 32-byte ABI encoded integer will be interpolated into the token URI. for ID 1
:
0x0000000000000000000000000000000000000000000000000000000000000001
This results in broken or malformed results for where it is being used to fetch results.
Reentrancy in onERC721Received: The
onERC721Received
is implemented to ensure that the receiving address supports ERC721 tokens. However, this introduces a reentrancy if the code does not follow the Checks-Effects-Interaction(CEI) pattern.The
onERC721Received
is called in thesafeTransferFrom()
function hence every time that is called, the possibility for reentrancy is born. For example in the following code:
require(_mintAmount <= maxMint, "Cant mint more then maxmint" );
for (uint256 i = 1; i <= _mintAmount; i++) {
_safeMint(msg.sender, supply + i);
}
Definition of _safeMint() function:
_mint(to, tokenId);
require(
_checkOnERC721Received(address(0), to, tokenId, _data),
"ERC721: transfer to non ERC721Receiver implementer"
);
Definition of _checkOnERC721Received() function:
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))
}
}
}
}
When the mint is initiated by a contract, the contract is checked for its ability to receive ERC721 tokens. Without a reentrancy guard, onERC721Received
will allow an attacker-controlled contract to call the mint again, which may not be desirable to some parties, like allowing minting more than allowed which allows them to reenter the code and benefit by minting more tokens than desired.
A simple way to block against this vector is to simply add the nonReentrant
modifier to prevent reentrancy.