Starknet: An NFT contract with Nile and L1 <-> L2 reputation messaging
The objective of this tutorial is twofold:
- Get you familiar with the foundational Starknet concepts: the account model and the L1 <-> L2 messaging.
- Do a hands-on walkthrough with the tooling available on Starknet, the smart contracts, and the communication between the L1 Ethereum network and the L2 Starknet network.
For the tutorial, you will implement an NFT contract with a simple reputation points system. You will run the NFT contract on the L2 Starknet testnet, and you will be able to interact with the L2 reputation system through the L1 Ethereum Sepolia testnet.
Prerequisites
- Chainstack account to deploy an Ethereum Sepolia node and a Starknet testnet node.
- Cairo to compile Starknet contracts and to interact with the network through the Starknet CLI.
- OpenZeppelin Nile to create and deploy an NFT contract.
- OpenZeppelin Cairo contracts to use the ERC-721 contract library in Cairo.
- MetaMask to deploy the L1 contract and interact with the contract.
- ArgentX to interact with the L2 NFT contract.
Overview
To get from zero to a deployed NFT contract with a reputation system on L2 and its counterpart on L1, do the following:
- With Chainstack, create a public chain project.
- With Chainstack, deploy an Ethereum Sepolia testnet node.
- With Chainstack, deploy a Starknet testnet node.
- With Chainstack, access your Ethereum Sepolia testnet node credentials and your Starknet testnet node credentials.
- Set up your MetaMask with the Ethereum Sepolia testnet node.
- Deploy the L1 contract on the Ethereum Sepolia testnet and verify the contract.
- With the Starknet CLI, deploy an account on the Starknet testnet.
- With Nile, deploy the L2 NFT contract on the Starknet network.
- Mint an NFT on L2.
- Increase and decrease the reputation of the minted NFT on L2.
- On L1, withdraw the reputation points of the minted L2 NFT.
- On L1, increase the reputation of the minted L2 NFT.
Step-by-step
Create a public chain project
See Create a project.
Join the Ethereum Sepolia and Starknet testnets
For the L1 <-> L2 messaging, you need access to both an Ethereum network and a Starknet network. See Join a public network.
Get the node access and credentials
See View node access and credentials.
Set up MetaMask
You will deploy the L1 contract with Remix through MetaMask.
To set up, see Ethereum tools: MetaMask.
Set up an account on Starknet
Unlike on other EVM-based networks, accounts on Starknet are abstract and decoupled from the signer. Accounts are contracts and can contain any code—for example, an account can be multi-signature or have any other mechanisms built in.
For this tutorial, you will deploy the standard account that comes with the Starknet CLI and is based on the OpenZeppelin account contract implementation.
To deploy an account:
new_account --wallet starkware.starknet.wallets.open_zeppelin.OpenZeppelinAccount --network=alpha-sepolia
Fund the deployed account with L2 Goerli ETH.
Set up the L2 NFT contract
There will be three contracts in total to do the L1 <-> L2 messaging for this tutorial:
- Starknet L1 core contract — the default Starknet contract deployed on L1. See the official core contract addresses.
- Starknet L2 NFT contract — your tutorial contract in Cairo that you will deploy on the Starknet testnet. This contract will communicate with your Ethereum L1 contract.
- Ethereum L1 contract — your tutorial contract in Solidity that you will deploy on the Ethereum Sepolia testnet. This contract will:
- Consume messages from your L2 contract through the Starknet L1 core contract.
- Send messages to your L2 contract.
You are going to use the OpenZeppelin ERC721 Mintable Burnable contract and modify it to add the L1 <-> L2 reputation messaging.
Initialize the project with Nile:
nile init
Install the OpenZeppelin contract libraries:
pip install cairo-nile openzeppelin-cairo-contracts
In the contracts/
directory, create a contract called NFT_with_reputation.cairo
:
%lang starknet
from openzeppelin.token.erc721.ERC721_Mintable_Burnable import constructor
If you were going to just create a simple NFT contract on the Starknet network, this would have been enough.
But you are going to add to the NFT contract a reputation system that will communicate with the L1 contract.
Update your NFT_with_reputation.cairo
contract with the following code:
%lang starknet
from openzeppelin.token.erc721.ERC721_Mintable_Burnable import constructor
from starkware.cairo.common.alloc import alloc
from starkware.cairo.common.cairo_builtins import HashBuiltin
from starkware.starknet.common.messages import send_message_to_l1
const L1_CONTRACT_ADDRESS = (
L1_MESSAGING_CONTRACT)
const MESSAGE_REP_DOWN = 0
# A mapping from a nftId (L1 Ethereum address) to their reputation.
@storage_var
func reputation(nftId : felt) -> (res : felt):
end
@view
func get_rep{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
nftId : felt
) -> (reputation : felt):
let (res) = reputation.read(nftId=nftId)
return (res)
end
@external
func rep_up{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
nftId : felt, points : felt
):
let (res) = reputation.read(nftId=nftId)
reputation.write(nftId, res + points)
return ()
end
@external
func rep_down{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
nftId : felt, points : felt
):
let (res) = reputation.read(nftId=nftId)
tempvar new_reputation = res - points
# Update the new reputation.
reputation.write(nftId, new_reputation)
# Send the rep down message.
let (message_payload : felt*) = alloc()
assert message_payload[0] = MESSAGE_REP_DOWN
assert message_payload[1] = nftId
assert message_payload[2] = points
send_message_to_l1(to_address=L1_CONTRACT_ADDRESS, payload_size=3, payload=message_payload)
return ()
end
@l1_handler
func repUp{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
from_address : felt, nftId : felt, points : felt
):
# Make sure the message was sent by the intended L1 contract.
assert from_address = L1_CONTRACT_ADDRESS
# Read the current reputation.
let (res) = reputation.read(nftId=nftId)
# Compute and update the new reputation.
tempvar new_reputation = res + points
reputation.write(nftId, new_reputation)
return ()
end
where L1_MESSAGING_CONTRACT is the address of the L1 contract that you will deploy later.
The contract implementation is the following:
- The NFT part of the contract is based on the OpenZeppelin ERC721 Mintable Burnable library.
nftId
is used to identify the NFT token ID when it is minted.rep_up
can be called by any contract on L2 to increase the reputation of the NFT.rep_down
can be called by any contract on L2 to decrease the reputation of the NFT and send the reputation points as a message to the L1 contract.get_rep
can be called by any contract on L2 to get the current reputation of the NFT.l1_handler
processes the message from the L1 contract.
You will deploy the L2 contract after you deploy the L1 contract.
Set up and deploy the L1 contract
The L1 contract will communicate with your L2 contract through the L1 Starknet core contract.
In Remix, create the L1L2ERC721Rep.sol
contract:
pragma solidity ^0.8.7;
interface IStarknetCore {
/**
Sends a message to an L2 contract.
Returns the hash of the message.
*/
function sendMessageToL2(
uint256 toAddress,
uint256 selector,
uint256[] calldata payload
) external returns (bytes32);
/**
Consumes a message that was sent from an L2 contract.
Returns the hash of the message.
*/
function consumeMessageFromL2(uint256 fromAddress, uint256[] calldata payload)
external
returns (bytes32);
}
/**
Contract for L1 <-> L2 interaction between an L2 Starknet contract and this L1 solidity
contract.
*/
contract L1L2ERC721Rep {
// The Starknet core contract.
IStarknetCore starknetCore;
mapping(uint256 => uint256) public nftIdIdleReputationPoints;
uint256 constant MESSAGE_REP_DOWN = 0;
// The selector of the "repUp" l1_handler.
uint256 constant REPUP_SELECTOR =
481301234104709516967081079511443560691305293629011359495317790738588668414;
/**
Initializes the contract state.
*/
constructor(IStarknetCore starknetCore_) {
starknetCore = starknetCore_;
}
function withdrawToIdle(
uint256 l2ContractAddress,
uint256 nftId,
uint256 points
) external {
// Construct the rep down message's payload.
uint256[] memory payload = new uint256[](3);
payload[0] = MESSAGE_REP_DOWN;
payload[1] = nftId;
payload[2] = points;
// Consume the message from the Starknet core contract.
// This will revert the (Ethereum) transaction if the message does not exist.
starknetCore.consumeMessageFromL2(l2ContractAddress, payload);
// Update the L1 reputation points of the NFT.
nftIdIdleReputationPoints[nftId] += points;
}
function repUp(
uint256 l2ContractAddress,
uint256 nftId,
uint256 points
) external {
require(points < 2**64, "Invalid points.");
require(points <= nftIdIdleReputationPoints[nftId], "Insufficient nftId's reputation points.");
// Update the L1 reputation points of the NFT.
nftIdIdleReputationPoints[nftId] -= points;
// Construct the repUp message's payload.
uint256[] memory payload = new uint256[](2);
payload[0] = nftId;
payload[1] = points;
// Send the message to the Starknet core contract.
starknetCore.sendMessageToL2(l2ContractAddress, REPUP_SELECTOR, payload);
}
}
The contract implementation is the following:
- The contract has the interface to communicate with the L1 Starknet core contract.
- The constructor of the contract takes the L1 Starknet core contract address.
- The
withdrawToIdle
external function withdraws the NFT reputation points sent to L1 using the L2rep_down
function. Mechanically, once you hit the L2rep_down
function, the L2 state is pushed as a message into the L1 Starknet core contract. Therep_down
message then sits in the L1 Starknet core contract until it gets consumed by triggering thewithdrawToIdle
function in your L1 contract. - The contract has the
repUp
external function of the L2 contract computed forREPUP_SELECTOR
. To compute:
-
Create a Python script with the function name (
repUp
):from starkware.starknet.compiler.compile import \ get_selector_from_name print(get_selector_from_name('repUp'))
-
Run the script.
The reputation points withdrawn to an account through withdrawToIdle can then be used in the L1
repUp
function to send a message to the L2 NFT contract to increase the NFT reputation.If this seems a little complicated at this point, do not worry—just keep following the tutorial and you will have a full hands-on walkthrough, which will help understand each of the steps better.
In Remix, compile the
L1L2ERC721Rep.sol
contract.In Remix, on the deployment tab:
- In Environment, select Injected Provider - Metamask.
- In Contract, select
L1L2ERC721Rep
. - In Deploy, provide
0xde29d060D45901Fb19ED6C6e959EB22d8626708e
. This is the address of the L1 Starknet core contract on the Ethereum Sepolia testnet.
Clicking Deploy will engage your MetaMask instance connected to the Ethereum Sepolia testnet and deploy the contract.
Verify the deployed contract on Etherscan:
- Find the contract by the address and click Contract > Verify and Publish.
- For Compiler Type, select Solidity (Single file).
- For Compiler Version, select v0.8.7.
- For Open Source License Type, select No License (None).
- For Optimization, select No.
- In the Solidity Contract Code field, paste the entirety of your
L1L2ERC721Rep.sol
contract. - Click Verify and Publish.
This will verify the contract and turn it into a web app that you can interact with through MetaMask.
Deploy the L2 NFT contract
Now that you have the address of your L1 contract, swap L1_MESSAGING_CONTRACT
in the L2 contract code that you have prepared with the actual L1 contract address.
In your Nile project directory, compile the contract:
nile compile
Deploy the contract:
nile deploy CONTRACT_NAME NFT_NAME_INT NFT_TICKER_INT OWNER_ADDRESS --network sepolia
where:
- CONTRACT_NAME — the name of the contract, which is
NFT_with_reputation
for this tutorial. - NFT_NAME_INT — Nile accepts only integers for the deployment constructor. Provide any NFT collection name in integer format. See below for the conversion script.
- NFT_TICKER_INT — Any NFT collection ticker in integer format.
To convert a string to an integer, run the following Python script:
# string_to_integer.py
import sys
for arg in sys.argv[1:]:
print(int.from_bytes(arg.encode(), byteorder="big"))
Example:
$ python3 string_to_integer.py NFTwithRep
369641944198362097149296
Example of deploying the contract with the name NFTwithRep
, ticker NFTRP
, and owner 0x02a152cb1a753a858c950bb710d957e7f3f78d87435831e7fae394f9233e60fa
:
nile deploy NFT_with_reputation 369641944198362097149296 336187380304 0x02a152cb1a753a858c950bb710d957e7f3f78d87435831e7fae394f9233e60fa --network goerli
You can check the deployment status with the starknet tx_status --hash HASH --network=alpha-sepolia
command.
Example:
starknet tx_status --hash 0x635132bdc45cfe7f7f3b458bba7e739043f133ee1315ae1e67da70f7ed53782 --network=alpha-goerli
When you get the ACCEPTED_ON_L2
status, the contract is running on the Starknet network.
REJECTED
statusIf you get the
REJECTED
status, reattempt the transaction and up the suggested network fee when prompted by ArgentX.
Interact with the L2 contract
Mint an NFT:
- Use Voyager to open your deployed contract.
- Click Write Contract.
- Click Connect ArgentX Wallet and make sure you connect with the account address that you provided as the owner when deploying the contract.
- In
mint
, provide a deployed account to mint an NFT to in theto
field and any ID intokenId
. This will be the ID of the minted NFT that you will assign the reputation points to. - Click Transact.
Your NFT is now minted on L2.
L2 -> L1 messaging
Sending a message from L2 to L1 takes much longer than sending a message from L1 to L2.
When sending a message from L2 to L1, Starknet puts messages in a batch and pushes the state onto L1 at different time intervals. It can take 30 minutes for your message to arrive on L1 on testnet.
You can watch state updates live as transactions to the L1 Starknet core contracts:
- Starknet core mainnet: 0xc662c410C0ECf747543f5bA90660f6ABeBD9C8c4
- Starknet core testnet: 0xde29d060D45901Fb19ED6C6e959EB22d8626708e
Now that you have an NFT minted on L2, assign reputation points to it:
- Use Voyager to open your deployed contract.
- Click Write Contract.
- In
rep_up
, provide the ID of your minted NFT innftId
and any integer for reputation points inpoints
. For example, assign1500
. - Click Transact.
Once the transaction is accepted on L2, you can check the assigned reputation points on Read Contract > get_rep
.
Now that you have the points assigned, you will decrease the reputation partially and send the reputation points to L1.
- Use Voyager to open your deployed contract.
- Click Write Contract.
- In
rep_down
, provide the ID of your minted NFT innftId
and any integer for reputation points inpoints
. For example, remove700
points. - Click Transact.
And now you wait. Your transaction will go through the following states in the specified order:
RECEIVED
— the transaction is received by the Starknet node.PENDING
– the transaction is valid and is in a pending block.ACCEPTED_ON_L2
– the transaction is in an accepted block on L2.ACCEPTED_ON_L1
— the transaction is pushed onto L2 as a message and ready to be consumed on L1.
To go from RECEIVED
to ACCEPTED_ON_L1
will take about 30 minutes on average on the testnet and 2 hours on the mainnet.
You can check the deployment status with the starknet tx_status --hash HASH --network=alpha-sepolia
command.
Example of an L2 transaction sending a message to L1 with nftId
: 1
and rep_down
: 700
: 0x6f753d8249a1f94d6ede18e34717cddf35936c1bcce329be599ad6c1ca7afaf.
Example of the message arriving on L1 (#89): 0x56a8e663d0e607fc6eb74b6b7bb713c5acdc262130e7790a8b252c192b41fcc9.
You now have the message in the Starknet core contract on L1 waiting to be consumed by triggering your L1 contract.
L1 -> L2 messaging
Sending a message from L1 to L2 is much faster than sending a message from L2 to L1.
The Starknet network will receive the L1 -> L2 message after a few block confirmations on the L1 network.
First, consume the message on L1:
-
Use Etherscan to open your contract.
-
Click Write Contract > Connect to Web3.
-
In
withdrawToIdle
, provide:l2ContractAddress
— the address of your NFT contract deployed on L2nftId
— the ID of the minted NFT. For this tutorial, it is1
.points
— the number of reputation points you removed from the NFT contract on L2. For this tutorial, it is700
.
-
Click Write.
This will withdraw the reputation points to the idle state—not assigned to the NFT on L2. This constitutes consuming the L2 message on L1.
Now send the L1 -> L2 message—for this tutorial, it is putting the idle reputation points back on L2 for the NFT reputation by calling repUp
on L1.
First, check the current reputation points of the NFT on L2 by calling get_rep
on the L2 contract. If you are following the example numbers in this tutorial, you should get the reputation of 800
for nftId
: 1
on L2.
Now up the reputation by 300
points by sending a message from L1 to L2:
-
Use Etherscan to open your contract.
-
Click Write Contract > Connect to Web3.
-
In
repUp
, provide:l2ContractAddress
— the address of your NFT contract deployed on L2nftId
— the ID of the minted NFT. For this tutorial, it is1
.points
— the number of reputation points you want to put back in the NFT reputation on the L2 contract. For this tutorial, it is300
.
-
Click Write.
After a few blocks on L1, call get_rep
on the L2 contract. You should get the reputation of 1100
for nftId
: 1
on L2.
Congratulations! You have completed the full L1 <-> L2 messaging exercise on Starknet.
Conclusion
This tutorial guided you through the basics of operating on Starknet, using tooling like Nile and OpenZeppelin, implementing your own L1 <-> L2 messaging protocol on the contract level, and actually sending the L1 <-> L2 messages.
As part of the tutorial, you also created an NFT contract that implements a basic reputation system for each of the minted tokens.
This tutorial uses testnet, however, the exact same instructions and sequence work on the mainnet.
About the author
Updated about 2 months ago