ERC721 is the standard interface for non-fungible tokens, also known as NFTs. Having a standard is important as it promotes interoperability, which means your NFT is valid on every application that uses the standard.
Through this article, I will walk you through all the functions in an ERC721 contract and at the end we will create an ERC721 token and an NFTGatedEvent contract with the knowledge we have gathered.
Ready to dive in? Let’s go!
Let’s begin with a detailed explanation of all the functions in the interface of a standard ERC721 Contract.
An interface defines a set of standard functions that an ERC721-compliant contract must implement. This standard defines the standard and functionalities that an NFT (Non-Fungible Token) contract must adhere to for interoperability with other contracts and platforms. This means that if you want to write an NFT Contract (ERC721) and you want to implement certain features, these are the functions that you must implement for your logic:
interface IERC721 is IERC165 {
// Emitted when `tokenId` token is transferred.
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
// Emitted when `owner` enables `approved` to manage the `tokenId` token.
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
// Emitted when `owner` enables or disables approved `operator` to manage all of its assets.
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
// Returns the number of tokens in `owner`'s account.
function balanceOf(address owner) external view returns (uint256 balance);
// Returns the owner of the `tokenId` token.
function ownerOf(uint256 tokenId) external view returns (address owner);
// Transfers token.
function transferFrom(address from, address to, uint256 tokenId) external;
// Safely transfers token.
function safeTransferFrom(address from, address to, uint256 tokenId) external;
// Gives permission to transfer token to another account..
function approve(address to, uint256 tokenId) external;
// Approve or remove address as an operator.
function setApprovalForAll(address operator, bool approved) external;
// Returns the account approved for `tokenId` of token..
function getApproved(uint256 tokenId) external view returns (address operator);
// Returns if the `operator` is allowed to manage all of the assets of `owner`
function isApprovedForAll(address owner, address operator) external view returns (bool);
}
Why are we inheriting IERC165?
The above code sample is the IERC721 interface contract that follows the OpenZeppelin standard implementation of an interface contract for ERC721 contracts. A standard ERC721 contract uses the IERC721 interface contract to inherit the IERC165 contract, this is to ensure that the ERC721 contract complies also with the ERC165 standard. This inheritance serves the purpose of signalling the support for interface detection..
Purpose of IERC165
The IERC165 interface provides a standard way to publish and detect what interfaces a contract implements. Its primary function is to allow a contract to advertise which interfaces it supports. This is particularly useful for contracts that may implement multiple interfaces, as it provides a consistent way to check for support of a specific interface. You can read more about it here.
The supportsInterface function allows a contract to signal whether it supports a particular interface by returning a boolean value. It takes an interface ID as a parameter and returns true if the contract supports the interface and false if it does not.
Deep dive into ERC721
Now that we know the functions in a standard ERC721, let's look at how these functions are implemented in the standard ERC721 library implementation and what each of these functions does and represents.
Transfer
, Approval
and ApprovalForAll
events have been explained well by the comment in the interface above, so I will be focusing on the functions.
balanceOf
function balanceOf(address owner) external view returns (uint256 balance);
This function takes in an address and returns the number of NFTs (tokens) that the specific address (owner) holds within a particular ERC721 collection.
It verifies that the address is not an address zero. An address zero is represented by a string of all zeros: 0x0000000000000000000000000000000000000000
which simply means a non-existent address.
ownerOf
function ownerOf(uint256 tokenId) external view returns (address owner);
This function helps to determine the current owner of a specific NFT that is identified by its unique token ID (tokenId
) within the ERC721 collection.
This function returns the address that currently owns an NFT.
transferFrom
function transferFrom(address from, address to, uint256 tokenId) external;
This function takes in the address of the current owner of the NFT(from
), the receiver(to
), and the tokenId
of the NFT you are transferring and then transfers the ownership of that NFT (tokenId
).
safeTransferFrom
function safeTransferFrom(address from, address to, uint256 tokenId) external;
This function offers a safer and more robust approach to transferring NFTs between accounts compared to the standard transferFrom
function.
It prioritizes security measures to prevent unintended consequences and potential token loss.
Difference betweentransferFrom
andsafeTransferFrom
transferFrom | safeTransferFrom |
Designed for basic transfers where the caller has already verified the recipient's ability to handle NFTs. | It does an extra check that checks if the recipient's address is a contract. |
Only performs a basic check to ensure the recipient address isn't the zero address (address(0) ) to prevent sending NFTs to invalid accounts. | If the recipient is a contract, it calls the onERC721Received function on the recipient contract to ensure it can properly receive and handle ERC721 tokens. This prevents accidental locking of NFTs in incompatible contracts. |
When to use which of the functions
transferFrom | safeTransferFrom |
UsetransferFrom when transferring NFTs to trusted addresses that you know can handle ERC721 tokens appropriately. | UsesafeTransferFrom when the recipient is an unknown or third-party contract. Also when you want to guarantee the safety of the NFT transfer and avoid potential locking issues. |
approve
// interface
function approve(address to, uint256 tokenId) external;
// Openzepplin implementation
function _approve(address to, uint256 tokenId, address auth, bool emitEvent) internal virtual {
// Avoid reading the owner unless necessary
if (emitEvent || auth != address(0)) {
address owner = _requireOwned(tokenId);
// We do not use _isAuthorized because single-token approvals should not be able to call approve
if (auth != address(0) && owner != auth && !isApprovedForAll(owner, auth)) {
revert ERC721InvalidApprover(auth);
}
if (emitEvent) {
emit Approval(owner, to, tokenId);
}
}
_tokenApprovals[tokenId] = to;
}
The above two functions work together in the ERC721 standard for authorizing another address to transfer a specific NFT.
approve
The public function approve
serves as the entry point for approving another address (recipient) to transfer a particular NFT (identified by its tokenId
).
_approve
The internal function _approve
handles the core logic of authorization. It's called by approve
and potentially other functions within the contract.
It takes in two additional parameters:
auth
: The address that is being authorized (might not always be the caller ofapprove
).emitEvent
: A boolean indicating whether to emit anApproval
event (explained later).
More explanation of what_approve
function does:
If
auth
is not the zero address, the owner is not the same asauth
, and the owner hasn't already granted overall approval toauth
usingsetApprovalForAll
(checked byisApprovedForAll
), then it throws a custom errorERC721InvalidApprover
.
This ensures that only authorized addresses can be approved on behalf of the owner. The internal_approve
function checks ifemitEvent
is true or ifauth
is not the zero address (address(0)
).
If either condition is met, it proceeds to retrieve the current owner of the NFT using the_requireOwned(tokenId)
(This function is also implemented inside ERC721 as a helper function that makes sure that thetokenId
has an owner).It checks if
auth
is not the zero address, and the owner is not the same asauth
, and the owner hasn't already approved toauth
, then it throws a custom errorERC721InvalidApprover
. This ensures that only authorized addresses can be approved on behalf of the owner.If
emitEvent
is true, the function emits anApproval
event.mapping(uint256 tokenId => address) private _tokenApprovals;
Regardless of event emission, the core action is assigning the
to
address to the internal mapping_tokenApprovals[tokenId]
.This mapping essentially stores the authorized address for each NFT within the contract.
setApprovalForAll
function setApprovalForAll(address operator, bool approved) external;
This function grants transfer authority for all of an owner's NFTs within a specific ERC721 contract to another address (operator).
It enables NFT owners to set/assign a trusted party to manage and transfer their NFTs without requiring individual approvals for each token.
Creating our NFT
There have been some implementations of the ERC721 standard interface and in this article, we will be using the Openzepplin implementation.
See this implementation as a library, it already has an implementation of the standard interface so what you just need to do is import their implementation and use it to create your NFT.
Creating a structure for our token contract. We are inheriting the ERC721, which means we have access to all its functions and can use it in our token contract. The contract inherits from both ERC721
and ERC721URIStorage
.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
contract RokiMarsNFT is ERC721, ERC721URIStorage {}
ERC721URIStorage
helps us add support for token URIs. It provides a standard way to associate a token ID with a URI(Uniform Resource Identifier), allowing for the retrieval of off-chain metadata associated with each token.
In the Openzepllin implementation, we can pass the token name and symbol in the constructor, so let's do that.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
contract RokiMarsNFT is ERC721, ERC721URIStorage {
constructor() ERC721("RokiMars", "ROKI") {}
}
Now, we need to make the NFT contract ownable which means, we want to be able to use the onlyOwner
modifier on some functions which will give certain rights to the address that would pass when deploying the contract and that address would be called the owner.
So we would import and inherit ownable from Openzepplin.
Next, we set the initial owner upon deployment, using the constructor(a function that runs once upon deployment).
Then we create a function called safeMint and pass in the address we want to mint to and the tokenId as parameters. The onlyOwner
is a modifier that is part of the things we inherited from Openzepplin.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract RokiMarsNFT is ERC721, ERC721URIStorage, Ownable {
constructor(address initialOwner)
ERC721("RokiMars", "ROKI")
Ownable(initialOwner){}
function safeMint(address to, uint256 tokenId) public onlyOwner {
_safeMint(to, tokenId);
}
}
Every NFT should have a token ID, and with what we have now, we are adding a token ID every time we want to mint and that's not very efficient if we would like the ID to be accurate, as this would be prone to human error.
The solution to this is to auto-increment the ID every time we mint.
If you see below a lot has changed in our contract, let's talk about what changed:-
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract RokiMarsNFT is ERC721, ERC721URIStorage, Ownable {
uint256 private nextTokenId;
constructor(address initialOwner)
ERC721("RokiMars", "ROKI")
Ownable(initialOwner){}
function safeMint(address to, string memory uri) public onlyOwner returns (uint256) {
uint256 _tokenId = nextTokenId++;
_safeMint(to, _tokenId);
_setTokenURI(_tokenId, uri);
return _tokenId;
}
// The following functions are overrides required by Solidity.
function tokenURI(uint256 tokenId)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC721URIStorage)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
We created a private variable called
nextTokenId
.For the
_safeMint
function, we added the token URI as a parameter.Then we removed the
tokenId
parameter from thesafeMint
function, as we don't need to addtokenId
every time we want to mint since we want to auto-increment.Inside this function, the
nextTokenId
is used to determine the ID of the newly minted token. The++
operator is used to incrementnextTokenId
and then assign its current value to_tokenId
.Now each time the
safeMint
the function is called, it mints a new token with a unique ID by incrementingnextTokenId
.tokenUrl
andsupportsInterface
functions are required overrides required by solidity when you want to useERC721URIStorage
.
Building an NFTGatedEvent Contract
This NFTGatedEvent contract is built to manage events where participants are required to own a specific NFT. Here's an overview of the key components and functionalities:-
nftaddress
This variable stores the address of the NFT, which identifies the NFT. We want to validate that the user has the NFT before they can register for any of the events.
address nftaddress;
eventCount
This is used to maintain a count of all the events that have been created.
uint eventCount;
structEvent
This is a struct named "Event
" to represent each event, including details such as title, description, venue, registered users, and event date.
struct Event{
uint eventId;
string title;
string description;
string venue;
address[] registeredUsers;
string eventDate;
}
events
Mapping
A mapping that we use to store events based on their IDs. This means using this mapping, you can pass in the ID and get a particular event with that ID.
mapping (uint256 => Event) events;
createEvent
function
The createEvent function allows the creation of new events by providing essential details such as title, description, venue, and event date. Each event is assigned a unique event ID and stored in the events
mapping and eventsArrays
.
We made all the parameters required. So when an event creator wants to create an event they would have to enter all these fields before the event is created.
function createEvent(
string memory _title,
string memory _description,
string memory _venue,
string memory _eventDate
) external {
require(bytes(_title).length > 0, "Title is required");
require(bytes(_description).length > 0, "Description is required");
require(bytes(_venue).length > 0, "Venue is required");
require(bytes(_eventDate).length > 0, "Event date is required");
uint _newEventId = eventCount + 1;
Event storage _event = events[_newEventId];
_event.title = _title;
_event.description = _description;
_event.venue = _venue;
_event.eventDate = _eventDate;
eventsArrays.push(_event);
eventCount = eventCount + 1;
}
The registerForEvent
function allows users to register for a specific event by providing the event ID. Before registration, it checks if the sender holds at least one NFT from the specified NFT contract and if the user has already registered for the event. If the conditions are met, the user is added to the list of registered users for the event.
function registerForEvent(uint _eventId) external {
require(IERC721(nftaddress).balanceOf(msg.sender) > 0, "Not enough");
require(!hasRegistered[_eventId][msg.sender], "You have registered already");
require(_eventId <= eventCount, "Event ID does not exist");
Event storage _event = events[_eventId];
_event.registeredUsers.push(msg.sender);
hasRegistered[_eventId][msg.sender] = true;
}
The getEvent
function allows the retrieval of a specific event based on its ID, while the getAllEvents
function returns an array containing all created events.
function getEvent(uint256 _eventId) external view returns(Event memory){
return events[_eventId];
}
function getAllEvents() external view returns(Event[] memory){
return eventsArrays;
}
You can get the full code here
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
contract NFTGatedEvent{
address nftaddress;
uint eventCount;
struct Event{
uint eventId;
string title;
string description;
string venue;
address[] registeredUsers;
string eventDate;
}
mapping (uint256 => Event) events;
mapping (uint256 => mapping (address => bool)) hasRegistered;
Event[] eventsArrays;
function createEvent(
string memory _title,
string memory _description,
string memory _venue,
string memory _eventDate
) external {
require(bytes(_title).length > 0, "Title is required");
require(bytes(_description).length > 0, "Description is required");
require(bytes(_venue).length > 0, "Venue is required");
require(bytes(_eventDate).length > 0, "Event date is required");
uint _newEventId = eventCount + 1;
Event storage _event = events[_newEventId];
_event.title = _title;
_event.description = _description;
_event.venue = _venue;
_event.eventDate = _eventDate;
eventsArrays.push(_event);
eventCount = eventCount + 1;
}
function registerForEvent(uint _eventId) external {
require(IERC721(nftaddress).balanceOf(msg.sender) > 0, "Not enough");
require(!hasRegistered[_eventId][msg.sender], "You have registered already");
require(_eventId <= eventCount, "Event ID does not exist");
Event storage _event = events[_eventId];
_event.registeredUsers.push(msg.sender);
hasRegistered[_eventId][msg.sender] = true;
}
function getEvent(uint256 _eventId) external view returns(Event memory){
return events[_eventId];
}
function getAllEvents() external view returns(Event[] memory){
return eventsArrays;
}
}