This guide covers how to create, deploy, and interact with an ERC721 smart contract on Starknet using OpenZeppelin, Starknet Foundry, and Starknet.js. You’ll learn how to compile, declare, and deploy the contract, then mint NFTs and manage them efficiently on Layer 2.
Table Of Contents
· Table Of Contents
· Introduction to StarkNet and Cairo
· Starknet Foundry Set Up
∘ Importing the Braavos Account
∘ Defining the Profiles
∘ Install The OpenZeppelin Library
· StarkNet Faucet
· Building The Contract
∘ Declaring The Contract
· Deploying the Contract
· Starknet.js
· Github Repository
· Conclusion
· Sources
Introduction to StarkNet and Cairo
Starknet is a Layer 2 (L2) scaling solution for Ethereum that uses STARK (Scalable Transparent Argument of Knowledge) proofs to batch multiple transactions off-chain and submit a single proof to Ethereum. This drastically reduces gas fees and increases throughput, while maintaining Ethereum’s security and decentralization.
Unlike Solidity, which is used for Ethereum smart contracts, Cairo is the native smart-contract language for Starknet. Cairo allows to write provable program with STARK validity proofs, without a deep knowledge of the complex cryptographic concepts underneath.
Check out this guide for further exploration on Starknet.
Starknet Foundry Set Up
To compile and deploy our smart contract, we will use Starknet Foundry in this article. For installation instructions, I recommend checking out this guide.
To start a new project called label_nft we will execute:
snforge new label_nft
Importing the Braavos Account
For this project I have imported my Braavos account using sncast account importboth for Sepolia testnet and mainnet:
sncast
account import
–url https://starknet-sepolia.infura.io/v3/****
–name account_braavos
–address 0xyjozq3nlq7f1fma6d2oz9rd8xa3np961igz7xpg3roxw37c8fl7nzfsz4avkeag4
–private-key 0x7a90aE1b2904F6F6787b0bcB6E4c8D08aF1e4fca5a33b96c8d8e7b8a6f78b60c
–type braavos
These accounts are saved in the file starknet_open_zeppelin_accounts.json at the path: ~/.starknet_accounts/starknet_open_zeppelin_accounts.json
The file starknet_open_zeppelin_accounts.json should be similar to this:
{
“alpha-mainnet”: {
“account_braavos_mainnet”: {
“address”: “0xyjozq3nlq7f1fma6d2oz9rd8xa3np961igz7xpg3roxw37c8fl7nzfsz4avkeag4”,
“class_hash”: “0xba780ad3e82a6756c2e892a252d527261177324fad6b0622f13aa7354433bef”,
“deployed”: true,
“legacy”: false,
“private_key”: “0x7a90aE1b2904F6F6787b0bcB6E4c8D08aF1e4fca5a33b96c8d8e7b8a6f78b60c”,
“public_key”: “0xyjozq3nlq7f1fma6d2oz9rd8xa3np961igz7xpg3roxw37c8fl7nzfsz4avkeag”,
“type”: “braavos”
}
},
“alpha-sepolia”: {
“account_braavos”: {
“address”: “0xyjozq3nlq7f1fma6d2oz9rd8xa3np961igz7xpg3roxw37c8fl7nzfsz4avkeag4”,
“class_hash”: “0xba780ad3e82a6756c2e892a252d527261177324fad6b0622f13aa7354433bef”,
“deployed”: true,
“legacy”: false,
“private_key”: “0x7a90aE1b2904F6F6787b0bcB6E4c8D08aF1e4fca5a33b96c8d8e7b8a6f78b60c”,
“public_key”: “0xyjozq3nlq7f1fma6d2oz9rd8xa3np961igz7xpg3roxw37c8fl7nzfsz4avkeag”,
“type”: “braavos”
}
}
}
Here you can find the complete guide.
To check the account’s list we can also execute:
sncast account list
Defining the Profiles
In the snfoundry.toml file, I have added the account configuration and the RPC URL for deploying the smart contract on both Starknet Testnet Sepolia and Starknet Mainnet:
[sncast.default]
url = “https://starknet-sepolia.infura.io/v3/*****”
accounts-file = “~/.starknet_accounts/starknet_open_zeppelin_accounts.json”
account = “account_braavos”
[sncast.mainnet]
account = “account_braavos_mainnet”
accounts-file = “~/.starknet_accounts/starknet_open_zeppelin_accounts.json”
url = “https://starknet-mainnet.infura.io/v3/******”
Check out this guide to learn more about profile definition.
Install The OpenZeppelin Library
To install the OpenZeppelin library in our project, we should add it to the dependencies section of the Scarb.toml file:
openzeppelin = “0.20.0”
So our dependencies section should look like this:
[dependencies]
starknet = “2.9.2”
openzeppelin = “0.20.0”
Take a look at the OpenZeppelin’s guide for further exploration.
StarkNet Faucet
To declare, and deploy your tokens or to test your smart contract on StarkNet Sepolia you can use the StarkNet faucet to get test tokens both in ETH and in STRK at this url.
Building The Contract
To create the ERC721 smart contract, I started with the OpenZeppelin Wizard, which is accessible at this URL.
I chose to create a mintable and enumerable smart contract using the wizard. I removed owner support to simplify things but added a map called token_uris to store the token URI for each minted NFT. This practice is commonly used to store NFT metadata on external storage, such as IPFS.
I also added a new getter and setter for the token_uris variable, as well as a mint_item function to mint NFTs and use token_uris to store the NFT metadata.
This is the resulting smart contract:
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts for Cairo ^0.20.0
use starknet::ContractAddress;
#[starknet::interface]
pub trait ILabels<TContractState> {
fn get_token_uri(self: @TContractState, token_id: u256) -> ByteArray;
fn set_token_uri(ref self: TContractState, token_id: u256, uri: ByteArray);
fn mint_item(ref self: TContractState, recipient: ContractAddress, uri: ByteArray);
}
#[starknet::contract]
mod Labels {
use openzeppelin::introspection::src5::SRC5Component;
use openzeppelin::token::erc721::ERC721Component;
use starknet::ContractAddress;
use openzeppelin::token::erc721::extensions::ERC721EnumerableComponent;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess};
component!(path: ERC721Component, storage: erc721, event: ERC721Event);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
component!(path: ERC721EnumerableComponent, storage: erc721_enumerable, event: ERC721EnumerableEvent);
// External
#[abi(embed_v0)]
impl ERC721MixinImpl = ERC721Component::ERC721MixinImpl<ContractState>;
#[abi(embed_v0)]
impl ERC721EnumerableImpl = ERC721EnumerableComponent::ERC721EnumerableImpl<ContractState>;
// Internal
impl ERC721InternalImpl = ERC721Component::InternalImpl<ContractState>;
impl ERC721EnumerableInternalImpl = ERC721EnumerableComponent::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
erc721: ERC721Component::Storage,
#[substorage(v0)]
src5: SRC5Component::Storage,
#[substorage(v0)]
erc721_enumerable: ERC721EnumerableComponent::Storage,
pub counter: u256,
pub token_uris: Map<u256, ByteArray>,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
ERC721Event: ERC721Component::Event,
#[flat]
SRC5Event: SRC5Component::Event,
#[flat]
ERC721EnumerableEvent: ERC721EnumerableComponent::Event,
}
#[constructor]
fn constructor(ref self: ContractState) {
self.erc721.initializer(“Labels”, “LBL”, “”);
self.erc721_enumerable.initializer();
}
impl ERC721HooksImpl of ERC721Component::ERC721HooksTrait<ContractState> {
fn before_update(
ref self: ERC721Component::ComponentState<ContractState>,
to: ContractAddress,
token_id: u256,
auth: ContractAddress,
) {
let mut contract_state = self.get_contract_mut();
contract_state.erc721_enumerable.before_update(to, token_id);
}
}
#[generate_trait]
#[abi(per_item)]
impl ExternalImpl of ExternalTrait {
#[external(v0)]
fn safe_mint(
ref self: ContractState,
recipient: ContractAddress,
token_id: u256,
data: Span<felt252>,
) {
self.erc721.safe_mint(recipient, token_id, data);
}
#[external(v0)]
fn safeMint(
ref self: ContractState,
recipient: ContractAddress,
tokenId: u256,
data: Span<felt252>,
) {
self.safe_mint(recipient, tokenId, data);
}
}
#[abi(embed_v0)]
impl ImplLabels of super::ILabels<ContractState> {
fn get_token_uri(self: @ContractState, token_id: u256) -> ByteArray {
assert(self.erc721.exists(token_id), ERC721Component::Errors::INVALID_TOKEN_ID);
return self.token_uris.read(token_id);
}
fn set_token_uri(ref self: ContractState, token_id: u256, uri: ByteArray) {
assert(self.erc721.exists(token_id), ERC721Component::Errors::INVALID_TOKEN_ID);
self.token_uris.write(token_id, uri);
}
fn mint_item(ref self: ContractState, recipient: ContractAddress, uri: ByteArray) {
let current_counter = self.counter.read();
let new_counter = current_counter + 1;
self.counter.write(new_counter);
self.erc721.mint(recipient, new_counter);
self.set_token_uri(new_counter, uri);
}
}
}
In a Cairo project, the main file is lib.cairo, located in the src folder. Therefore, you should place your smart contract inside the lib.cairo file.
Declaring The Contract
Starknet differentiates between a contract class and a contract instance, similar to the distinction in object-oriented programming between defining a class (MyClass {}) and creating an instance of it (let myInstance = MyClass()).
Declaring a contract is a required step to make it available on the network. Once declared, the contract can then be deployed and interacted with.
To declare our smart contract on the Sepolia testnet, we can use the default profile, which points to Sepolia, as seen earlier. Furthermore, if we declare the smart contract using v2, the fees will be paid in ETH. However, to use the latest version and pay fees in STRK, we should use v3. Check out this guide to learn more.
So if we run:
sncast declare -v v3 -c Labels
We are paying the gas fees in STRK and declaring on the Sepolia testnet because we are using the default profile. In fact, this command implicitly includes –profile default. Labels is the name of our smart contract.
Instead if we want to pay the gass fees in STRK we should execute:
sncast declare -v v2 -c Labels
If we want to declare our smart contract on the StarkNet mainnet we should run:
sncast –profile mainnet declare -v v2 -c Labels
Like we said earlier in this case we are paying the fees in ETH but if we want to pay in STRK we should use v3.
If we don’t want to use the profile we can also declare the contract this way:
sncast –account account_braavos
declare
–url https://starknet-sepolia.infura.io/v3/*******
–contract-name Labels
To learn more about declaring contracts check out this guide.
Once you run the command it will compile the smart contract and it will output a class hash. In this case it is: 0x000da2972b416e39ce7cc2a59edf9ff6f40666188ea2add93a1011ba3e59c73c
Deploying the Contract
Starknet Foundry’s sncast tool allows smart contract deployment to a specified network using the sncast deploy command.
It operates by calling a Universal Deployer Contract, which deploys the contract using the provided class hash and constructor arguments.
After declaring our smart contract we can deploy it on Sepolia with:
sncast deploy -v v3 –class-hash 0x000da2972b416e39ce7cc2a59edf9ff6f40666188ea2add93a1011ba3e59c73c
In this case we use v3 to pay the fees in STRK. If we want to pay the fees in ETH we need to use v1.
If we want to deploy the smart contract on StarkNet mainnet we can run:
sncast –profile mainnet deploy -v v1 –class-hash 0x000da2972b416e39ce7cc2a59edf9ff6f40666188ea2add93a1011ba3e59c73c
If you don’t want to use the profile, you can deploy the contract using:
sncast
–account my_account
deploy
–url http://127.0.0.1:5055/rpc
–class-hash 0x000da2972b416e39ce7cc2a59edf9ff6f40666188ea2add93a1011ba3e59c73c
The output should be something like this:
command: deploy
contract_address: 0x042de0e88b8d70b02ff6303fdb69cec5718154db91b8c53a58de047dfcbc41c0
transaction_hash: 0x009282ecfa8611a6b2d5a213c9da1a861a2760b96c0a72f1b94b85cb7ed36d97
To see deployment details, visit:
contract: https://sepolia.starkscan.co/contract/0x042de0e88b8d70b02ff6303fdb69cec5718154db91b8c53a58de047dfcbc41c0
transaction: https://sepolia.starkscan.co/tx/0x009282ecfa8611a6b2d5a213c9da1a861a2760b96c0a72f1b94b85cb7ed36d97
If you want to learn more about deploying smart contracts with Starknet Foundry check out this guide.
Starknet.js
To test our smart contract, we can use Starknet.js, a library designed to interact with Starknet and perform operations using JavaScript.
Let’s create three environment variables in our .envfile:
STARKNET_ADDRESS=your_starnet_address
STARKNET_PRIVATE_KEY=your_account_private_key
STARKNET_RPC_URL=your_rpc_url
Let’s start by creating an RpcProvider object:
const provider = new RpcProvider({
nodeUrl: process.env.STARKNET_RPC_URL,
});
Now let’s connect to our account:
const account = new Account(
provider,
process.env.STARKNET_ADDRESS,
process.env.STARKNET_PRIVATE_KEY
);
Now, to connect to our smart contract, we need the ABI. We can extract it from our Starknet Foundry repository, where our code was compiled, specifically from the file label_nft_Labels.contract_class.json located in the target/release folder.
To extract the ABI field from this file, I used:
jq -r ‘.abi’ ./target/release/label_nft_Labels.contract_class.json > labels.json
This extracts the abi field and saves it into a new file labels.json.
To connect to our smart contract with starknet.js we must first import the ABI:
import labelsAbi from “../labels.json” with { type: ‘json’ };
Then we can write:
const erc721 = new Contract(labelsAbi, DEPLOYED_CONTRACT, provider);
erc721.connect(account);
We should be able to call the name function of the smart contract:
await contract.name(); // Labels
To mint a new NFT we can call the mint_itemfunction that we have created in the smart contract:
const mintNft = async (
contract: Contract,
account: Account,
provider: RpcProvider,
recipient: string,
uri: string
) => {
// Transaction with fees paid in ETH
const mintCall = contract.populate(“mint_item”, { recipient, uri });
const { transaction_hash: transferTxHash } = await account.execute(mintCall, {
version: 3, // version 3 to pay fees in STRK. To pay fees in ETH remove the version field
});
console.log(`Minting NFT with transaction hash: ${transferTxHash}`);
// Wait for the invoke transaction to be accepted on Starknet
const receipt = await provider.waitForTransaction(transferTxHash);
console.log(receipt);
};
await mintNft(
erc721,
account,
provider,
account.address,
“NFT1”
);
Also in this case if we put version: 3 we will pay the fees in STRK, if we remove this field we will pay in ETH.
So, once I execute this, it will mint my first NFT to account.address, which is:0x032e21f8277033fd4ddbb2127f5ebe74c7cdb09e36e72bd0071ad9bf6039b7bd, with the token URI NFT1, and it has returned the transaction hash: 0x6444953ee970574896ccc025b33636aceeb314247dadc4913c3d10f1d9db401.
It is possible to check the transaction on the Stark Scan.
We could call the total_supplymethod of the smart contract to check how many NFTs have been minted:
const totalSupply = await contract.total_supply(); // 1
We could also check the token URI of the first NFT:
const uri = await contract.get_token_uri(1); // NFT1
Finally, we could also test the balance_of method by calling it on account.address:
await contract.balance_of(account.address);
Github Repository
The whole code is available at the this Github repository.
Conclusion
In this article, we walked through the process of creating an ERC721 smart contract using OpenZeppelin, compiling and deploying it with Starknet Foundry, and interacting with it using Starknet.js. With Starknet’s scalability and low-cost transactions, this approach provides an efficient way to build and manage NFTs on Layer 2.
Sources
https://www.starknet.io/blog/validity-rollups/https://foundry-rs.github.io/starknet-foundry/getting-started/installation.htmlhttps://foundry-rs.github.io/starknet-foundry/starknet/account-import.htmlhttps://foundry-rs.github.io/starknet-foundry/projects/configuration.htmlhttps://github.com/OpenZeppelin/cairo-contracts/blob/main/README.mdhttps://foundry-rs.github.io/starknet-foundry/starknet/declare.htmlhttps://foundry-rs.github.io/starknet-foundry/starknet/deploy.htmlhttps://starknetjs.com/
How to Create and Deploy an ERC721 Smart Contract on Starknet with Starknet Foundry and Starknet.js was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.