We’re on a mission to bring developers into the Starknet ecosystem. To achieve this, we’re working on a content series that will educate and guide you through the basics of building on the network.
Previously, we explained what an ERC20 token is and how to create and deploy an ERC20 token on Starknet. This week, we’ll focus on ERC721 tokens, also known as NFTs.
By the end of this article, you’ll understand what an ERC721 token is and how to create one using OpenZeppelin’s library.
WTF are NFTs?
NFTs (Non-fungible tokens) are unique digital assets that live on a blockchain, like Starknet.
Popularly known as NFTs, these tokens differ from their counterparts (Fungible tokens) because they cannot be exchanged or traded at equivalence for other tokens.
Unlike an ERC20 token which is fungible, meaning they are replaceable by the same token, similar to how money works. NFTs are non-fungible, meaning they cannot be replaced, as each has a unique identifier and metadata that distinguishes them from other tokens.
The ERC721 Standard
The ERC721 Standard was introduced by William Entriken, Dieter Shirley, Jacob Evans, and Nastassia Sachs and was inspired by the ERC20 Standard, containing ERC20-like methods such as:
- name: This function defines the token’s name.
- symbol: This function defines the token’s symbol.
- balanceOf: This function returns number of NFTs owned by an address.
The ERC721 standard also introduces some new methods:
- ownerOf: This function returns the address of the owner of a token.
- supportsInterface: This function queries if a contract implements an interface.
- transferFrom: Transfers token ownership from one account to another.
- safeTransferFrom: Carries out safe transfer of token ownership from one account to another. A safe transfer means that it checks whether the receiver is valid. It can also accept additional data sent to the receiver.
- approve: This function grants or approves another entity the permission to transfer tokens on the owner’s behalf.
- setApprovalForAll: This function enables or disables approval for an operator to manage all owner’s asset.
- getApproved: This function gets the approved address for a particular token ID.
- isApprovedForAll: This function queries if an address is an authorized operator for another address.
Setting up Protostar Environment
To start building your first NFT on Starknet, you need to set up your developer framework. Our preferred option is Protostar.
If you’re unfamiliar with a developer environment or want to learn more about Protostar, we recommend our article on creating a Cairo Development Environment.
When you’ve set Protostar up, you’re ready to build your first Starknet NFT. Let’s call our project “argentERC721”.
To do this, run:
protostar init
There will be a further request for the project’s name and the library’s directory name. This is needed to initialize your project successfully.
Creating a new file
We need to create a new file called ERC721.cairo in our src folder. This is where we’re going to be writing our contract code.

Imports
Inside our new file, we’ll begin with the %lang starknet directive, which specifies that our file contains code for a Starknet contract.
After we’ve done that, we import all the necessary library functions.
%lang starknet from starkware.cairo.common.cairo_builtins import HashBuiltin from starkware.cairo.common.uint256 import Uint256 from starkware.starknet.common.syscalls import get_caller_address from cairo_contracts.src.openzeppelin.token.erc721.library import ERC721 from cairo_contracts.src.openzeppelin.introspection.erc165.library import ERC165 from cairo_contracts.src.openzeppelin.access.ownable.library import Ownable
From this code snippet, you can see that we import and will use the ERC721 and ERC165 namespaces from Openzeppelin’s ERC721 library. We also import the HashBuiltin, which is needed for pedersen hashing related operations, the Uint256 struct for creating Uint256 variables, and the get_caller_address for getting the address of the caller.
The ERC721 Contract Interface
Here’s the ERC721 contract interface, which exposes the methods we will need to implement while building our token:
%lang starknet from starkware.cairo.common.uint256 import Uint256 @contract_interface namespace IERC721 { func name() -> (name: felt) { } func symbol() -> (symbol: felt) { } func balanceOf(owner: felt) -> (balance: Uint256) { } func ownerOf(tokenId: Uint256) -> (owner: felt) { } func safeTransferFrom(from_: felt, to: felt, tokenId: Uint256, data_len: felt, data: felt*) { } func transferFrom(from_: felt, to: felt, tokenId: Uint256) { } func approve(approved: felt, tokenId: Uint256) { } func setApprovalForAll(operator: felt, approved: felt) { } func getApproved(tokenId: Uint256) -> (approved: felt) { } func isApprovedForAll(owner: felt, operator: felt) -> (isApproved: felt) { } func mint(to: felt) { } }
Writing the contract
Constructor
For our ERC721 token, we need to initialize certain variables on deployment, such as the name, symbol, and owner. To do this, our contract must implement a constructor:
@constructor func constructor{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr} ( name: felt, symbol: felt, owner: felt ) { ERC721.initializer(name, symbol); Ownable.initializer(owner); return (); }
We also call the initializer internal function by using the ERC721 namespace we imported from Openzeppelin while passing the required function arguments [name and symbol]. We finally assign an owner to the contract by calling the initializer function of the Ownable namespace.
SupportsInterface
This function queries to check if the contract implements a certain interface.
@view func supportsInterface{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr} ( interfaceId: felt ) -> (success: felt) { let (success) = ERC165.supports_interface(interfaceId); return (success,); }
Name
The name function is a view function that simply returns the token’s name when queried.
@view func name{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr} () -> (name: felt) { let (name) = ERC721.name(); return (name,); }
Symbol
The symbol function returns the token’s symbol when queried.
@view func symbol{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr} () -> (symbol: felt) { let (symbol) = ERC721.symbol(); return (symbol,); }
BalanceOf
The balance of function returns the number of NFTs owned by an address.
@view func balanceOf{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr} (owner: felt) -> (balance: Uint256) { let (balance: Uint256) = ERC721.balance_of(owner); return (balance,); }
OwnerOf
This function returns the address of the owner of a token.
@view func ownerOf{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr} (tokenId: Uint256) -> (owner: felt) { let (owner) = ERC721.owner_of(tokenId); return (owner,); }
GetApproved
This function gets the approved address for a particular token ID.
@view func getApproved{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr} (tokenId: Uint256) -> (approved: felt) { let (approved) = ERC721.get_approved(tokenId); return (approved,); }
IsApprovedForAll
This function queries if an address is an authorized operator for another address.
@view func isApprovedForAll{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr} (owner: felt, operator: felt) -> (isApproved: felt) { let (isApproved) = ERC721.is_approved_for_all(owner, operator); return (isApproved,); }
TransferFrom
This function Transfers token ownership from one account to another.
@external func transferFrom{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}( _from: felt, to: felt, tokenId: Uint256 ) { ERC721.transfer_from(_from, to, tokenId); return (); }
SafeTransferFrom
Carries out safe transfer of token ownership from one account to another.
@external func safeTransferFrom{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}( _from: felt, to: felt, tokenId: Uint256, data_len: felt, data: felt* ) { ERC721.safe_transfer_from(_from, to, tokenId, data_len, data); return (); }
Approve
This function grants or approves another entity the permission to transfer tokens on the owner’s behalf.
@external func approve{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}( account: felt, tokenId: Uint256 ) { ERC721.approve(account, tokenId); return (); }
SetApprovalForAll
This function enables or disables approval for an operator to manage all owner’s asset.
@external func setApprovalForAll{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}( operator: felt, approved: felt ) { ERC721.set_approval_for_all(operator, approved); return (); }
Congratulations, you just completed your first ERC721 contract! You can find the full contract code here.
Minting NFTs
While we’ve completed our NFT contract, we would need an extra function for minting new tokens with different token IDs to a user. To do this, we will implement a storage variable token_counter and an external function mint.
token_counter
The token_counter is a storage variable that keeps track of the number of tokens created in order to determine the next token ID.
@storage_var func token_counter() -> (id: felt) { }
mint
The mint function is an external function that implements the minting logic. It first checks that the caller is the contract owner, then calculates the new token ID, mints the new token to the recipient, and finally updates the token_countervariable.
@external func mint{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}( to: felt ) { Ownable.assert_only_owner(); let (prevTokenId) = token_counter.read(); let tokenId = prevTokenId + 1; ERC721._mint(to, Uint256(tokenId, 0)); token_counter.write(tokenId); return (); }
Deployment
Finally, following the usual steps to deploy our contracts, we will first build/compile, declare, and deploy.
Building
When you build your Starknet NFT, you need to specify the correct path to your contract in your protostar.toml and enable Protostar to locate your lib folder, which contains your Openzeppelin contracts. To do this, add the snippet:
cairo-path = ["lib"] Your protostar.toml file should look like this: [project] protostar-version = "0.9.1" lib-path = "lib" cairo-path = ["lib"] [contracts] ERC721 = ["src/ERC721.cairo"]
To build your contract, simply run the following:
protostar build

Declaring
Before executing the “declare” command, you need to set the private key associated with the specified account address in a file or the terminal.
To set your private key in the terminal, run the command:
export PROTOSTAR_ACCOUNT_PRIVATE_KEY=[YOUR PRIVATE KEY HERE]
Do not share your private key with anyone. Not even Argent. It should be for your eyes only.
To declare your contract, simply run the command:
protostar declare ./build/ERC721.json --network testnet --account 0x0691622bBFD29e835bA4004e7425A4e9630840EbD11c5269DE51C16774585b16 --max-fee auto

Deploying
Finally, we need to call the “deploy” command passing in our contract class hash to deploy our contract.
protostar deploy 0x04dae654c7b6707667a178729b512d61494fe590ab4accc46923d6409b97e617 --network testnet --account 0x0691622bBFD29e835bA4004e7425A4e9630840EbD11c5269DE51C16774585b16 --max-fee auto --inputs 71959616777844 4280903 0x0691622bBFD29e835bA4004e7425A4e9630840EbD11c5269DE51C16774585b16

Conclusion
Congratulations! You just wrote and deployed your first ERC721 contract on StarkNet.
To interact with the deployed contract, check Starkscan here.
If you have any questions as regards this, reach out to me @0xdarlington, I’d love to help you build on StarkNet with Argent X.
For more developer resources, follow us across our socials:
Twitter — @argentHq
Engineering Twitter — @argentDeveloper
LinkedIn — @argentHq
Youtube — @argentHQ
Interested in the topic? Join us!
We’re always looking for outstanding engineers to help us pioneer better UX and security in crypto. We work remotely across Europe.
Argent CareersRelated Blogs
Getting started with StarkNet
The StarkNet ecosystem is exploding and here's how you can get involved
Understanding The Universal Deployer Contract And Deploying your Contracts through Argent X
Deploying contracts on Starknet using the Universal deployer contract
Part I: WTF is Account Abstraction
Learn why it’s a game changer for crypto's adoption