The Starknet Book
The Starknet Book is a work in progress, shaped by ongoing community input. Some sections may be incomplete or still under review and are marked under a 🚧 emoji. We welcome your suggestions, feedback, and content contributions to make this book a reliable guide for everyone.
The Starknet Book is a step-by-step guide aimed at teaching you the essentials of Starknet development. It’s a community effort, with each chapter guiding you through the Starknet ecosystem.
Understanding Cairo, the key programming language for Starknet smart contracts, is crucial. That’s why this book works hand-in-hand with the Cairo Book, another community resource. You can access the Cairo Book here.
In short, the Cairo Book helps you master Cairo, while The Starknet Book focuses on Starknet’s specific features. For a well-rounded understanding, we recommend exploring both. This book will introduce you to tools, architecture, account setups, STARKs, and Starknet-specific apps.
Table of Contents
Chapter Titles
Chapter | Description |
---|---|
1: Starknet Introduction | Delve into the fundamental concepts of Starknet and acquaint yourself with the deployment of smart contracts. |
2: Starknet Tooling | Familiarize yourself with vital tools, such as Starkli, Katana, Scarb, Starknet-Foundry and more. Explore how languages like Javascript, Python, and Rust can be leveraged for Starknet interactions. |
3: Starknet Architecture | Uncover Starknet’s core structure, gaining insights into the transaction lifecycle and the interplay between the Sequencer, Prover, and Nodes. |
4: Account Abstraction | Delve deep into Starknet’s unique approach to user accounts, and master the art of crafting custom accounts. |
5: STARKs | Dive into the intricacies of STARKs and their pivotal role in shaping Starknet’s landscape. |
Where to Start?
Depending on your goals and interests, you can choose different paths through the Starknet Book. Here are some recommendations based on various objectives:
-
If you’re a complete beginner and want to start learning about Cairo and Starknet from scratch, follow the book in its entirety, starting with Introduction to Starknet.
-
If you’re an experienced developer looking to quickly dive into writing scalable and decentralized smart contracts, focus on the Cairo Book, particularly chapter 12: Starknet Smart Contracts (link).
-
If you’re a frontend developer wanting to integrate Starknet with a React frontend using Javascript, prioritize the starknet-js and starknet-react subchapters in Starknet Tooling
-
If you’re a DevOps engineer or node operator interested in running a Starknet node and indexer, head straight to Starknet Architecture.
-
If you’re a security researcher or smart contract auditor wanting to learn about the Account Abstraction feature and its implications, go for Account Abstraction.
-
If you’re a blockchain enthusiast curious about the underlying architecture and mechanics of Starknet and Cairo, explore Starknet Architecture.
-
If you’re a cryptography expert or researcher eager to understand the fundamentals of STARKs and their connection to the Starknet ecosystem, delve into STARKs.
Feel free to mix and match these paths based on your unique interests and requirements.
Your Contributions Matter
Welcome aboard! By contributing to the Starknet Book, you’re doing more than sharing expertise—you’re shaping the future of decentralized tech. Let’s build a guide that helps developers unlock Starknet’s potential.
For detailed contribution guidelines, visit the Contributors Guide. Every contribution counts. Your skills and passion will help make this book an invaluable tool.
How You Can Help
-
Found an empty section? Fill it in!
-
Think we need a new section? Suggest one.
-
See room for improvement? Go ahead and tweak it.
-
Want to add code in a new programming language? Go for it.
-
Found a bug? Fix it.
-
Exercises unclear? Add explanations.
-
Show off your favorite Cairo features through new exercises.
Additional Key Educational Resources
We’ve compiled a list of valuable educational resources that will help deepen your understanding and enhance your skills in coding with Cairo and staying abreast with Starknet developments:
-
Starklings: A resource specifically designed to guide you through learning Cairo programming, ensuring that you reach a proficient level. You can access it here.
-
Starknet Community Forum: An online platform where you can engage in discussions about the latest developments in Starknet. Join the conversation here.
-
Starknet Documentation: You can browse through the documentation here.
-
Cairo Documentation: Explore it here.
-
Starknet Developer Telegram (English): A community for English-speaking Starknet developers. This is a great platform for networking, sharing ideas, and troubleshooting together. Join us on Telegram here.
The Starknet Network
Preamble
Historically, societal roles like currency, property rights, and social status titles have been governed by protocols and registries. Their value stems from a widely accepted understanding of their integrity. These functions have predominantly been overseen by centralized entities prone to challenges such as corruption, agency conflicts, and exclusion (Eli Ben-Sasson, Bareli, Brandt, Volokh, 2023).
Satoshi's creation, Bitcoin, introduced a novel approach for these functions, termed an integrity web. This is an infrastructure for societal roles that:
- Is openly described by a public protocol.
- Operates over a wide, inclusive, peer-to-peer network.
- Distributes value fairly and extensively to maintain societal consensus on its integrity.
While Bitcoin addressed monetary functions, Ethereum expanded this to include any function that can be defined by computer programming. Both faced the challenge of balancing scalability with decentralization. These integrity webs have often favored inclusivity over capacity, ensuring even those with limited resources can authenticate the system's integrity. Yet, this means they struggle to meet global demand.
Defining "Blockchain"
In the ever-evolving realm of technology, defining a term as multifaceted as "Blockchain" can be challenging. Based on current understandings and applications, a Blockchain can be characterized by the following three properties (Eli Ben-Sasson, 2023):
- Public Protocol: The foundation of a Blockchain rests upon a protocol that is openly available. This transparency ensures that any interested party can understand its workings, fostering trust and enabling wider adoption.
- Open P2P Network: Instead of relying on a centralized entity, a Blockchain operates over a peer-to-peer (P2P) network. This decentralized approach ensures that operations are distributed across various participants or nodes, making the system more resilient to failures and censorship.
- Value Distribution: Central to the Blockchain's operation is the way it rewards its operators. The system autonomously distributes value in a manner that is wide-ranging and equitable. This incentivization not only motivates participants to maintain the system's integrity but also ensures a broader societal consensus.
While these properties capture the essence of many Blockchains, the term's definition might need refinement as the technology matures and finds new applications. Engaging in continuous dialogue and revisiting definitions will be crucial in this dynamic landscape.
Starknet Definition
Starknet is a Layer-2 network that makes Ethereum transactions faster, cheaper, and more secure using zk-STARKs technology. Think of it as a boosted layer on top of Ethereum, optimized for speed and cost.
Starknet bridges the gap between scalability and broad consensus. It integrates a mathematical framework to navigate the balance between capacity and inclusivity. Its integrity hinges on the robustness of succinct, transparent proofs of computational integrity. This method lets powerful operators enhance Starknet's capacity, ensuring everyone can authenticate Starknet's integrity using universally accessible tools (Eli Ben-Sasson, Bareli, Brandt, Volokh, 2023).
Starknet’s Mission
Starknet’s mission is to allow individuals to freely implement and use any social function they desire.
Starknet’s Values
Starknet's ethos is anchored in core principles (Eli Ben-Sasson, Bareli, Brandt, Volokh, 2023):
-
Lasting Broadness. Starknet continuously resists power consolidation. Key points include:
- Broad power distribution underpins Starknet's legitimacy and must persist across operations and decision-making. While centralized operation may be necessary at times, it should be short-lived.
- Starknet's protocol and governance should always be open and transparent.
- Governance should bolster inclusivity, with a flexible structure that can evolve to ensure enduring inclusivity.
-
Neutrality. Starknet remains impartial to the societal functions it supports.
- The objectives and ethos of functions on Starknet lie with their creators.
- Censorship resistance: Starknet remains agnostic to the nature and meaning of user transactions.
-
Individual Empowerment. At its core, Starknet thrives on a well-informed and autonomous user base. This is achieved by fostering a culture rooted in its core mission and values, with a strong emphasis on education.
Key Features
These are some key features of Starknet:
-
Low Costs: Transactions on Starknet cost less than on Ethereum. Future updates like Volition and EIP 4844 will make it even cheaper.
-
Developer-Friendly: Starknet lets developers easily build decentralized apps using its native language, Cairo.
-
Speed and Efficiency: Upcoming releases aim to make transactions even faster and cheaper.
-
CVM: Thanks to Cairo, Starknet runs on it´s own VM, called Cairo VM (CVM), that allow us to innovate beyond the Ethereum Virtual Machine (EVM) and create a new paradigm for decentralized applications.
Here are some of them:
-
Account Abstraction: Implemented at the protocol level, this facilitates diverse signing schemes while ensuring user security and self-custody of assets.
-
Volition: Will be implemented on testnet during Q4 2023 will allow developers to regulate data availability on Ethereum (L1) or on Starknet (L2). Reducing L1 onchain data can radically reduce costs.
-
Paymaster: Starknet will allow users to choose how to pay for transaction fee, follows the guidelines laid out in EIP 4337 and allows the transaction to specify a specific contract, a Paymaster, to pay for their transaction. Supports gasless transactions, enhancing user accessibility.
Cairo: The Language of Starknet
Cairo is tailor-made for creating STARK-based smart contracts. As Starknet’s native language, it’s central to building scalable and secure decentralized apps. To start learning now, check out the Cairo Book and Starklings.
Inspired by Rust, Cairo lets you write contracts safely and conveniently.
Governance
The Starknet Foundation oversees Starknet’s governance. Its duties include:
-
Managing Starknet’s development and operations
-
Overseeing the Starknet DAO, which enables community involvement
-
Setting rules to maintain network integrity
Our focus is on technical input and debate for improving the protocol. While we value all perspectives, it’s often the technical insights that steer us forward.
Members can influence Starknet by voting on changes. Here’s the process: A new version is tested on the Goerli Testnet. Members then have six days to review it. A Snapshot proposal is made, and the community votes. A majority of YES votes means an upgrade to the Mainnet.
In short, governance is key to Starknet’s evolution.
To propose an improvement, create a SNIP.
SNIP: Starknet Improvement Proposals
SNIP is short for Starknet Improvement Proposal. It’s essentially a blueprint that details proposed enhancements or changes to the Starknet ecosystem. A well-crafted SNIP includes both the technical specifications of the change and the reasons behind it. If you’re proposing a SNIP, it’s your job to rally community support and document any objections (more details here). Once a SNIP is approved, it becomes a part of the Starknet protocol. All the SNIPs can be found in this repository.
SNIPs serve three crucial roles:
-
They are the main avenue for proposing new features or changes.
-
They act as a platform for technical discussions within the community.
-
They document the decision-making process, offering a historical view of how Starknet has evolved.
Because SNIPs are stored as text files in a version-controlled repository, you can easily track changes and understand the history of proposals.
For those who are building on Starknet, SNIPs aren’t just suggestions—they’re a roadmap. It’s beneficial for implementers to keep a list of the SNIPs they’ve executed. This transparency helps users gauge the state of a particular implementation or software library.
Getting Started
Starknet is a scalable Layer-2 solution on Ethereum. This guide will walk you through the process of deploying and interacting with your first Starknet smart contract using the Cairo programming language, a language tailored for creating validity proofs and that Starknet uses. For seasoned developers looking to understand the core concepts and get hands-on experience, this guide offers step-by-step instructions and essential details.
We will use the Starknet Remix Plugin to compile, deploy and interact with our smart contract. It is a great tool to get started with Starknet development.
- Visit The Remix Project.
- Navigate to the ‘Plugins’ section in the bottom left corner.
- Enable the “Starknet” plugin.

Activate the Starknet Plugin
- After enabling, the Starknet logo appears on the left sidebar. Click it to interact with opened Cairo files.
Introduction to Starknet Smart Contracts
The script below is a simple Ownable
contract pattern written in Cairo for Starknet. It features:
- An ownership system.
- A method to transfer ownership.
- A method to check the current owner.
- An event notification for ownership changes.
#![allow(unused)] fn main() { use starknet::ContractAddress; #[starknet::interface] trait OwnableTrait<T> { fn transfer_ownership(ref self: T, new_owner: ContractAddress); fn get_owner(self: @T) -> ContractAddress; } #[starknet::contract] mod Ownable { use super::ContractAddress; use starknet::get_caller_address; #[event] #[derive(Drop, starknet::Event)] enum Event { OwnershipTransferred1: OwnershipTransferred1, } #[derive(Drop, starknet::Event)] struct OwnershipTransferred1 { #[key] prev_owner: ContractAddress, #[key] new_owner: ContractAddress, } #[storage] struct Storage { owner: ContractAddress, } #[constructor] fn constructor(ref self: ContractState, init_owner: ContractAddress) { self.owner.write(init_owner); } #[external(v0)] impl OwnableImpl of super::OwnableTrait<ContractState> { fn transfer_ownership(ref self: ContractState, new_owner: ContractAddress) { self.only_owner(); let prev_owner = self.owner.read(); self.owner.write(new_owner); self.emit(Event::OwnershipTransferred1(OwnershipTransferred1 { prev_owner: prev_owner, new_owner: new_owner, })); } fn get_owner(self: @ContractState) -> ContractAddress { self.owner.read() } } #[generate_trait] impl PrivateMethods of PrivateMethodsTrait { fn only_owner(self: @ContractState) { let caller = get_caller_address(); assert(caller == self.owner.read(), 'Caller is not the owner'); } } } }
Components Breakdown
The following is a brief description of the components in the contract. We will get into more details when we get deeper into Cairo so feel free to skip this section for now if you are not familiar with smart contract development.
- Dependencies and Interface:
starknet::ContractAddress
: Represents a Starknet contract address.OwnableTrait
: Specifies functions for transferring and getting ownership.
- Events:
OwnershipTransferred1
: Indicates ownership change with previous and new owner details.
- Storage:
Storage
: Holds the contract's state with the current owner's address.
- Constructor:
- Initializes the contract with a starting owner.
- External Functions:
- Functions for transferring ownership and retrieving the current owner's details.
- Private Methods:
only_owner
: Validates if the caller is the current owner.
Compilation Process
To compile using Remix:
-
File Creation
- Navigate to the "File Explorer" tab in Remix.
- Create a new file named
Ownable.cairo
and input the previous code.
-
Compilation
- Choose the
Ownable.cairo
file. - In the "Starknet" tab, select "Compile Ownable.cairo".
- Post-compilation, an "artifacts" folder emerges containing the compiled contract in two distinct formats: Sierra (JSON file) and CASM. For Starknet deployment, Remix will use the Sierra file. Do not worry about this process for now; we will cover it in detail in a later chapter. For now, Remix will handle the compilation and deployment for us.
- Choose the

Artifacts folder after compilation
Deployment on the Development Network
To set your smart contract in motion, an initial owner must be defined. The Constructor function needs this information.
Here's a step-by-step guide to deploying your smart contract on the development network:
-
Select the Appropriate Network
- Go to the Environment selection tab.
- Choose "Remote Devnet" for deploying your inaugural contract on a development network.
-
Choose a Devnet Account
- Under "Devnet account selection", a list of accounts specific to the chosen devnet is presented.
- Pick any account and copy its address.
-
Initiating Deployment
- Navigate to the "Starknet" tab.
- Input the copied address into the
init_owner
variable. - Click on "Deploy ownable.cairo".
Post-deployment, Remix's terminal will send various logs. These logs provide crucial details, including:
transaction_hash
: The unique hash of the transaction. This hash can be used to track the transaction's status.contract_address
: The address of the deployed contract. Use this address to interact with your contract.calldata
: Contains theinit_owner
address fed to the constructor.
{
"transaction_hash": "0x275e6d2caf9bc98b47ba09fa9034668c6697160a74de89c4655e2a70be84247",
"contract_address": "0x5eb239955ad4c4333b8ab83406a3cf5970554b60a0d8e78a531df18c59a0db9",
...
"calldata": [
"0x4d9c8282b5633eeb1aab56393690d76f71e32f1b7be1bea03eb03e059245a28"
],
...
}
By following the above process, you successfully deploy your smart contract on the development network.
Interaction with the Contract
With the contract now active on the development network, interaction becomes possible. Here's a guide to effectively interact with your contract on Starknet:
-
Initiating Interaction
- Navigate to the "Starknet" tab.
- Select the "Interact" option.
-
Calling the
get_owner
Function- Choose the
get_owner
function. Since this function doesn't require arguments, the calldata field remains blank. (This is a read function, hence calling it is termed as a "call".) - Press the "get_owner" button. Your terminal will display the result, revealing the owner's address provided during the contract's deployment as calldata for the constructor:
- Choose the
{
"response": {
"result": [
"0x4d9c8282b5633eeb1aab56393690d76f71e32f1b7be1bea03eb03e059245a28"
]
},
"contract": "ownable.cairo",
"function": "get_owner"
}
This call currently doesn't spend gas because the function does not change the state of the contract.
- Invoking the
transfer_ownership
Function
- Now, for the
transfer_ownership
function, which requires the new owner's address as input. - Enter this address into the calldata field. (For this, use any address from the "Devnet account selection" listed in the Environment tab.)
- Click the "transfer_ownership" button. The terminal then showcases the transaction hash indicating the contract's state alteration. Since we are altering the contract's state this typo of interaction is called an "invoke" and needs to be signed by the account that is calling the function.
For these transactions, the terminal logs will exhibit a "status" variable, indicating the transaction's fate. If the status reads "ACCEPTED_ON_L2", the Sequencer has accepted the transaction, pending block inclusion. However, a "REJECTED" status signifies the Sequencer's disapproval, and the transaction won't feature in the upcoming block. More often than not, this transaction gains acceptance, leading to a contract state modification. On calling the get_owner
function again we get this:
{
"response": {
"result": [
"0x20884fd341e11a00b9d31600c332f126f5c3f9ffd2aa93cb43dee9f90176d4f"
]
},
"contract": "ownable.cairo",
"function": "get_owner"
}
You've now adeptly compiled, deployed, and interacted with your inaugural Starknet smart contract. Well done!
Deploying on Starknet Testnet
After testing your smart contract on a development network, it's time to deploy it to the Starknet Testnet. Starknet Testnet is a public platform available for everyone, ideal for testing smart contracts and collaborating with fellow developers.
First you need to create a Starknet account.
Smart Wallet Setup
Before deploying your smart contract to Starknet, you must handle the transaction cost. While deploying to the Starknet Goerli Testnet is free, a smart wallet account is essential. You can set up a smart wallet using either:
Both are reliable Starknet wallets offering enhanced security and accessibility features thanks to the possibilities that the Cairo VM brings, such as Account Abstraction (keep reading the Book for more on this).
- Install the recommended chrome/brave extension for your chosen wallet.
- Follow your wallet provider's instructions to deploy your account.
- Use the Starknet Faucet to fund your account.
- Deploy the account to the network. This usually takes around 10 seconds.
Once set up, you're ready to deploy your smart contracts to the Starknet Testnet.
Deployment and Interaction
- Follow the previous deployment steps.
- In the 'Environment selection' tab, choose 'Wallet Selection'.
- Select your Starknet account and continue with deploying and interacting with your contract.
You can monitor transaction hashes and addresses using any Starknet block explorers like:
These tools provide a visual representation of transactions and contract state alterations. Notably, when you alter the contract ownership using the transfer_ownership
function, the event emitted by the contract appears in the block explorer. It's an effective method to track contract events.
Your Next Steps
Decide your direction from the following choices:
-
Deepen Your Starknet Knowledge: For an extensive grasp of Starknet's inner workings and potential use cases, delve into Chapter 3 of the Starknet Book. This chapter details Starknet’s architectural nuances. Then go ahead from there.
-
Dive into Cairo: If you're more attuned to coding and wish to craft Starknet contracts, then Cairo is essential. It stands as Starknet's core contract language. Begin with Chapters 1-6 of the Cairo Book, ranging from basics in Getting Started to more advanced aspects such as Enums and Pattern Matching. Conclude by navigating to the Starknet Smart Contracts chapter, ensuring you have a well-rounded understanding.
Starknet Tooling
To make the most of this chapter, a basic grasp of the Cairo programming language is advised. We suggest reading chapters 1-6 of the Cairo Book, covering topics from Getting Started to Enums and Pattern Matching. Follow this by studying the Starknet Smart Contracts chapter in the same book. With this background, you’ll be well-equipped to understand the examples presented here.
Today, Starknet provides all essential tools for building decentralized applications (dApps), compatible with multiple languages like JavaScript, Rust, and Python. You can use the Starknet SDK for development. Front-end developers can use Starknet.js with React, while Rust and Python work well for back-end tasks.
We welcome contributors to enhance existing tools or develop new solutions.
In this chapter, you’ll explore:
-
Frameworks: Build using Starknet-Foundry
-
SDKs: Discover multi-language support through Starknet.js, Starknet-rs, Starknet_py, and Caigo
-
Front-end Development: Use Starknet.js and React
-
Testing: Understand testing methods with Starknet-Foundry and the Devnet
By chapter’s end, you’ll have a complete grasp of Starknet’s toolset, enabling efficient dApp development.
Here’s a quick rundown of the tools that could be used for Starknet development and that we’ll cover in this chapter:
-
Scarb: A package manager that compiles your contracts.
-
Starkli: A CLI tool for interacting with the Starknet network.
-
Starknet Foundry: For contract testing.
-
Katana: Creates a local test node.
-
SDKs: starknet.js, Starknet.py, and starknet.rs interface with Starknet using common programming languages.
-
Starknet-react: Builds front-end apps using React.
Installation
This chapter walks you through setting up your Starknet development tools.
Essential tools to install:
-
Starkli - A CLI tool for interacting with Starknet. More tools are discussed in Chapter 2.
-
Scarb - Cairo’s package manager that compiles code to Sierra, a mid-level language between Cairo and CASM.
-
Katana - Katana is a Starknet node, built for local development.
For support or queries, visit our GitHub Issues or contact espejelomar on Telegram.
Starkli Installation
Easily install Starkli using Starkliup, an installer invoked through the command line.
curl https://get.starkli.sh | sh
starkliup
Restart your terminal and confirm installation:
starkli --version
To upgrade Starkli, simply repeat the steps.
Scarb Package Manager Installation
We will get deeper into Scarb later in this chapter. For now, we will go over the installation process.
For macOS and Linux:
curl --proto '=https' --tlsv1.2 -sSf https://docs.swmansion.com/scarb/install.sh | sh
For Windows, follow manual setup in the Scarb documentation.
Restart the terminal and run:
scarb --version
To upgrade Scarb, rerun the installation command.
Katana Node Installation
To install Katana, use the dojoup
installer from the command line:
curl -L https://install.dojoengine.org | bash
dojoup
After restarting your terminal, verify the installation with:
katana --version
To upgrade Katana, rerun the installation command.
You are now set to code in Cairo and deploy to Starknet.
Introduction to Starkli, Scarb and Katana
In this chapter, you’ll learn how to compile, deploy, and interact with a Starknet smart contract written in Cairo using starkli, scarb and katana.
First, confirm that the following commands work on your system. If they don’t, refer to Basic Installation in this chapter.
scarb --version # For Cairo code compilation
starkli --version # To interact with Starknet
katana --version # To declare and deploy on local development
[OPTIONAL] Checking Supported Compiler Versions
If issues arise during the declare or deploy process, ensure that the Starkli compiler version aligns with the Scarb compiler version.
To check the compiler versions Starkli supports, run:
starkli declare --help
You’ll see a list of possible compiler versions under the
--compiler-version
flag.
...
--compiler-version <COMPILER_VERSION>
Statically-linked Sierra compiler version [possible values: [COMPILER VERSIONS]]]
...
Be aware: Scarb's compiler version may not match Starkli’s. To verify Scarb's version:
scarb --version
The output displays the versions for scarb, cairo, and sierra:
scarb <SCARB VERSION>
cairo: <COMPILER VERSION>
sierra: <SIERRA VERSION>
If the versions don't match, consider installing a version of Scarb compatible with Starkli. Browse Scarb's GitHub repo for earlier releases.
To install a specific version, such as 2.3.0
, run:
curl --proto '=https' --tlsv1.2 -sSf https://docs.swmansion.com/scarb/install.sh | sh -s -- -v 2.3.0
Crafting a Starknet Smart Contract
Begin by initiating a Scarb project:
scarb new my_contract
Configure Environment Variables and the Scarb.toml
File
Review the my_contract
project. Its structure appears as:
src/
lib.cairo
.gitignore
Scarb.toml
Amend the Scarb.toml
file to integrate the starknet
dependency and introduce the starknet-contract
target:
[dependencies]
starknet = ">=2.3.0"
[[target.starknet-contract]]
For streamlined Starkli command execution, establish environment variables. Two primary variables are essential:
- One for your account, a pre-funded account on the local development network
- Another for designating the network, specifically the local katana devnet
In the src/
directory, create a .env
file with the following:
export STARKNET_ACCOUNT=katana-0
export STARKNET_RPC=http://0.0.0.0:5050
These settings streamline Starkli command operations.
Declaring Smart Contracts in Starknet
Deploying a Starknet smart contract requires two primary steps:
- Declare the contract's code.
- Deploy an instance of that declared code.
Begin with the src/lib.cairo
file, which provides a foundational template. Remove its contents and insert the following:
#![allow(unused)] fn main() { #[starknet::contract] mod hello { #[storage] struct Storage { name: felt252, } #[constructor] fn constructor(ref self: ContractState, name: felt252) { self.name.write(name); } #[external(v0)] fn get_name(self: @ContractState) -> felt252 { self.name.read() } #[external(v0)] fn set_name(ref self: ContractState, name: felt252) { let previous = self.name.read(); self.name.write(name); } } }
This rudimentary smart contract serves as a starting point.
Compile the contract with the Scarb compiler. If Scarb isn't installed, consult the Installation section.
scarb build
The above command results in a compiled contract under target/dev/
, named "my_contract_hello.contract_class.json
" (check Scarb's subchapter for more details).
Having compiled the smart contract, it's time to declare it with Starkli and katana. First, ensure your project acknowledges the environmental variables:
source .env
Next, launch Katana. In a separate terminal, run (more details in the Katan subchapter):
katana
To declare your contract, execute:
starkli declare target/dev/my_contract_hello.contract_class.json
Facing an "Error: Invalid contract class"? It indicates a version mismatch between Scarb's compiler and Starkli. Refer to the earlier steps to sync the versions. Typically, Starkli supports compiler versions approved by mainnet, even if the most recent Scarb version isn't compatible.
Upon successful command execution, you'll obtain a contract class hash: This unique hash serves as the identifier for your contract class within Starknet. For example:
Class hash declared: 0x00bfb49ff80fd7ef5e84662d6d256d49daf75e0c5bd279b20a786f058ca21418
Consider this hash as the contract class's address.
If you try to declare an already existing contract class, don't fret. Just proceed. You might see:
Not declaring class as its already declared. Class hash:
0x00bfb49ff80fd7ef5e84662d6d256d49daf75e0c5bd279b20a786f058ca21418
Deploying Starknet Smart Contracts
To deploy a smart contract on the katana local devnet, use the following command. It primarily requires:
- Your contract's class hash.
- Constructor arguments your contract needs (in our example, a name of type
felt252
).
Here's the command structure:
starkli deploy \
<CLASS_HASH> \
<CONSTRUCTOR_INPUTS>
Notice the constructor inputs are in felt format. So we need to convert a short string to a felt252 format. We can use the to-cairo-string
command for this:
starkli to-cairo-string <STRING>
In this case, we'll use the string "starknetbook" as the name:
starkli to-cairo-string starknetbook
The output:
0x737461726b6e6574626f6f6b
Now deploy using a class hash and constructor input:
starkli deploy \
0x00bfb49ff80fd7ef5e84662d6d256d49daf75e0c5bd279b20a786f058ca21418 \
0x737461726b6e6574626f6f6b
After running, expect an output similar to:
Deploying class 0x00bfb49ff80fd7ef5e84662d6d256d49daf75e0c5bd279b20a786f058ca21418 with salt 0x054645c0d1e766ddd927b3bde150c0a3dc0081af7fb82160c1582e05f6018794...
The contract will be deployed at address 0x07cdd583619462c2b14532eddb2b169b8f8d94b63bfb5271dae6090f95147a44
Contract deployment transaction: 0x00413d9638fecb75eb07593b5c76d13a68e4af7962c368c5c2e810e7a310d54c
Contract deployed: 0x07cdd583619462c2b14532eddb2b169b8f8d94b63bfb5271dae6090f95147a44
Interacting with Starknet Contracts
Using Starkli, you can interact with smart contracts through two primary methods:
call
: For read-only functions.invoke
: For functions that alter the state.
Reading Data with call
The call
command let's you query contract functions without transacting. For instance, if you want to determine the current contract owner using the get_name
function, which
requires no arguments:
starkli call \
<CONTRACT_ADDRESS> \
get_name
Replace <CONTRACT_ADDRESS>
with the address of your contract. The
command will return the owner’s address, which was initially set during
the contract’s deployment:
[
"0x0000000000000000000000000000000000000000737461726b6e6574626f6f6b"
]
But what is this lengthy output? In Starknet, we use the felt252
data type to represent strings. This can be decoded into its string representation:
starkli parse-cairo-string 0x737461726b6e6574626f6f6b
The result:
starknetbook
Modifying Contract State with invoke
To alter the contract's state, use the invoke
command. For instance, if you want to update the name field in the storage, utilize the set_name
function:
starkli invoke \
<CONTRACT_ADDRESS> \
set_name \
<felt252>
Where:
<CONTRACT_ADDRESS>
is the address of your contract.<felt252>
is the new value for thename
field, in felt252 format.
For example, to update the name to "Omar", first convert the string "Omar" to its felt252 representation:
starkli to-cairo-string Omar
This will return:
0x4f6d6172
Now, proceed with the invoke
command:
starkli invoke 0x07cdd583619462c2b14532eddb2b169b8f8d94b63bfb5271dae6090f95147a44 set_name 0x4f6d6172
Bravo! You've adeptly modified and interfaced with your Starknet contract.
Scarb: The Package Manager
To make the most of this chapter, a basic grasp of the Cairo programming language is advised. We suggest reading chapters 1-6 of the Cairo Book, covering topics from Getting Started to Enums and Pattern Matching. Follow this by studying the Starknet Smart Contracts chapter in the same book. With this background, you’ll be well-equipped to understand the examples presented here.
Scarb is Cairo’s package manager designed for both Cairo and Starknet projects. It handles dependencies, compiles projects, and integrates with tools like Foundry. It is built by the same team that created Foundry for Starknet.
Scarb Workflow
Follow these steps to develop a Starknet contract using Scarb:
-
Initialize: Use
scarb new
to set up a new project, generating aScarb.toml
file and initialsrc/lib.cairo
. -
Code: Add your Cairo code in the
src
directory. -
Dependencies: Add external libraries using
scarb add
. -
Compile: Execute
scarb build
to convert your contract into Sierra code.
Scarb simplifies your development workflow, making it efficient and streamlined.
Installation
Scarb is cross-platform, supporting macOS, Linux, and Windows. For installation, refer to the Chapter 1 setup guide.
Cairo Project Structure
Next, we’ll dive into the key components that make up a Cairo project.
Cairo Packages
Cairo packages, also referred to as "crates" in some contexts, are the building blocks of a Cairo project. Each package must follow several rules:
-
A package must include a
Scarb.toml
file, which is Scarb’s manifest file. It contains the dependencies for your package. -
A package must include a
src/lib.cairo
file, which is the root of the package tree. It allows you to define functions and declare used modules.
Package structures might look like the following case where we have a
package named my_package
, which includes a src
directory with the
lib.cairo
file inside, a snips
directory which in itself a package
we can use, and a Scarb.toml
file in the top-level directory.
my_package/
├── src/
│ ├── module1.cairo
│ ├── module2.cairo
│ └── lib.cairo
├── snips/
│ ├── src/
│ │ ├── lib.cairo
│ ├── Scarb.toml
└── Scarb.toml
Within the Scarb.toml
file, you might have:
[package]
name = "my_package"
version = "0.1.0"
[dependencies]
starknet = ">=2.0.1"
snips = { path = "snips" }
Here starknet and snips are the dependencies of the package. The
starknet
dependency is hosted on the Scarb registry (we do not need to
download it), while the snips
dependency is located in the snips
directory.
Setting Up a Project with Scarb
To create a new project using Scarb, navigate to your desired project directory and execute the following command:
$ scarb new hello_scarb
This command will create a new project directory named hello_scarb
,
including a Scarb.toml
file, a src
directory with a lib.cairo
file
inside, and initialize a new Git repository with a .gitignore
file.
hello_scarb/
├── src/
│ └── lib.cairo
└── Scarb.toml
Upon opening Scarb.toml
in a text editor, you should see something
similar to the code snippet below:
[package]
name = "hello_scarb"
version = "0.1.0"
# See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest.html
[dependencies]
# foo = { path = "vendor/foo" }
Building a Scarb Project
Clear all content in src/lib.cairo
and replace with the following:
// src/lib.cairo
mod hello_scarb;
Next, create a new file titled src/hello_scarb.cairo
and add the
following:
// src/hello_scarb.cairo
use debug::PrintTrait;
fn main() {
'Hello, Scarb!'.print();
}
In this instance, the lib.cairo
file contains a module declaration
referencing hello_scarb, which includes the hello_scarb.cairo
file’s implementation. For more on modules, imports, and the lib.cairo
file, please refer to the subchapter on imports in Chapter
2.
Scarb mandates that your source files be located within the src
directory.
To build (compile) your project from your hello_scarb
directory, use
the following command:
scarb build
This command compiles your project and produces the Sierra code in the
target/dev/hello_scarb.sierra
file. Sierra serves as an intermediate
layer between high-level Cairo and compilation targets such as Cairo
Assembly (CASM). To understand more about Sierra, check out this
article.
To remove the build artifacts and delete the target directory, use the
scarb clean
command.
Adding Dependencies
Scarb facilitates the seamless management of dependencies for your Cairo packages. Here are two methods to add dependencies to your project:
- Edit Scarb.toml File
Open the Scarb.toml file in your project directory and locate the
[dependencies]
section. If it doesn’t exist, add it. To include a
dependency hosted on a Git repository, use the following format:
[dependencies]
alexandria_math = { git = "https://github.com/keep-starknet-strange/alexandria.git" }
For consistency, it’s recommended to pin Git dependencies to specific
commits. This can be done by adding the rev
field with the commit
hash:
[dependencies]
alexandria_math = { git = "https://github.com/keep-starknet-strange/alexandria.git", rev = "81bb93c" }
After adding the dependency, remember to save the file.
- Use the scarb add Command
Alternatively, you can use the scarb add
command to add dependencies
to your project. Open your terminal and execute the following command:
$ scarb add alexandria_math --git https://github.com/keep-starknet-strange/alexandria.git
This command will add the alexandria_math dependency from the specified Git repository to your project.
To remove a dependency, you can use the scarb rm
command.
Once a dependency is added, the Scarb.toml file will be automatically updated with the new dependency information.
Using Dependencies in Your Code
After dependencies are added to your project, you can start utilizing them in your Cairo code.
For example, let’s assume you have added the alexandria_math
dependency. Now, you can import and utilize functions from the
alexandria_math library in your src/hello_scarb.cairo
file:
// src/hello_scarb.cairo
use alexandria_math::fibonacci;
fn main() -> felt252 {
fibonacci::fib(0, 1, 10)
}
In the above example, we import the fibonacci function from the alexandria_math library and utilize it in the main function.
Scarb Cheat Sheet
Here’s a quick cheat sheet of some of the most commonly used Scarb commands:
-
scarb new <project_name>
: Initialize a new project with the given project name. -
scarb build
: Compile your Cairo code into Sierra code. -
scarb add <dependency> --git <repository>
: Add a dependency to your project from a specified Git repository. -
scarb rm <dependency>
: Remove a dependency from your project. -
scarb run <script>
: Run a custom script defined in yourScarb.toml
file.
Scarb is a versatile tool, and this is just the beginning of what you can achieve with it. As you gain more experience in the Cairo language and the Starknet platform, you’ll discover how much more you can do with Scarb.
To stay updated on Scarb and its features, be sure to check the official Scarb documentation regularly. Happy coding!
The Book is a community-driven effort created for the community.
-
If you’ve learned something, or not, please take a moment to provide feedback through this 3-question survey.
-
If you discover any errors or have additional suggestions, don’t hesitate to open an issue on our GitHub repository.
Katana: A Local Node
Katana
is designed to aid in local development.
This creation by the Dojo
team
enables you to perform all Starknet-related activities in a local
environment, thus serving as an efficient platform for development and
testing.
We suggest employing either katana
or starknet-devnet
for testing
your contracts, with the latter discussed in another
subchapter. The starknet-devnet
is a public testnet, maintained by the
Shard Labs team. Both
these tools offer an effective environment for development and testing.
For an example of how to use katana
to deploy and interact with a
contract, see the introduction subchapter of this Chapter or a voting contract example in The Cairo Book.
Understanding RPC in Starknet
Remote Procedure Call (RPC) establishes the communication between nodes in the Starknet network. Essentially, it allows us to interact with a node in the Starknet network. The RPC server is responsible for receiving these calls.
RPC can be obtained from various sources: . To support the
decentralization of the Network, you can use your own local Starknet
node. For ease of access, consider using a provider such as
Infura or
Alchemy to get an RPC client. For
development and testing, a temporary local node such as katana
can be
used.
Getting Started with Katana
To install Katana, use the dojoup
installer from the command line:
curl -L https://install.dojoengine.org | bash
dojoup
After restarting your terminal, verify the installation with:
katana --version
To upgrade Katana, rerun the installation command.
To initialize a local Starknet node, execute the following command:
katana --accounts 3 --seed 0 --gas-price 250
The --accounts
flag determines the number of accounts to be created,
while the --seed
flag sets the seed for the private keys of these
accounts. This ensures that initializing the node with the same seed
will always yield the same accounts. Lastly, the --gas-price
flag
specifies the transaction gas price.
Running the command produces output similar to this:
██╗ ██╗ █████╗ ████████╗ █████╗ ███╗ ██╗ █████╗
██║ ██╔╝██╔══██╗╚══██╔══╝██╔══██╗████╗ ██║██╔══██╗
█████╔╝ ███████║ ██║ ███████║██╔██╗ ██║███████║
██╔═██╗ ██╔══██║ ██║ ██╔══██║██║╚██╗██║██╔══██║
██║ ██╗██║ ██║ ██║ ██║ ██║██║ ╚████║██║ ██║
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝
PREFUNDED ACCOUNTS
==================
| Account address | 0x03ee9e18edc71a6df30ac3aca2e0b02a198fbce19b7480a63a0d71cbd76652e0
| Private key | 0x0300001800000000300000180000000000030000000000003006001800006600
| Public key | 0x01b7b37a580d91bc3ad4f9933ed61f3a395e0e51c9dd5553323b8ca3942bb44e
| Account address | 0x033c627a3e5213790e246a917770ce23d7e562baa5b4d2917c23b1be6d91961c
| Private key | 0x0333803103001800039980190300d206608b0070db0012135bd1fb5f6282170b
| Public key | 0x04486e2308ef3513531042acb8ead377b887af16bd4cdd8149812dfef1ba924d
| Account address | 0x01d98d835e43b032254ffbef0f150c5606fa9c5c9310b1fae370ab956a7919f5
| Private key | 0x07ca856005bee0329def368d34a6711b2d95b09ef9740ebf2c7c7e3b16c1ca9c
| Public key | 0x07006c42b1cfc8bd45710646a0bb3534b182e83c313c7bc88ecf33b53ba4bcbc
ACCOUNTS SEED
=============
0
🚀 JSON-RPC server started: http://0.0.0.0:5050
The output includes the addresses, private keys, and public keys of the created accounts. It also contains the seed used to generate the accounts. This seed can be reused to create identical accounts in future runs. Additionally, the output provides the URL of the JSON-RPC server. This URL can be used to establish a connection to the local Starknet node.
To stop the local Starknet node, simply press Ctrl+C
.
The local Starknet node does not persist data. Hence, once it’s stopped, all data will be erased.
For a practical demonstration of katana
to deploy and interact with a
contract, see Chapter 2’s Voting contract
example.
Testnet Deployment
This chapter guides developers through the process of compiling, deploying, and interacting with a Starknet smart contract written in Cairo on the testnet. Earlier, the focus was on deploying contracts using a local node, Katana. This time, the deployment and interaction target the Starknet testnet.
Ensure the following commands run successfully on your system. If not, see the Basic Installation section:
scarb --version # For Cairo code compilation
starkli --version # To interact with Starknet
Smart Wallet Setup
A smart wallet comprises a Signer and an Account Descriptor. The Signer is a smart contract with a private key for signing transactions, while the Account Descriptor is a JSON file detailing the wallet’s address and public key.
In order for an account to be used as a signer it must be deployed to the appropriate network, Starknet Goerli or mainnet, and funded. For this example we are going to use Goerli Testnet. To deploy your wallet, visit Smart Wallet Setup. Now you’re ready to interact with Starknet smart contracts.
Creating a Signer
The Signer is an essential smart contract capable of signing transactions in Starknet. You’ll need the private key from your smart wallet to create one, from which the public key can be derived.
Starkli enables secure storage of your private key through a keystore file. This encrypted file can be accessed using a password and is generally stored in the default Starkli directory.
First, create the default directory:
mkdir -p ~/.starkli-wallets/deployer
Then generate the keystore file. The signer command contains subcommands
for creating a keystore file from a private key or completely create a
new one. In this tutorial, we’ll use the private key option which is the
most common use case. You need to provide the path to the keystore file
you want to create. You can give any name to the keystore file, you will
likely have several wallets. In this tutorial, we will use the name
my_keystore_ 1.json
.
starkli signer keystore from-key ~/.starkli-wallets/deployer/my_keystore_1.json
Enter private key:
Enter password:
In the private key prompt, paste the private key of your smart wallet. In the password prompt, enter a password of your choice. You will need this password to sign transactions using Starkli.
Export the private key from your Braavos or Argent wallet. For Argent X, you can find it in the "Settings" section → Select your Account → "Export Private Key". For Braavos, you can find it in the "Settings" section → "Privacy and Security" → "Export Private Key".
While knowing the private key of a smart wallet is necessary to sign transactions, it’s not sufficient. We also need to inform Starkli about the signing mechanism employed by our smart wallet created by Braavos or Argent X. Does it use an elliptic curve? If yes, which one? This is the reason why we need an account descriptor file.
[OPTIONAL] The Architecture of the Starknet Signer
This section is optional and is intended for those who want to learn more about the Starknet Signer. If you are not interested in the details, you can skip it.
The Starknet Signer plays an instrumental role in securing your transactions. Let’s demystify what goes on under the hood.
Key Components:
-
Private Key: A 256-bit/32-byte/64-character (ignoring the 0x prefix) hexadecimal key that is the cornerstone of your wallet’s security.
-
Public Key: Derived from the private key, it’s also a 256-bit/32-byte/64-character hexadecimal key.
-
Smart Wallet Address: Unlike Ethereum, the address here is influenced by the public key, class hash, and a salt. Learn more in Starknet Documentation.
To view the details of the previously created keystore file:
cat ~/.starkli-wallets/deployer/my_keystore_1.json
Anatomy of the keystore.json
File:
{
"crypto": {
"cipher": "aes-128-ctr",
"cipherparams": {
"iv": "dba5f9a67456b121f3f486aa18e24db7"
},
"ciphertext": "b3cda3df39563e3dd61064149d6ed8c9ab5f07fbcd6347625e081fb695ddf36c",
"kdf": "scrypt",
"kdfparams": {
"dklen": 32,
"n": 8192,
"p": 1,
"r": 8,
"salt": "6dd5b06b1077ba25a7bf511510ea0c608424c6657dd3ab51b93029244537dffb"
},
"mac": "55e1616d9ddd052864a1ae4207824baac58a6c88798bf28585167a5986585ce6"
},
"id": "afbb9007-8f61-4e62-bf14-e491c30fd09a",
"version": 3
}
-
version
: The version of the smart wallet implementation. -
id
: A randomly generated identification string. -
crypto
: Houses all encryption details.
Inside crypto
:
-
cipher
: Specifies the encryption algorithm used, which in this case is AES-128-CTR.-
AES (Advanced Encryption Standard): A globally accepted encryption standard.
-
128: Refers to the key size in bits, making it a 128-bit key.
-
CTR (Counter Mode): A specific mode of operation for the AES cipher.
-
-
cipherparams
: Contains an Initialization Vector (IV), which ensures that encrypting the same plaintext with the same key will produce different ciphertexts.iv
(Initialization Vector): A 16-byte hex string that serves as a random and unique starting point for each encryption operation.
-
ciphertext
: This is the private key after encryption, securely stored so that only the correct password can reveal it. -
kdf
andkdfparams
: KDF stands for Key Derivation Function. This adds a layer of security by requiring computational work, making brute-force attacks harder.-
dklen
: The length (in bytes) of the derived key. Typically 32 bytes. -
n
: A cost factor representing CPU/memory usage. A higher value means more computational work is needed, thus increasing security. -
p
: Parallelization factor, affecting the computational complexity. -
r
: Block size for the hash function, again affecting computational requirements. -
salt
: A random value that is combined with the password to deter dictionary attacks.
-
-
mac
(Message Authentication Code): This is a cryptographic code that ensures the integrity of the message (the encrypted private key in this case). It is generated using a hash of both the ciphertext and a portion of the derived key.
Creating an Account Descriptor
An Account Descriptor informs Starkli about your smart wallet’s unique
features, such as its signing mechanism. You can generate this
descriptor using Starkli’s fetch
subcommand under the account
command. The fetch
subcommand takes your on-chain wallet address as
input and generates the account descriptor file. The account descriptor
file is a JSON file that contains the details of your smart wallet.
starkli account fetch <SMART_WALLET_ADDRESS> --output ~/.starkli-wallets/deployer/my_account_1.json
After running the command, you’ll see a message like the one below. We’re using a Braavos wallet as an example, but the steps are the same for an Argent wallet.
Account contract type identified as: Braavos
Description: Braavos official proxy account
Downloaded new account config file: ~/.starkli-wallets/deployer/my_account_1.json
In case you face an error like this:
Error: code=ContractNotFound, message="Contract with address {SMART_WALLET_ADDRESS} is not deployed."
It means you probably just created a new wallet and it has not been deployed yet. To accomplish this you have to fund your wallet with tokens and transfer tokens to a different wallet address. After this process, search your wallet address on the Starknet explorer. To see the details, go back to Smart Wallet Setup.
After the acount descriptor file is generated, you can see its details, run:
cat ~/.starkli-wallets/deployer/my_account_1.json
Here’s what a typical descriptor might look like:
{
"version": 1,
"variant": {
"type": "braavos",
"version": 1,
"implementation": "0x5dec330eebf36c8672b60db4a718d44762d3ae6d1333e553197acb47ee5a062",
"multisig": {
"status": "off"
},
"signers": [
{
"type": "stark",
"public_key": "0x49759ed6197d0d385a96f9d8e7af350848b07777e901f5570b3dc2d9744a25e"
}
]
},
"deployment": {
"status": "deployed",
"class_hash": "0x3131fa018d520a037686ce3efddeab8f28895662f019ca3ca18a626650f7d1e",
"address": "0x6dcb489c1a93069f469746ef35312d6a3b9e56ccad7f21f0b69eb799d6d2821"
}
}
Note: The structure will differ if you use an Argent wallet.
Setting up Environment Variables
To simplify Starkli commands, you can set environment variables. Two key variables are crucial: one for the Signer’s keystore file location and another for the Account Descriptor file.
export STARKNET_ACCOUNT=~/.starkli-wallets/deployer/my_account_1.json
export STARKNET_KEYSTORE=~/.starkli-wallets/deployer/my_keystore_1.json
Setting these variables makes running Starkli commands easier and more efficient.
Declaring Smart Contracts in Starknet
Deploying a smart contract on Starknet involves two steps:
- Declare your contract’s code.
- Deploy an instance of the declared code.
To get started, navigate to the src/
directory in the Ownable-Starknet directory
of the Starknet Book repo. The src/lib.cairo
file contains a basic
contract to practice with.
First, compile the contract using the Scarb compiler. If you haven’t installed Scarb, follow the installation guide in the basic instalation section.
scarb build
This creates a compiled contract in target/dev/
as
"contracts_Ownable.sierra.json" (in Chapter 2 of the book we will learn
more details about Scarb).
With the smart contract compiled, we’re ready to declare it using Starkli. Before declaring your contract, decide on an RPC provider.
Choosing an RPC Provider
There are three main options for RPC providers, sorted by ease of use:
-
Starknet Sequencer’s Gateway: The quickest option and it’s the default for Starkli for now. The sequencer gateway is deprecated and will be disabled by StarkWare soon. You’re strongly recommended to use a third-party JSON-RPC API provider like Infura, Alchemy, or Chainstack.
-
Infura or Alchemy: A step up in complexity. You’ll need to set up an API key and choose an endpoint. For Infura, it would look like
https://starknet-goerli.infura.io/v3/<API_KEY>
. Learn more in the Infura documentation. -
Your Own Node: For those who want full control. It’s the most complex but offers the most freedom. Check out Chapter 4 of the Starknet Book or Kasar for setup guides.
In this tutorial, we will use Alchemy. We can set the STARKNET_RPC environment variable to make command invocations easier:
export STARKNET_RPC="https://starknet-goerli.g.alchemy.com/v2/<API_KEY>"
Declaring Your Contract
Run this command to declare your contract using the default Starknet Sequencer’s Gateway:
starkli declare target/dev/contracts_Ownable.contract_class.json
According to the STARKNET_RPC
url, starkli can recognize the target
blockchain network, in this case "goerli", so it is not necessary
explicitly specify it.
Unless you’re working with custom networks where it’s infeasible for
Starkli to detect the right compiler version, you shouldn’t need to
manually choose a version with --network
and --compiler-version
.
If you encounter an "Error: Invalid contract class," it likely means your Scarb’s compiler version is incompatible with Starkli. Follow the steps above to align the versions. Starkli usually supports compiler versions accepted by mainnet, even if Scarb’s latest version is not yet compatible.
After running the command, you’ll receive a contract class hash. This unique hash serves as the identifier for your contract class within Starknet. For example:
Class hash declared: 0x04c70a75f0246e572aa2e1e1ec4fffbe95fa196c60db8d5677a5c3a3b5b6a1a8
You can think of this hash as the contract class’s address. Use a block explorer like StarkScan to verify this hash on the blockchain.
If the contract class you’re attempting to declare already exists, it is ok we can continue. You’ll receive a message like:
Not declaring class as its already declared. Class hash:
0x04c70a75f0246e572aa2e1e1ec4fffbe95fa196c60db8d5677a5c3a3b5b6a1a8
Deploying Smart Contracts on Starknet
To deploy a smart contract, you’ll need to instantiate it on Starknet’s testnet. This process involves executing a command that requires two main components:
-
The class hash of your smart contract.
-
Any constructor arguments that the contract expects.
In our example, the constructor expects an owner address. You can learn more about constructors in Chapter 12 of The Cairo Book.
The command would look like this:
starkli deploy \
<CLASS_HASH> \
<CONSTRUCTOR_INPUTS>
Here’s a specific example with an actual class hash and constructor inputs (as the owner address use the address of your smart wallet so you can invoke the transfer_ownership function later):
starkli deploy \
0x04c70a75f0246e572aa2e1e1ec4fffbe95fa196c60db8d5677a5c3a3b5b6a1a8 \
0x02cdAb749380950e7a7c0deFf5ea8eDD716fEb3a2952aDd4E5659655077B8510
After executing the command and entering your password, you should see output like the following:
Deploying class 0x04c70a75f0246e572aa2e1e1ec4fffbe95fa196c60db8d5677a5c3a3b5b6a1a8 with salt 0x065034b27a199cbb2a5b97b78a8a6a6c6edd027c7e398b18e5c0e5c0c65246b7...
The contract will be deployed at address 0x02a83c32d4b417d3c22f665acbc10e9a1062033b9ab5b2c3358952541bc6c012
Contract deployment transaction: 0x0743de1e233d38c4f3e9fb13f1794276f7d4bf44af9eac66e22944ad1fa85f14
Contract deployed:
0x02a83c32d4b417d3c22f665acbc10e9a1062033b9ab5b2c3358952541bc6c012
The contract is now live on the Starknet testnet. You can verify its status using a block explorer like StarkScan. On the "Read/Write Contract" tab, you’ll see the contract’s external functions.
Interacting with the Starknet Contract
Starkli enables interaction with smart contracts via two primary
methods: call
for read-only functions and invoke
for write functions
that modify the state.
Calling a Read Function
The call
command enables you to query a smart contract function
without sending a transaction. For instance, to find out who the current
owner of the contract is, you can use the get_owner
function, which
requires no arguments.
starkli call \
<CONTRACT_ADDRESS> \
get_owner
Replace <CONTRACT_ADDRESS>
with the address of your contract. The
command will return the owner’s address, which was initially set during
the contract’s deployment:
[
"0x02cdab749380950e7a7c0deff5ea8edd716feb3a2952add4e5659655077b8510"
]
Invoking a Write Function
You can modify the contract’s state using the invoke
command. For
example, let’s transfer the contract’s ownership with the
transfer_ownership
function.
starkli invoke \
<CONTRACT_ADDRESS> \
transfer_ownership \
<NEW_OWNER_ADDRESS>
Replace <CONTRACT_ADDRESS>
with the address of the contract and
<NEW_OWNER_ADDRESS>
with the address you want to transfer ownership
to. If the smart wallet you’re using isn’t the contract’s owner, an
error will appear. Note that the initial owner was set when deploying
the contract:
Execution was reverted; failure reason: [0x43616c6c6572206973206e6f7420746865206f776e6572].
The failure reason is encoded as a felt. o decode it, use the starkli’s
parse-cairo-string
command.
starkli parse-cairo-string <ENCODED_ERROR>
For example, if you see
0x43616c6c6572206973206e6f7420746865206f776e6572
, decoding it will
yield "Caller is not the owner."
After a successful transaction on L2, use a block explorer like
StarkScan or Voyager to confirm the transaction status using the hash
provided by the invoke
command.
To verify that the ownership has successfully transferred, you can call
the get_owner
function again:
starkli call \
<CONTRACT_ADDRESS> \
get_owner
If the function returns the new owner’s address, the transfer was successful.
Congratulations! You’ve successfully deployed and interacted with a Starknet contract.
Starkli: Querying Starknet
Starkli is a Command Line Interface (CLI) tool designed for Starknet interaction, utilizing the capabilities of starknet-rs. This tool simplifies querying and executing transactions on Starknet.
NOTE: Before continuing with this chapter, make sure you have completed the Basic Installation subchapter of Chapter 2. This includes the installation of Starkli.
In the next subchapter we will create a short Bash script using Starkli to query Starknet. It's just an example, however, creating your own Bash scripts to interact with Starknet would be very useful in practice.
Basic Setup
To ensure a smooth start with Starkli, execute the following command on your system. If you encounter any issues, refer to the Basic Installation guide for assistance:
starkli --version # Verifies Starkli installation and interacts with Starknet
Connect to Starknet with Providers
Starkli primarily operates with a JSON-RPC provider. To access a JSON-RPC endpoint, you have several options:
- Use services like Infura or Alchemy for an RPC client.
- Employ a temporary local node like
katana
for development and testing purposes. - Setup your own node.
Interacting with Katana
To start Katana, open a terminal and execute:
katana
To retrieve the chain id from the Katana JSON-RPC endpoint, use the following command:
starkli chain-id --rpc http://0.0.0.0:5050
This command will output:
0x4b4154414e41 (KATANA)
To obtain the latest block number on Katana, run:
starkli block-number --rpc http://0.0.0.0:5050
The output will be:
0
Since katana is a temporary local node and its state is ephemeral, the block number is initially 0. Refer to Introduction to Starkli, Scarb and Katana for further details on changing the state of Katana and observing the block number after commands like starkli declare and starkli deploy.
To declare a contract, execute:
starkli declare target/dev/my_contract_hello.contract_class.json
After declaring, the output will be:
Class hash declared: 0x00bfb49ff80fd7ef5e84662d6d256d49daf75e0c5bd279b20a786f058ca21418
Retrieving the latest block number on Katana again:
starkli block-number
Will result in:
1
Katana logs also reflect these changes:
2023-11-03T04:38:48.712332Z DEBUG server: method="starknet_chainId"
2023-11-03T04:38:48.725133Z DEBUG server: method="starknet_getClass"
2023-11-03T04:38:48.726668Z DEBUG server: method="starknet_chainId"
2023-11-03T04:38:48.741588Z DEBUG server: method="starknet_getNonce"
2023-11-03T04:38:48.744718Z DEBUG server: method="starknet_estimateFee"
2023-11-03T04:38:48.766843Z DEBUG server: method="starknet_getNonce"
2023-11-03T04:38:48.770236Z DEBUG server: method="starknet_addDeclareTransaction"
2023-11-03T04:38:48.779714Z INFO txpool: Transaction received | Hash: 0x352f04ad496761c73806f92c64c267746afcbc16406bd0041ac6efa70b01a51
2023-11-03T04:38:48.782100Z TRACE executor: Transaction resource usage: Steps: 2854 | ECDSA: 1 | L1 Gas: 3672 | Pedersen: 15 | Range Checks: 63
2023-11-03T04:38:48.782112Z TRACE executor: Event emitted keys=[0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9]
2023-11-03T04:38:48.782399Z INFO backend: ⛏️ Block 1 mined with 1 transactions
These logs indicate the receipt of a transaction, gas usage, and the mining of a new block, explaining the increment in block number to 1
.
Before deploying a contract, note that Starkli supports argument resolution, simplifying the input process. For instance, constructor inputs in felt format can be easily passed as str:<String-value>
:
starkli deploy \
0x00bfb49ff80fd7ef5e84662d6d256d49daf75e0c5bd279b20a786f058ca21418 \
str:starknet-book
This command deploys the contract without requiring to-cairo-string
, and a new block is mined as a result.
Interacting with Testnet
To interact with the Testnet, use a third-party JSON-RPC API provider like Infura or Alchemy. With your provider URL, execute the following command to get the latest block number:
starkli block-number --rpc https://starknet-goerli.g.alchemy.com/v2/V0WI...
This command will return a response like:
896360
You can confirm this result by checking Starkscan, where you'll find matching data.
Starkli also streamlines the process of invoking commands. For instance, to transfer 1000 Wei of ETH to address 0x1234, first set up your environment variables:
export STARKNET_ACCOUNT=~/.starkli-wallets/deployer/my_account_1.json
export STARKNET_KEYSTORE=~/.starkli-wallets/deployer/my_keystore_1.json
Then, use the following command for the transfer:
starkli invoke eth transfer <YOUR-ACCOUNT-ADDRESS> u256:1000
You can create your own script to connect to Starknet using Starkli. In the next subchapter we will create a short Bash script.
Example - Starknet Connection Script
This section provides step-by-step instructions to create and run custom bash scripts for Starknet interactions.
Katana Local Node
Description: This script connects to the local StarkNet devnet through Katana, retrieves the current chain ID, the latest block number, and the balance of a specified account.
First, ensure that Katana is running (in terminal 1):
katana
Then, create a file named script_devnet
(in terminal 2):
touch script_devnet
Edit this file with your preferred text editor and insert the following script:
#!/bin/bash
chain=$(starkli chain-id --rpc http://0.0.0.0:5050)
echo "Connected to the Starknet local devnet with chain id: $chain"
block=$(starkli block-number --rpc http://0.0.0.0:5050)
echo "The latest block number on Katana is: $block"
account1="0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973"
balance=$(starkli balance $account1 --rpc http://0.0.0.0:5050)
echo "The balance of account $account1 is: $balance ETH"
Execute the script with:
bash script_devnet
You will see output details from the devnet.
Goerli Testnet
Description: This script connects to the Goerli testnet, reads the latest block number, and retrieves the transaction receipt for a specific transaction hash.
For Goerli testnet interactions, create a file named script_testnet
:
touch script_testnet
Edit the file and paste in this script:
echo "Input your testnet API URL: "
read url
chain=$(starkli chain-id --rpc $url)
echo "Connected to the Starknet testnet with chain id: $chain"
block=$(starkli block-number --rpc $url)
echo "The latest block number on Goerli is: $block"
echo "Input your transaction hash: "
read hash
receipt=$(starkli receipt $hash --rpc $url)
echo "The receipt of transaction $hash is: $receipt"
Run the script:
bash script_testnet
You will need to input a testnet API URL
and a transaction hash
. Example hash: 0x2dd73eb1802aef84e8d73334ce0e5856b18df6626fe1a67bb247fcaaccaac8c.
These are brief examples but you get the idea. You can create custom Bash scripts to customize your interactions with Starknet.
Starknet Devnet
Starknet Devnet is a development network (devnet) implemented in Rust, similar to the Python-based starknet-devnet
.
Installation
starknet devnet rs
can be installed in two ways: using Docker or manually by cloning the repository and running it with Cargo.
Using Docker
To install using Docker, follow the instructions provided here.
Manual Installation (Cloning the Repo)
Prerequisites:
- Rust installation (Rust Install Guide)
Procedure:
- Create a new folder for the project.
- Clone the repository:
git clone git@github.com:0xSpaceShard/starknet-devnet-rs.git
Running
After installation, run Starknet Devnet with the following command:
cargo run
On successful execution, you'll see outputs like predeployed contract addresses, account information, and seed details.
Predeployed FeeToken
Address: 0x49D36570D4E46F48E99674BD3FCC84644DDD6B96F7C741B1562B82F9E004DC7
Class Hash: 0x6A22BF63C7BC07EFFA39A25DFBD21523D211DB0100A0AFD054D172B81840EAF
Predeployed UDC
Address: 0x41A78E741E5AF2FEC34B695679BC6891742439F7AFB8484ECD7766661AD02BF
Class Hash: 0x7B3E05F48F0C69E4A65CE5E076A66271A527AFF2C34CE1083EC6E1526997A69
| Account address | 0x1d11***221c
| Private key | 0xb7***8ee25
| Public key | 0x5d46***76bf10
.
.
.
Predeployed accounts using class with hash: 0x4d07e40e93398ed3c76981e72dd1fd22557a78ce36c0515f679e27f0bb5bc5f
Initial balance of each account: 1000000000000000000000 WEI
Seed to replicate this account sequence: 912753742
Running Options
Using a Seed
The Starknet devnet provides a Seed to replicate this account sequence
feature. This allows you to use a specific seed to access previously used accounts. This functionality is particularly useful when employing tools like sncast
or starkli
for contract interactions, as it eliminates the need to change account information.
To load old accounts using a specific seed, execute the following command:
cargo run -- --seed <SEED>
Example (add any number you prefer):
cargo run -- --seed 912753742
Dumping and Loading Data
The process of dumping and loading data facilitates resuming work from where you left off.
- Dumping Data:
- Data can be dumped either on
exit
or after atransaction
. - In this example, dumping is done on exit into a specified directory. Ensure the directory exists, but not the file.
cargo run -- --dump-on exit --dump-path ./dumps/contract_1
- Loading Data:
- To load data, use the command below. Note that both the directory and the file created by the
dump
command must exist. Also, pass in the seed to avoid issues like 'low account balance'.
cargo run -- --dump-path ./dumps/contract_1 --seed 912753742
For additional options and configurations, refer to the Starknet Devnet documentation. This guide primarily covers the Python-based devnet. However, the main difference for the Rust version is the syntax for flags. For example, use
cargo run -- --port 5006
orcargo run -- --dump-on exit ...
for the Rust Devnet. Other flags can be used in the standard format.
Cross-Version Disclaimer
Be aware that the dumping and loading functionality might not be compatible across different versions of the Devnet. In other words, data dumped from one version of Devnet may not be loadable with another version.
Minting Tokens
To mint tokens, either to an existing address or a new one, use the following command:
curl -d '{"amount":8646000000000, "address":"0x6e...eadf"}' -H "Content-Type: application/json" -X POST http://localhost:5050/mint
Commands compatible with the
sncast
andstarkli
subchapters are also applicable in the Rust Devnet.
Next
In the next subchapter we will use the sncast
tool to interact with the Starknet Devnet in a real world example.
Foundry Cast: Starknet CLI Interaction
Starknet Foundry is a tool designed for testing and developing Starknet contracts. It is an adaptation of the Ethereum Foundry for Starknet, aiming to expedite the development process.
The project consists of two primary components:
- Forge: A testing tool specifically for Cairo contracts. This tool acts as a test runner and boasts features designed to enhance your testing process. Tests are written directly in Cairo, eliminating the need for other programming languages. Additionally, the Forge implementation uses Rust, mirroring Ethereum Foundry's choice of language.
- Cast: This serves as a DevOps tool for Starknet, initially supporting a series of commands to interface with Starknet. In the future, Cast aims to offer deployment scripts for contracts and other DevOps functions.
Cast
Cast provides the Command Line Interface (CLI) for starknet, while Forge addresses testing. Written in Rust, Cast utilizes starknet Rust and integrates with Scarb. This integration allows for argument specification in Scarb.toml
, streamlining the process.
sncast
simplifies interaction with smart contracts, reducing the number of necessary commands compared to using starkli
alone.
In this section, we'll delve into sncast
.
Step 1: Sample Smart Contract
The following code sample is sourced from starknet foundry
. You can find the original here.
#![allow(unused)] fn main() { #[starknet::interface] trait IHelloStarknet<TContractState> { fn increase_balance(ref self: TContractState, amount: felt252); fn get_balance(self: @TContractState) -> felt252; } #[starknet::contract] mod HelloStarknet { #[storage] struct Storage { balance: felt252, } #[external(v0)] impl HelloStarknetImpl of super::IHelloStarknet<ContractState> { fn increase_balance(ref self: ContractState, amount: felt252) { assert(amount != 0, 'amount cannot be 0'); self.balance.write(self.balance.read() + amount); } fn get_balance(self: @ContractState) -> felt252 { self.balance.read() } } } }
Before interacting with this sample smart contract, it's crucial to test its functionality using snforge
to ensure its integrity.
Here are the associated tests:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use learnsncast::IHelloStarknetDispatcherTrait; use snforge_std::{declare, ContractClassTrait}; use super::{IHelloStarknetDispatcher}; #[test] fn call_and_invoke() { // Declare and deploy a contract let contract = declare('HelloStarknet'); let contract_address = contract.deploy(@ArrayTrait::new()).unwrap(); // Create a Dispatcher object for interaction with the deployed contract let dispatcher = IHelloStarknetDispatcher { contract_address }; // Query a contract view function let balance = dispatcher.get_balance(); assert(balance == 0, 'balance == 0'); // Invoke a contract function to mutate state dispatcher.increase_balance(100); // Verify the transaction's effect let balance = dispatcher.get_balance(); assert(balance == 100, 'balance == 100'); } } }
If needed, copy the provided code snippets into the lib.cairo
file of your new scarb project.
To execute tests, follow the steps below:
- Ensure
snforge
is listed as a dependency in yourScarb.toml
file, positioned beneath thestarknet
dependency. Your dependencies section should appear as (make sure to use the latest version ofsnforge
andstarknet
):
starknet = "2.1.0-rc2"
snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "v0.7.1" }
- Run the command:
snforge
Note: Use snforge
for testing instead of the scarb test
command. The tests are set up to utilize functions from snforge_std
. Running scarb test
would cause errors.
Step 2: Setting Up Starknet Devnet
For this guide, the focus is on using starknet-devnet
. If you've been using katana
, please be cautious as there might be inconsistencies. If you haven't configured devnet
, consider following this guide for a quick setup.
To launch starknet devnet
, use the command:
starknet-devnet
Upon successful startup, you should receive a response similar to:
Predeployed FeeToken
Address: 0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7
Class Hash: 0x6a22bf63c7bc07effa39a25dfbd21523d211db0100a0afd054d172b81840eaf
Symbol: ETH
Account #0:
Address: 0x5fd5ef7f4b0e23a44a3670bd84f802f6cc37983c7766d562a8d4d72bb8360ba
Public key: 0x6bd5d1d46a7f603f1106824a3b276fdb52168f55b595ba7ff6b2ded390161cd
Private key: 0xc12927df61303656b3c066e65eda0acc
...
...
...
* Listening on http://127.0.0.1:5050/ (Press CTRL+C to quit)
(Note: The abbreviated ... is just a placeholder for the detailed response. In your actual output, you'll see the full details.)
Now, you have written a smart contract, tested it, and successfully initiated starknet devnet.
Dive into sncast
Let's unpack sncast
.
As a multifunctional tool, the quickest way to discover its capabilities is via the command:
sncast --help
In the output, you'll notice distinct categories: commands
and options
. Each option offers both a concise (short
) and a descriptive (long
) variant.
Tip: While both option variants are useful, we'll prioritize the long form in this guide. This choice aids clarity, especially when constructing intricate commands.
Delving deeper, to understand specific commands such as account
, you can run:
sncast account help
Each account subcommand like add
, create
, and deploy
can be further explored. For instance:
sncast account add --help
The layered structure of sncast
provides a wealth of information right at your fingertips. It's like having dynamic documentation. Make it a habit to explore, and you'll always stay informed.
Step 3: Using sncast
for Account Management
Let's delve into how to use sncast
for interacting with the contract.
By default, starknet devnet
offers several predeployed accounts
. These are accounts already registered with the node, loaded with test tokens (for gas fees and various transactions). Developers can use them directly with any contract
on the local node
(i.e., starknet devnet).
How to Utilize Predeployed Accounts
To employ a predeployed account with the smart contract, execute the account add
command as shown below:
sncast [SNCAST_MAIN_OPTIONS] account add [SUBCOMMAND_OPTIONS] --name <NAME> --address <ADDRESS> --private-key <PRIVATE_KEY>
Although several options can accompany the add
command (e.g., --name, --address, --class-hash, --deployed, --private-key, --public-key, --salt, --add-profile
), we'll focus on a select few for this illustration.
Choose an account from the starknet-devnet
, for demonstration, we'll select account #0
, and execute:
sncast --url http://localhost:5050/rpc account add --name account1 --address 0x5f...60ba --private-key 0xc...0acc --add-profile
Points to remember:
-name
- Mandatory field.-address
- Necessary account address.-private-key
- Private key of the account.-add-profile
- Though optional, it's pivotal. By enablingsncast
to include the account in yourScarb.toml
file, you can manage multiple accounts, facilitating transactions among them when working with your smart contract using sncast.
Now that we have familiarized ourselves with using a predeployed account, let's proceed to add a new account.
Creating and Deploying a New Account to Starknet Devnet
Creating a new account involves a few more steps than using an existing one, but it's straightforward when broken down. Here are the steps:
- Account Creation
To create a new account, use (you can use sncast account create --help
to see the available options):
sncast --url http://localhost:5050/rpc account create --name new_account --class-hash 0x19...8dd6 --add-profile
Wondering where the --class-hash
comes from? It's visible in the output from the starknet-devnet
command under the Predeclared Starknet CLI account section. For example:
Predeclared Starknet CLI account:
Class hash: 0x195c984a44ae2b8ad5d49f48c0aaa0132c42521dcfc66513530203feca48dd6
- Funding the Account
To fund the new account, replace the address in the following command with your new one:
curl -d '{"amount":8646000000000, "address":"0x6e...eadf"}' -H "Content-Type: application/json" -X POST http://127.0.0.1:5050/mint
Note: The amount is specified in the previous command's output.
A successful fund addition will return:
{"new_balance":8646000000000,"tx_hash":"0x48...1919","unit":"wei"}
- Account Deployment
Deploy the account to the starknet devnet
local node to register it with the chain:
sncast --url http://localhost:5050/rpc account deploy --name new_account --max-fee 0x64a7168300
A successful deployment provides a transaction hash. If it doesn't work, revisit your previous steps.
- Setting a Default Profile
You can define a default profile for your sncast
actions. To set one, edit the Scarb.toml
file. To make the new_account
the default profile, find the section [tool.sncast.new_account]
and change it to [tool.sncast]
. This means sncast
will default to using this profile unless instructed otherwise.
Step 4: Declaring and Deploying our Contract
By now, we've arrived at the crucial step of using sncast
to declare and deploy our smart contracts.
Declaring the Contract
Recall that we drafted and tested the contract in Step 1. Here, we'll focus on two actions: building and declaring.
- Building the Contract
Execute the following to build the contract:
scarb build
If you've successfully run tests using snforge
, the scarb build
should operate without issues. After the build completes, a new target
folder will appear at the root of your project.
Within the target
folder, you'll find a dev
sub-folder containing three files: *.casm.json
, *.sierra.json
, and *.starknet_artifacts.json
.
If these files aren't present, it's likely due to missing configurations in your Scarb.toml
file. To address this, append the following lines after dependencies
:
[[target.starknet-contract]]
sierra = true
casm = true
These lines instruct the compiler to produce both sierra
and casm
outputs.
- Declaring the Contract
We will use the sncast declare
command to declare the contract. Here's the format:
sncast declare [OPTIONS] --contract-name <CONTRACT>
Given this, the correct command would be:
sncast --profile account1 declare --contract-name HelloStarknet
Note that we've omitted the --url
option. Why? When using --profile
, as seen here with account1
, it's not necessary. Remember, earlier in this guide, we discussed adding and creating new accounts. You can use either account1
or new_account
and achieve the desired result.
Hint: You can define a default profile for sncast actions. Modify the
Scarb.toml
file to set a default. For example, to makenew_account
the default, find[tool.sncast.new_account]
and change it to[tool.sncast]
. Then, there's no need to specify the profile for each call, simplifying your command to:
sncast declare --contract-name HelloStarknet
The output will resemble:
command: declare
class_hash: 0x20fe30f3990ecfb673d723944f28202db5acf107a359bfeef861b578c00f2a0
transaction_hash: 0x7fbdcca80e7c666f1b5c4522fdad986ad3b731107001f7d8df5f3cb1ce8fd11
Make sure to note the **class hash
as it will be essential in the subsequent step.
Note: If you encounter an error stating Class hash already declared, simply move to the next step. Redeclaring an already-declared contract isn't permissible. Use the mentioned class hash for deployment.
Deploying the Contract
With the contract successfully declared and a class hash
obtained, we're ready to proceed to contract deployment. This step is straightforward. Replace <class-hash>
in the command below with your obtained class hash:
sncast deploy --class-hash 0x20fe30f3990ecfb673d723944f28202db5acf107a359bfeef861b578c00f2a0
Executing this will likely yield:
command: deploy
contract_address: 0x7e3fc427c2f085e7f8adeaec7501cacdfe6b350daef18d76755ddaa68b3b3f9
transaction_hash: 0x6bdf6cfc8080336d9315f9b4df7bca5fb90135817aba4412ade6f942e9dbe60
However, you may encounter some issues, such as:
Error: RPC url not passed nor found in Scarb.toml. This indicates the absence of a default profile in the Scarb.toml
file. To remedy this:
- Add the
-profile
option, followed by the desired profile name, as per the ones you've established. - Alternatively, set a default profile as previously discussed in the "Declaring the Contract" section under "Hint" or as detailed in the "Adding, Creating, and Deploying Account" subsection.
You've successfully deployed your contract with sncast
! Now, let's explore how to interact with it.
Interacting with the Contract
This section explains how to read and write information to the contract.
Invoking Contract Functions
To write to the contract, invoke its functions. Here's a basic overview of the command:
Usage: sncast invoke [OPTIONS] --contract-address <CONTRACT_ADDRESS> --function <FUNCTION>
Options:
-a, --contract-address <CONTRACT_ADDRESS> Address of the contract
-f, --function <FUNCTION> Name of the function
-c, --calldata <CALLDATA> Data for the function
-m, --max-fee <MAX_FEE> Maximum transaction fee (auto-estimated if absent)
-h, --help Show help
To demonstrate, let's invoke the increase_balance
method of our smart contract with a preset default profile. Not every option is always necessary; for instance, sometimes, including the --max-fee
might be essential.
sncast invoke --contract-address 0x7e...b3f9 --function increase_balance --calldata 4
If successful, you'll receive a transaction hash like this:
command: invoke
transaction_hash: 0x33248e393d985a28826e9fbb143d2cf0bb3342f1da85483cf253b450973b638
Reading from the Contract
To retrieve data from the contract, use the sncast call
command. Here's how it works:
sncast call --help
Executing the command displays:
Usage: sncast call [OPTIONS] --contract-address <CONTRACT_ADDRESS> --function <FUNCTION>
Options:
-a, --contract-address <CONTRACT_ADDRESS> Address of the contract (hex format)
-f, --function <FUNCTION> Name of the function to call
-c, --calldata <CALLDATA> Function arguments (list of hex values)
-b, --block-id <BLOCK_ID> Block identifier for the call. Accepts: pending, latest, block hash (with a 0x prefix), or block number (u64). Default is 'pending'.
-h, --help Show help
For instance:
sncast call --contract-address 0x7e...b3f9 --function get_balance
While not all options are used in the example, you might need to include options like --calldata
, specifying it as a list or array.
A successful call returns:
command: call
response: [0x4]
This indicates successful read and write operations on the contract.
sncast Multicall Guide
Use sncast multicall
to simultaneously read and write to the contract. Let's explore how to effectively use this feature.
First, understand its basic usage:
sncast multicall --help
This command displays:
Execute multiple calls
Usage: sncast multicall <COMMAND>
Commands:
run Execute multicall using a .toml file
new Create a template for the multicall .toml file
help Display help for subcommand(s)
Options:
-h, --help Show help
To delve deeper, initiate the new
subcommand:
Generate a template for the multicall .toml file
Usage: sncast multicall new [OPTIONS]
Options:
-p, --output-path <OUTPUT_PATH> File path for saving the template
-o, --overwrite Overwrite file if it already exists at specified path
-h, --help Display help
Generate a template called call1.toml
:
sncast multicall new --output-path ./call1.toml --overwrite
This provides a basic template:
[[call]]
call_type = "deploy"
class_hash = ""
inputs = []
id = ""
unique = false
[[call]]
call_type = "invoke"
contract_address = ""
function = ""
inputs = []
Modify call1.toml
to:
[[call]]
call_type = "invoke"
contract_address = "0x7e3fc427c2f085e7f8adeaec7501cacdfe6b350daef18d76755ddaa68b3b3f9"
function = "increase_balance"
inputs = ['0x4']
[[call]]
call_type = "invoke"
contract_address = "0x7e3fc427c2f085e7f8adeaec7501cacdfe6b350daef18d76755ddaa68b3b3f9"
function = "increase_balance"
inputs = ['0x1']
In multicalls, only deploy
and invoke
actions are allowed. For a detailed guide on these, refer to the earlier section.
Note: Ensure inputs are in hexadecimal format. Strings work normally, but numbers require this format for accurate results.
To execute the multicall, use:
sncast multicall run --path call1.toml
Upon success:
command: multicall run
transaction_hash: 0x1ae4122266f99a5ede495ff50fdbd927c31db27ec601eb9f3eaa938273d4d61
Check the balance:
sncast call --contract-address 0x7e...b3f9 --function get_balance
The response:
command: call
response: [0x9]
The expected balance, 0x9
, is confirmed.
Conclusion
This guide detailed the use of sncast
, a robust command-line tool tailored for starknet smart contracts. Its purpose is to make interactions with starknet's smart contracts effortless. Key functionalities include contract deployment, function invocation, and function calling.
Deployment Script Example
RECOMMENDED: Before starting this chapter, make sure you have completed the Starknet Devnet subchapter.
This tutorial explains how to set up a test and deployment environment for smart contracts. The given script initializes accounts, runs tests, and carries out multicalls.
Disclaimer: This is an example. Use it as a foundation for your own work, adjusting as needed.
Setup
This script supports the following versions or above
scarb 2.3.0 (f306f9a91 2023-10-23)
cairo: 2.3.0 (https://crates.io/crates/cairo-lang-compiler/2.3.0)
sierra: 1.3.0
snforge 0.10.1
sncast 0.10.1
1. Prepare the Script File
- In your project's root folder, create a file named
script.sh
. This will house the script. - Adjust permissions to make the file executable:
chmod +x script.sh
2. Insert the Script
Below is the content for script.sh
. It adheres to best practices for clarity, error management, and long-term support.
Security Note: Using environment variables is safer than hardcoding private keys in your scripts, but they're still accessible to any process on your machine and could potentially be leaked in logs or error messages.
On step 5 declaring, Uncomment according to local devnet you are using either the rust node or python node for declaration to work as expected.
#!/usr/bin/env bash
# Ensure the script stops on first error
set -e
# Global variables
file_path="$HOME/.starknet_accounts/starknet_open_zeppelin_accounts.json"
CONTRACT_NAME="HelloStarknet"
PROFILE_NAME="account1"
MULTICALL_FILE="multicall.toml"
FAILED_TESTS=false
# Addresses and Private keys as environment variables
ACCOUNT1_ADDRESS=${ACCOUNT1_ADDRESS:-"0x7f61fa3893ad0637b2ff76fed23ebbb91835aacd4f743c2347716f856438429"}
ACCOUNT2_ADDRESS=${ACCOUNT2_ADDRESS:-"0x53c615080d35defd55569488bc48c1a91d82f2d2ce6199463e095b4a4ead551"}
ACCOUNT1_PRIVATE_KEY=${ACCOUNT1_PRIVATE_KEY:-"CHANGE_ME"}
ACCOUNT2_PRIVATE_KEY=${ACCOUNT2_PRIVATE_KEY:-"CHANGE_ME"}
# Utility function to log messages
function log_message() {
echo -e "\n$1"
}
# Step 1: Clean previous environment
if [ -e "$file_path" ]; then
log_message "Removing existing accounts file..."
rm -rf "$file_path"
fi
# Step 2: Define accounts for the smart contract
accounts_json=$(cat <<EOF
[
{
"name": "account1",
"address": "$ACCOUNT1_ADDRESS",
"private_key": "$ACCOUNT1_PRIVATE_KEY"
},
{
"name": "account2",
"address": "$ACCOUNT2_ADDRESS",
"private_key": "$ACCOUNT2_PRIVATE_KEY"
}
]
EOF
)
# Step 3: Run contract tests
echo -e "\nTesting the contract..."
testing_result=$(snforge test 2>&1)
if echo "$testing_result" | grep -q "Failure"; then
echo -e "Tests failed!\n"
snforge
echo -e "\nEnsure that your tests are passing before proceeding.\n"
FAILED_TESTS=true
fi
if [ "$FAILED_TESTS" != "true" ]; then
echo "Tests passed successfully."
# Step 4: Create new account(s)
echo -e "\nCreating account(s)..."
for row in $(echo "${accounts_json}" | jq -c '.[]'); do
name=$(echo "${row}" | jq -r '.name')
address=$(echo "${row}" | jq -r '.address')
private_key=$(echo "${row}" | jq -r '.private_key')
account_creation_result=$(sncast --url http://localhost:5050/rpc account add --name "$name" --address "$address" --private-key "$private_key" --add-profile 2>&1)
if echo "$account_creation_result" | grep -q "error:"; then
echo "Account $name already exists."
else
echo "Account $name created successfully."
fi
done
# Step 5: Build, declare, and deploy the contract
echo -e "\nBuilding the contract..."
scarb build
echo -e "\nDeclaring the contract..."
declaration_output=$(sncast --profile "$PROFILE_NAME" --wait declare --contract-name "$CONTRACT_NAME" 2>&1)
if echo "$declaration_output" | grep -q "error: Class with hash"; then
echo "Class hash already declared."
CLASS_HASH=$(echo "$declaration_output" | sed -n 's/.*Class with hash \([^ ]*\).*/\1/p') ## Uncomment this for devnet python
# CLASS_HASH=$(echo "$declaration_output" | sed -n 's/.*StarkFelt("\(.*\)").*/\1/p') ## Uncomment this for devnet rust
else
echo "New class hash declaration."
CLASS_HASH=$(echo "$declaration_output" | grep -o 'class_hash: 0x[^ ]*' | sed 's/class_hash: //')
fi
echo "Class Hash: $CLASS_HASH"
echo -e "\nDeploying the contract..."
deployment_result=$(sncast --profile "$PROFILE_NAME" deploy --class-hash "$CLASS_HASH")
CONTRACT_ADDRESS=$(echo "$deployment_result" | grep -o "contract_address: 0x[^ ]*" | awk '{print $2}')
echo "Contract address: $CONTRACT_ADDRESS"
# Step 6: Create and execute multicalls
echo -e "\nSetting up multicall..."
cat >"$MULTICALL_FILE" <<-EOM
[[call]]
call_type = 'invoke'
contract_address = '$CONTRACT_ADDRESS'
function = 'increase_balance'
inputs = ['0x1']
[[call]]
call_type = 'invoke'
contract_address = '$CONTRACT_ADDRESS'
function = 'increase_balance'
inputs = ['0x2']
EOM
echo "Executing multicall..."
sncast --profile "$PROFILE_NAME" multicall run --path "$MULTICALL_FILE"
# Step 7: Query the contract state
echo -e "\nChecking balance..."
sncast --profile "$PROFILE_NAME" call --contract-address "$CONTRACT_ADDRESS" --function get_balance
# Step 8: Clean up temporary files
echo -e "\nCleaning up..."
[ -e "$MULTICALL_FILE" ] && rm "$MULTICALL_FILE"
echo -e "\nScript completed successfully.\n"
fi
3. Adjust the Bash Path
The line #!/usr/bin/env bash
indicates the path to the bash interpreter. If you require a different version or location of bash, determine its path using:
which bash
Then replace #!/usr/bin/env
bash in the script with the resulting path, such as #!/path/to/your/bash
.
Execution
When running the script, you'll need to provide the environment variables ACCOUNT1_PRIVATE_KEY
and ACCOUNT2_PRIVATE_KEY
.
Example:
ACCOUNT1_PRIVATE_KEY="0x259f4329e6f4590b" ACCOUNT2_PRIVATE_KEY="0xb4862b21fb97d" ./script.sh
Considerations
- The
set -e
directive in the script ensures it exits if any command fails, enhancing the reliability of the deployment and testing process. - Always secure private keys and sensitive information. Keep them away from logs and visible outputs.
- For greater flexibility, consider moving hardcoded values like accounts or contract names to a configuration file. This approach simplifies updates and overall management.
Starknet-js: Javascript SDK
Starknet.js is a JavaScript/TypeScript library designed to connect your website or decentralized application (D-App) to Starknet. It aims to mimic the architecture of ethers.js, so if you are familiar with ethers, you should find Starknet.js easy to work with.

Starknet-js in your dapp
Installation
To install Starknet.js, follow these steps:
- For the latest official release (main branch):
npm install starknet
- To use the latest features (merges in develop branch):
npm install starknet@next
Getting Started
To build an app that users are able to connect to and interact with Starknet, we recommend adding the get-starknet library, which allows you to manage wallet connections.
With these tools ready, there are basically 3 main concepts to know on the frontend: Account, Provider, and Contracts.
Account
We can generally think of the account as the "end user" of a dapp, and some user interaction will be involved to gain access to it.
Think of a dapp where the user connects their browser extension wallet (such as ArgentX or Braavos) - if the user accepts the connection, that gives us access to the account and signer, which can sign transactions and messages.
Unlike Ethereum, where user accounts are Externally Owned Accounts, Starknet accounts are contracts. This might not necessarily impact your dapp’s frontend, but you should definitely be aware of this difference.
async function connectWallet() {
const starknet = await connect();
console.log(starknet.account);
const nonce = await starknet.account.getNonce();
const message = await starknet.account.signMessage(...)
}
The snippet above uses the connect
function provided by get-starknet
to establish a connection to the user wallet. Once connected, we are able to access account methods, such as signMessage
or execute
.
Provider
The provider allows you to interact with the Starknet network. You can think of it as a "read" connection to the blockchain, as it doesn’t allow signing transactions or messages. Just like in Ethereum, you can use a default provider, or use services such as Infura or Alchemy, both of which support Starknet, to create an RPC provider.
By default, the Provider is a sequencer provider.
export const provider = new Provider({
sequencer: {
network: "goerli-alpha",
},
// rpc: {
// nodeUrl: INFURA_ENDPOINT
// }
});
const block = await provider.getBlock("latest"); // <- Get latest block
console.log(block.block_number);
Contracts
Your frontend will likely be interacting with deployed contracts. For each contract, there should be a counterpart on the frontend. To create these instances, you will need the contract’s address and ABI, and either a provider or signer.
const contract = new Contract(abi_erc20, contractAddress, starknet.account);
const balance = await contract.balanceOf(starknet.account.address);
const transfer = await contract.transfer(recipientAddress, amountFormatted);
//or: const transfer = await contract.invoke("transfer", [to, amountFormatted]);
console.log(`Tx hash: ${transfer.transaction_hash}`);
If you create a contract instance with a provider, you’ll be limited to
calling read functions on the contract - only with a signer can you
change the state of the blockchain. However, you are able to connect a
previously created Contract
instance with a new account:
const contract = new Contract(abi_erc20, contractAddress, provider);
contract.connect(starknet.account);
In the snippet above, after
calling the connect
method, it would be possible to call read
functions on the contract, but not before.
Units
If you have previous experience with web3, you know dealing with units requires care, and Starknet is no exception. Once again, the docs are very useful here, in particular this section on data transformation.
Very often you will need to convert Cairo structs (such as Uint256) that are returned from contracts into numbers:
// Uint256 shape:
// {
// type: 'struct',
// low: Uint256.low,
// high: Uint256.high
//
// }
const balance = await contract.balanceOf(address); // <- uint256
const asBN = uint256.uint256ToBN(uint256); // <- uint256 into BN
const asString = asBN.toString(); //<- BN into string
And vice versa:
const amount = 1;
const amountFormatted = {
type: "struct",
...uint256.bnToUint256(amount),
};
There are other helpful utils, besides bnToUint256
and uint256ToBN
,
provided by Starknet.js.
We now have a solid foundation to build a Starknet dapp. However, there are framework specific tools that help us build Starknet dapps, which are covered in chaper 5.
Counter Smart Contract UI Integration
This guide walks readers through integrating a simple counter smart contract with a frontend. By the end of this guide, readers will understand how to:
- Connect the frontend to a smart contract.
- Initiate transactions, such as incrementing or decrementing the counter.
- Read and display data, such as showing the counter value on the frontend.
For a visual walkthrough, do check out the Basecamp frontend session. This comprehensive session delves deeper into the nuances of the concepts we've touched upon, presenting a mix of theoretical explanations and hands-on demonstrations.
Tools Used
- Reactjs: A frontend building framework.
- @argent/get-starknet: A wrapper for starknet.js, aiding interaction with wallet extensions.
- starknet: A JavaScript library for Starknet.
Setting Up the Environment
To begin, clone the project repository:
git clone https://github.com/Darlington02/basecamp-frontend-boilerplate
Then, navigate to the project directory and install necessary packages:
cd basecamp-frontend-boilerplate
npm install
To launch the project, run:
yarn start
In index.js
, several key functions are provided:
// Connect to the blockchain via a wallet provider (argentX or Bravoos)
const connectWallet = async () => {};
// Terminate the connection
const disconnectWallet = async () => {};
// Trigger increment
const increaseCounter = async () => {};
// Trigger decrement
const decreaseCounter = async () => {};
// Retrieve current count
const getCounter = async () => {};
Managing Connection
connectWallet
The connectWallet
function serves as the mechanism to establish a connection to the blockchain through specific wallet providers such as ArgentX or Braavos. It is asynchronous, allowing the use of await
for handling asynchronous tasks.
const connectWallet = async() => {
const connection = await connect({webWalletUrl: "https://web.argent.xyz"});
if (connection && connection.isConnected) {
setConnection(connection);
setAccount(connection.account);
setAddress(connection.selectedAddress);
}
}
- Initiates the connection using the
connect
method from the@argent/get-starknet
library, targeting Starknet. - Upon a successful connection, updates the React component's state with details of the
connection
,account
, andselectedAddress
.
disconnectWallet
The disconnectWallet
function is designed to sever the connection with the web wallet asynchronously. After disconnection, it updates the component's state, resetting connection details.
const disconnectWallet = async() => {
await disconnect();
setConnection(undefined);
setAccount(undefined);
setAddress('');
}
- It utilizes the
disconnect
function, possibly from an external library, and handles the operation asynchronously withawait
. - Post-disconnection, the state of the React component is updated:
setConnection
is set toundefined
.setAccount
is set toundefined
.setAddress
is cleared with an empty string.
EagerlyConnect
The EagerlyConnect
mechanism leverages React's useEffect
hook to initiate a connection to Starknet upon the component's mounting or initial rendering.
useEffect(() => {
const connectToStarknet = async () => {
const connection = await connect({
modalMode: "neverAsk",
webWalletUrl: "https://web.argent.xyz",
});
if (connection && connection.isConnected) {
setConnection(connection);
setAccount(connection.account);
setAddress(connection.selectedAddress);
}
};
connectToStarknet();
}, []);
- Inside the
useEffect
, theconnectToStarknet
function is defined, aiming to establish an asynchronous connection using theconnect
function. Parameters likemodalMode
andwebWalletUrl
are passed to guide the connection process. - If successful in connecting (
connection && connection.isConnected
), the state updates with details of the connection, the account, and the selected address usingsetConnection
,setAccount
, andsetAddress
. - The
connectToStarknet
function is executed immediately after its definition.
Important Refresher on Smart Contract Interactions
For effective interaction with a smart contract on the network, it's crucial to understand key components after establishing a connection. Among these are the contract address
, ABI
, Signer
, and Provider
.
ABI (Application Binary Interface)
ABI is a standardized bridge between two binary program modules. It is essential for:
- Interacting with smart contracts on the blockchain.
- Specifying the structure of functions, events, and variables for software applications.
- Enabling smooth communication with the smart contract, detailing function signatures, input/output types, event formats, and variable types.
- Facilitating invocation of functions and data retrieval from the contract.
Signer
The Signer plays a pivotal role in:
- Signing transactions.
- Authorizing actions on the blockchain.
- Bearing the fees associated with blockchain operations.
Signers are especially linked to write operations that change the state of the blockchain. These operations need cryptographic signing for security and validity.
Provider
The Provider acts as the medium for:
- Communication with the blockchain.
- Creating transactions.
- Fetching data from the blockchain.
To initiate a write transaction, the connected account (signer) must be provided. This signer then signs the transaction, bearing the necessary fee for execution.
Invoking the increment
Function
const increaseCounter = async () => {
try {
const contract = new Contract(contractAbi, contractAddress, account);
await contract.increment();
alert("You successfully incremented the counter!");
} catch (err) {
alert(err.message);
}
};
The increaseCounter
function is crafted to interact with a smart contract and increment a specific counter. Here's a step-by-step breakdown:
- Establishes a new contract instance using the provided contract's ABI, its address, and the connected account. The account is essential since this write transaction alters the contract's state.
- Executes the contract's
increment
method. Theawait
keyword ensures the program pauses until this action completes. - On successful execution, the user receives a confirmation alert indicating the counter's increment.
- In case of any errors during the process, an alert displays the corresponding error message to the user.
Invoking the decrement
Function
const decreaseCounter = async () => {
try {
const contract = new Contract(contractAbi, contractAddress, account);
await contract.decrement();
alert("You successfully decremented the counter!");
} catch (err) {
alert(err.message);
}
};
The decreaseCounter
function is designed to interact with a smart contract and decrement a specific counter. Here's a succinct breakdown of its operation:
- Creates a new contract instance by utilizing the provided contract's ABI, its address, and the connected account. The account is vital as this write transaction modifies the contract's state.
- Initiates the contract's
decrement
method. With the use of theawait
keyword, the program ensures it waits for the decrement action to finalize. - Upon successful execution, the user is notified with an alert indicating the counter's decrement.
- Should any errors arise during the interaction, the user is promptly alerted with the pertinent error message.
Fetching the Current Count with get_current_count
Function
const getCounter = async () => {
const provider = new Provider({
sequencer: { network: constants.NetworkName.SN_MAIN },
});
try {
const mycontract = new Contract(contractAbi, contractAddress, provider);
const num = await mycontract.get_current_count();
setRetrievedValue(num.toString());
} catch (err) {
alert(err.message);
}
};
The getCounter
function is designed to retrieve the current count from a smart contract. Here's a breakdown of its operation:
- Establishes a provider instance, specifying the sequencer network – in this instance, it's set to the
mainnet
throughconstants.NetworkName.SN_MAIN
. - With this provider, it then initiates a contract instance using the provided contract's ABI, its address, and the aforementioned provider.
- The function then invokes the
get_current_count
method of the contract to fetch the current count. This is an asynchronous action, and the program waits for its completion with theawait
keyword. - Once successfully retrieved, the count, which is presumably a number, is converted to a string and stored using the
setRetrievedValue
function. - In the event of any errors during the process, an alert provides the user with the relevant error message.
It's essential to emphasize that while performing read operations, like fetching data from a blockchain network, the function uses the provider. Unlike write operations, which typically require a signer (or an account) for transaction signing, read operations don't mandate such authentication. Thus, in this function, only the provider is specified, and not the signer.
Wrapping It Up: Integrating a Frontend with a Counter Smart Contract
In this tutorial, we review the process of integrating a basic counter smart contract with a frontend application.
Here's a quick recap:
- Establishing Connection: With the
connectWallet
function, we made seamless connections to the blockchain, paving the way for interactions with our smart contract. - Terminating Connection: The
disconnectWallet
function ensures that users can safely terminate their active connections to the blockchain, maintaining security and control. - Interacting with the Smart Contract: Using the
increaseCounter
,decreaseCounter
, andgetCounter
functions, we explored how to:- Initiate transactions
- Adjust the counter value (increment or decrement)
- Fetch data from the blockchain
For a visual walkthrough, do check out the Basecamp frontend session. This comprehensive session delves deeper into the nuances of the concepts we've touched upon, presenting a mix of theoretical explanations and hands-on demonstrations.
ERC-20 UI
This guide offers steps to build an ERC20 smart contract using Cairo and to incorporate it within a React web application with StarknetJS. Readers will:
- Understand how to implement the ERC20 interface
- Learn how to deploy contracts on the Starknet network
- Discover ways to engage with contracts within a React application
- Design their own ERC20 token and initiate it on Starknet
A prerequisite for this guide is a foundational understanding of both the Cairo programming language and StarknetJS. Additionally, ensure Node.js and NPM are installed on the system.
The example will walk through creating an ERC20 token named MKT and crafting a web3 interface for functionalities such as balance verification and token transfer.

Throughout this guide, the following tools and libraries will be utilized:
- Scarb 0.7.0 with Cairo 2.2.0
- Starkli 0.1.9
- Oppenzeppelin libraries v0.7.0
- StarknetJS v5.19.5
- get-starknet v3.0.1
- NodeJS v19.6.1
- Next.js 13.5.5
- Visual Studio Code
- Vercel
Initiating a New Starknet Project
Begin by establishing a new Starknet project named "erc20" using Scarb:
mkdir erc20
cd erc20
scarb init --name erc20
Subsequently, update the Scarb.toml to include the essential OpenZeppelin libraries. Post edits, the Scarb.toml should appear as:
[package]
name = "erc20"
version = "0.1.0"
# For more keys and definitions, visit https://docs.swmansion.com/scarb/docs/reference/manifest.html
[dependencies]
starknet = ">=2.2.0"
openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v0.7.0" }
[[target.starknet-contract]]
Implementing the ERC20 Token
Begin by creating a new file named src/erc20.cairo
. In this file, the ERC20 token named MKT, along with its associated functions, will be defined:
#![allow(unused)] fn main() { #[starknet::contract] mod erc20 { use starknet::ContractAddress; use openzeppelin::token::erc20::ERC20; #[storage] struct Storage {} #[constructor] fn constructor( ref self: ContractState, initial_supply: u256, recipient: ContractAddress ) { let name = 'MyToken'; let symbol = 'MTK'; let mut unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::InternalImpl::initializer(ref unsafe_state, name, symbol); ERC20::InternalImpl::_mint(ref unsafe_state, recipient, initial_supply); } #[external(v0)] #[generate_trait] impl Ierc20Impl of Ierc20 { fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { let unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::ERC20Impl::balance_of(@unsafe_state, account) } fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { let mut unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::ERC20Impl::transfer(ref unsafe_state, recipient, amount) } } } }

Now edit src/lib.cairo
and replace the content with:
#![allow(unused)] fn main() { mod erc20; }

Upon completing your contract, proceed to compile it using Scarb:
scarb build
Subsequent to the compilation, declare the smart contract on the Starknet testnet (using your own account and keystore):
starkli declare target/dev/erc20_erc20.sierra.json --account ../../demo-account.json --keystore ../../demo-key.json --compiler-version 2.1.0 --network goerli-1 --watch
The output should appear similar to:
Enter keystore password:
Declaring Cairo 1 class: 0x04940154eae35788e899ceb0ef2794eaa5ea6818af5c1c726d6d278fd4979713
... [shortened for brevity]
Class hash declared: 0x04940154eae35788e899ceb0ef2794eaa5ea6818af5c1c726d6d278fd4979713
In cases where no modifications have been made to the provided contract, a notification will indicate that the contract has previously been declared on Starknet:
Enter keystore password:
Not declaring class as it's already declared. Class hash: 0x04940154eae35788e899ceb0ef2794eaa5ea6818af5c1c726d6d278fd4979713
Deploying the ERC20 Contract
Proceed to deploy the MKT Token using Starkli. Provide these arguments for successful deployment:
Initial mint
: Mint 1,000,000 tokens. Given that the MKT token comprises 18 decimals (a standard of OpenZeppelin), the input required is 1,000,000 * 10^18 or 0xd3c21bcecceda1000000. Due to the contract's expectation of a u256 mint value, provide both low and high values: 0xd3c21bcecceda1000000 and 0 respectively.Receiver address
: Use a preferred address who wiil be the initial recipient of 1,000,000 MKT. In this example: 0x0334863e3e851de87fb4b6b6113aa2a6b40ea20f22dbec55536e4eac912206fc
starkli deploy 0x04940154eae35788e899ceb0ef2794eaa5ea6818af5c1c726d6d278fd4979713 --account ../../demo-account.json --keystore ../../demo-key.json --network goerli-1 --watch 0xd3c21bcecceda1000000 0 0x0334863e3e851de87fb4b6b6113aa2a6b40ea20f22dbec55536e4eac912206fc
The output should appear similar to:
Enter keystore password:
... [shortened for brevity]
Contract deployed: 0x001892d81e09cb2c2005f0112891dacb92a6f8ce571edd03ed1f3e549abcf37f
NOTE: The deployed address received will differ for every user. Retain this address, as it will replace instances in subsequent TypeScript files to match the specific contract address.
Well done! The Cairo ERC20 smart contract has been deployed successfully on Starknet.
Setting Up a New React Project
With the contract in place, initiate the development of the web application. Begin by
setting up our react project. To do this, Nextjs framework provides the create-next-app
script that streamlines the setup of a Nextjs application:
npx create-next-app@latest erc20_web --use-npm
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … No
Then, you should see something like this:
Creating a new Next.js app in /home/kali/cairo/erc20_web.
Using npm.
Initializing project with template: app-tw
Installing dependencies:
- react
- react-dom
- next
... [shortened for brevity]
Initialized a git repository.
Success! Created erc20_web at /home/kali/cairo/erc20_web
Installing the Starknet.js Library
Now, let's install the starknet.js and recommended get-starknet (manage wallet connections) libraries:
cd erc20_web
npm install get-starknet
You should see something like this:
added 3 packages, changed 1 package, and audited 1549 packages in 7s
... [shortened for brevity]
Run `npm audit` for details.
Install starknetJS:
npm install starknet
You should see something like this:
added 18 packages, and audited 1546 packages in 6s
... [shortened for brevity]
Run `npm audit` for details.
Post-installation, confirm the version of the Starknet.js library:
npm list starknet
npm list get-starknet
The output should display the installed version, such as starknet@5.19.5
and get-starknet@3.0.1
.
Building our Project
Once set up, make modifications to erc20_web/src/app/layout.tsx
by replacing its content with the following code:
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Now, edit erc20_web/src/app/page.tsx
and replace its content with the following code:
import Head from "next/head";
import App from "../components/App";
export default function Home() {
return (
<>
<Head>
<title>Homepage</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<p>A basic web3 example with StarknetJS</p>
<App />
</main>
</>
);
}
Enhancing Your React Application with Additional Features
To enhance the app's functionality, create one component (erc20_web/src/components/App.tsx
) for balance and transfer with the following code.
'use client';
import { useState, useMemo } from "react"
import { connect, disconnect } from "get-starknet"
import { Contract, Provider, SequencerProvider, constants } from "starknet"
const contractAddress = "0x001892d81e09cb2c2005f0112891dacb92a6f8ce571edd03ed1f3e549abcf37f"
function App() {
const [provider, setProvider] = useState({} as Provider)
const [address, setAddress] = useState('')
const [currentBlockHash, setCurrentBlockHash] = useState('')
const [balance, setBalance] = useState('')
const [isConnected, setIsConnected] = useState(false)
const [recipient, setRecipient] = useState('0x');
const [amount, setAmount] = useState('1000000000000000000');
const disconnectWallet = async () => {
try {
await disconnect({ clearLastWallet: true })
setProvider({} as Provider)
setAddress('')
setIsConnected(false)
}
catch (error: any) {
alert(error.message)
}
}
const connectWallet = async () => {
try {
const starknet = await connect()
if (!starknet) throw new Error("Failed to connect to wallet.")
await starknet.enable({ starknetVersion: "v5" })
setProvider(starknet.account)
setAddress(starknet.selectedAddress || '')
setIsConnected(true)
}
catch (error: any) {
alert(error.message)
}
}
const checkBalance = async () => {
try {
// initialize contract using abi, address and provider
const { abi: testAbi } = await provider.getClassAt(contractAddress);
if (testAbi === undefined) { throw new Error("no abi.") };
const contract = new Contract(testAbi, contractAddress, provider)
// make contract call
const data = await contract.balance_of(address)
setBalance(data.toString())
}
catch (error: any) {
alert(error.message)
}
}
const transfer = async () => {
try {
// initialize contract using abi, address and provider
const { abi: testAbi } = await provider.getClassAt(contractAddress);
if (testAbi === undefined) { throw new Error("no abi.") };
const contract = new Contract(testAbi, contractAddress, provider)
// make contract call
await contract.transfer(recipient, amount)
}
catch (error: any) {
alert(error.message)
}
}
const current_block_hash = async () => {
try {
const provider1 = new SequencerProvider({ baseUrl: constants.BaseUrl.SN_GOERLI });
const block = await provider1.getBlock("latest"); // <- Get latest block
setCurrentBlockHash(block.block_hash);
}
catch (error: any) {
alert(error.message)
}
}
current_block_hash()
const shortenedAddress = useMemo(() => {
if (!isConnected) return ''
return `${address.slice(0, 6)}...${address.slice(-4)}`
}, [isConnected, address])
const handleRecipientChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setRecipient(event.target.value);
};
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setAmount(event.target.value);
};
return (
<div>
<p>Latest block hash: {currentBlockHash}</p>
{isConnected ?
<div>
<span>Connected: {shortenedAddress}</span>
<p><button onClick={()=> {disconnectWallet()}}>Disconnect</button></p>
<hr />
<p>Balance.</p>
<p>{balance}</p>
<p><button onClick={() => checkBalance()}>Check Balance</button></p>
<hr />
<p>Transfer.</p>
<p>Recipient:
<input
type="text"
value={recipient}
onChange={handleRecipientChange}
/>
</p>
<p>Amount (default 1 MKT with 18 decimals):
<input
type="number"
value={amount}
onChange={handleAmountChange}
/>
</p>
<p>
<button onClick={() => transfer()}>Transfer</button>
</p>
<hr/>
</div> :
<div>
<span>Choose a wallet:</span>
<p>
<button onClick={() => connectWallet()}>Connect a Wallet</button>
</p>
</div>
}
</div>
);
}
export default App;
Finally, launch the web3 application:
cd erc20_web/
npm run dev
Congratulations, you have your starknetjs web3 application. Now connect your wallet in goerli testnet, check your balance and transfer MKT tokens to your friends:

Deploying Your Project Online
To share your application with friends and allow them to check their balances and transfer tokens, publish your app online. Vercel offers a straightforward way to do this:
Set Up Vercel
- Register for an account at Vercel Signup.
- Install Vercel in your web application folder (
erc20_web
):
cd erc20_web/
npm i -g vercel
- Authenticate your Vercel account:
vercel login
Continue with Email (or select your preferred login method)
After entering your email, check your inbox and click on the "Verify" button.


On successful verification, you'll receive a confirmation in the console.
- Link your project to Vercel:
vercel link
? Set up “~/cairo/erc20_web”? [Y/n] y
? Which scope should contain your project? (just press enter)
? Link to existing project? [y/N] n
? What’s your project’s name? erc20-web
? In which directory is your code located? ./
? Want to modify these settings? [y/N] n
✅ Linked erc20-web (created .vercel)
- Upload it:
vercel
- Publish your project:
vercel --prod
✅ Production: https://erc20-ch3cn791b-devnet0x-gmailcom.vercel.app [1s]
Check your production URL and congratulations! Your MKT token web3 application is now accessible to everyone.

Engage with your app by:
- Connecting your wallet:

- Checking your balance:

- Transferring tokens:

Wrapping Up
Throughout this tutorial, you've walked through the steps to craft a web3 application using React, StarknetJS and Cairo. This application, complete with an ERC20 smart contract, offers a modern web interface for user interaction. Here's a snapshot of your achievements:
-
Project Initialization: Set up a Starknet project with Scarb and incorporated OpenZeppelin libraries.
-
Crafting the ERC20 Contract: Developed an ERC20 token using Cairo, enriched with functionalities like balance checks and token transfers. This was then compiled and launched on the Starknet network.
-
React Application: Built a React application powered by StarknetJS, featuring components dedicated to balance inquiries and token transactions.
-
Online Deployment: Brought your application to a wider audience by deploying it on Vercel. This empowered users to connect their wallets, scrutinize their balances, and execute token transactions.
The insights you've gathered from this tutorial lay a solid groundwork for creating intricate web3 applications. You're now equipped with the prowess to craft more intricate decentralized applications and smart contracts. The vast expanse of decentralized finance and blockchain is ripe for your innovative inputs. Dive in and happy coding!
Starknet-React: React Integration
Several tools exist in the starknet ecosystem to build the front-end for your application. The most popular ones are:
-
starknet-react (documentation): Collection of React hooks for Starknet. It is inspired by wagmi, powered by starknet.js.
-
starknet.js: A JavaScript library for interacting with Starknet contracts. It would be the equivalent of web3.js for Ethereum.
For Vue developers, vue-stark-boil, created by the team at Don’t Panic DAO, is a great option. For a deeper understanding of Vue, visit their website. The vue-stark-boil boilerplate enables various functionalities, such as connecting to a wallet, listening for account changes, and calling a contract.
Authored by the Apibara team, Starknet React is an open-source collection of React providers and hooks meticulously designed for Starknet.
To immerse in the real-world application of Starknet React, we recommend exploring the comprehensive example dApp project at starknet-demo-dapp.
Integrating Starknet React
Embarking on your Starknet React journey necessitates the incorporation of vital dependencies. Let’s start by adding them to your project.
yarn add @starknet-react/core starknet get-starknet
Starknet.js is an essential SDK facilitating interactions with Starknet. In contrast, get-starknet is a package adept at managing wallet connections.
Proceed by swaddling your app within the StarknetConfig
component.
This enveloping action offers a degree of configuration, while
simultaneously providing a React Context for the application beneath to
utilize shared data and hooks. The StarknetConfig
component accepts a
connectors prop, allowing the definition of wallet connection options
available to the user.
const connectors = [
new InjectedConnector({ options: { id: "braavos" } }),
new InjectedConnector({ options: { id: "argentX" } }),
];
return (
<StarknetConfig
connectors={connectors}
autoConnect
>
<App />
</StarknetConfig>
)
Establishing Connection and Managing Account
Once the connectors are defined in the config, the stage is set to use a hook to access these connectors, enabling users to connect their wallets:
export default function Connect() {
const { connect, connectors, disconnect } = useConnectors();
return (
<div>
{connectors.map((connector) => (
<button
key={connector.id}
onClick={() => connect(connector)}
disabled={!connector.available()}
>
Connect with {connector.id}
</button>
))}
</div>
);
}
Observe the disconnect
function that terminates the connection when
invoked. Post connection, access to the connected account is provided
through the useAccount
hook, offering insight into the current state
of connection:
const { address, isConnected, isReconnecting, account } = useAccount();
return (
<div>
{isConnected ? (
<p>Hello, {address}</p>
) : (
<Connect />
)}
</div>
);
The state values, such as isConnected
and isReconnecting
, receive
automatic updates, simplifying UI conditional updates. This convenient
pattern shines when dealing with asynchronous processes, as it
eliminates the need to manually manage the state within your components.
Having established a connection, signing messages becomes a breeze using
the account value returned from the useAccount
hook. For a more
streamlined experience, the useSignTypedData
hook is at your disposal.
const { data, signTypedData } = useSignTypedData(typedMessage)
return (
<>
<p>
<button onClick={signTypedData}>Sign</button>
</p>
{data && <p>Signed: {JSON.stringify(data)}</p>}
</>
)
Starknet React supports signing an array of BigNumberish
values or an
object. While signing an object, it is crucial to ensure that the data
conforms to the EIP712 type. For a more comprehensive guide on signing,
refer to the Starknet.js documentation:
here.
Displaying StarkName
After an account has been connected, the useStarkName
hook can be used
to retrieve the StarkName of this connected account. Related to
Starknet.id it permits to display the user
address in a more user friendly way.
const { data, isError, isLoading, status } = useStarkName({ address });
// You can track the status of the request with the status variable ('idle' | 'error' | 'loading' | 'success')
if (isLoading) return <p>Loading...</p>
return <p>Account: {isError ? address : data}</p>
You also have additional information you can get from this hook → error, isIdle, isFetching, isSuccess, isFetched, isFetchedAfterMount, isRefetching, refetch which can give you more precise information on what is happening.
Fetching address from StarkName
You could also want to retrieve an address corresponding to a StarkName.
For this purpose, you can use the useAddressFromStarkName
hook.
const { data, isLoading, isError } = useAddressFromStarkName({ name: 'vitalik.stark' })
if (isLoading) return <p>Loading...</p>
if (isError) return <p>Something went wrong</p>
return <p>Address: {data}</p>
If the provided name does not have an associated address, it will return "0x0"
Navigating the Network
In addition to wallet and account management, Starknet React equips developers with hooks for network interactions. For instance, useBlock enables the retrieval of the latest block:
const { data, isError, isFetching } = useBlock({
refetchInterval: 10_000,
blockIdentifier: "latest",
});
if (isError) {
return (
<p>Something went wrong</p>
)
}
return (
<p>Current block: {isFetching ? "Loading..." : data?.block_number}<p>
)
In the aforementioned code, refetchInterval controls the frequency of data refetching. Behind the scenes, Starknet React harnesses react-query for managing state and queries. In addition to useBlock, Starknet React offers other hooks like useContractRead and useWaitForTransaction, which can be configured to update at regular intervals.
The useStarknet hook provides direct access to the ProviderInterface:
const { library } = useStarknet();
// library.getClassByHash(...)
// library.getTransaction(...)
Tracking Wallet changes
To improve your dApp User Experience, you can track the user wallet
changes, especially when the user changes the wallet account (or
connects/disconnects). But also when the user changes the network. You
could want to reload correct balances when the user changes the account,
or to reset the state of your dApp when the user changes the network. To
do so, you can use a previous hook we already looked at: useAccount
and a new one useNetwork
.
The useNetwork
hook can provide you with the network chain currently
in use.
const { chain: {id, name} } = useNetwork();
return (
<>
<p>Connected chain: {name}</p>
<p>Connected chain id: {id}</p>
</>
)
You also have additional information you can get from this hook → blockExplorer, testnet which can give you more precise information about the current network being used.
After knowing this you have all you need to track user interaction on
the using account and network. You can use the useEffect
hook to do
some work on changes.
const { chain } = useNetwork();
const { address } = useAccount();
useEffect(() => {
if(address) {
// Do some work when the user changes the account on the wallet
// Like reloading the balances
}else{
// Do some work when the user disconnects the wallet
// Like reseting the state of your dApp
}
}, [address]);
useEffect(() => {
// Do some work when the user changes the network on the wallet
// Like reseting the state of your dApp
}, [chain]);
Contract Interactions
Read Functions
Starknet React presents useContractRead, a specialized hook for invoking read functions on contracts, akin to wagmi. This hook functions independently of the user’s connection status, as read operations do not necessitate a signer.
const { data: balance, isLoading, isError, isSuccess } = useContractRead({
abi: abi_erc20,
address: CONTRACT_ADDRESS,
functionName: "allowance",
args: [owner, spender],
// watch: true <- refresh at every block
});
For ERC20 operations, Starknet React offers a convenient useBalance hook. This hook exempts you from passing an ABI and returns a suitably formatted balance value.
const { data, isLoading } = useBalance({
address,
token: CONTRACT_ADDRESS, // <- defaults to the ETH token
// watch: true <- refresh at every block
});
return (
<p>Balance: {data?.formatted} {data?.symbol}</p>
)
Write Functions
The useContractWrite hook, designed for write operations, deviates slightly from wagmi. The unique architecture of Starknet facilitates multicall transactions natively at the account level. This feature enhances the user experience when executing multiple transactions, eliminating the need to approve each transaction individually. Starknet React capitalizes on this functionality through the useContractWrite hook. Below is a demonstration of its usage:
const calls = useMemo(() => {
// compile the calldata to send
const calldata = stark.compileCalldata({
argName: argValue,
});
// return a single object for single transaction,
// or an array of objects for multicall**
return {
contractAddress: CONTRACT_ADDRESS,
entrypoint: functionName,
calldata,
};
}, [argValue]);
// Returns a function to trigger the transaction
// and state of tx after being sent
const { write, isLoading, data } = useContractWrite({
calls,
});
function execute() {
// trigger the transaction
write();
}
return (
<button type="button" onClick={execute}>
Make a transaction
</button>
)
The code snippet begins by compiling the calldata using the compileCalldata utility provided by Starknet.js. This calldata, along with the contract address and entry point, are passed to the useContractWrite hook. The hook returns a write function that is subsequently used to execute the transaction. The hook also provides the transaction’s hash and state.
A Single Contract Instance
In certain use cases, working with a single contract instance may be preferable to specifying the contract address and ABI in each hook. Starknet React accommodates this requirement with the useContract hook:
const { contract } = useContract({
address: CONTRACT_ADDRESS,
abi: abi_erc20,
});
// Call functions directly on contract
// contract.transfer(...);
// contract.balanceOf(...);
Tracking Transactions
The useTransaction hook allows for the tracking of transaction states given a transaction hash. This hook maintains a cache of all transactions, thereby minimizing redundant network requests.
const { data, isLoading, error } = useTransaction({ hash: txHash });
return (
<pre>
{JSON.stringify(data?.calldata)}
</pre>
)
The full array of available hooks can be discovered in the Starknet React documentation, accessible here: https://apibara.github.io/starknet-react/.
Conclusion
The Starknet React library offers a comprehensive suite of React hooks and providers, purpose-built for Starknet and the Starknet.js SDK. By taking advantage of these well-crafted tools, developers can build robust decentralized applications that harness the power of the Starknet network.
Through the diligent work of dedicated developers and contributors, Starknet React continues to evolve. New features and optimizations are regularly added, fostering a dynamic and growing ecosystem of decentralized applications.
It’s a fascinating journey, filled with innovative technology, endless opportunities, and a growing community of passionate individuals. As a developer, you’re not only building applications, but contributing to the advancement of a global, decentralized network.
Have questions or need help? The Starknet community is always ready to assist. Join the Starknet Discord or explore the StarknetBook’s GitHub repository for resources and support.
ERC-20 UI
This guide offers steps to build an ERC20 smart contract using Cairo and to incorporate it within a React web application with Starknet React. Readers will:
- Understand how to implement the ERC20 interface
- Learn how to deploy contracts on the Starknet network
- Discover ways to engage with contracts within a React application
- Design their own ERC20 token and initiate it on Starknet
A prerequisite for this guide is a foundational understanding of both the Cairo programming language and ReactJS. Additionally, ensure Node.js and NPM are installed on the system.
The example will walk through creating an ERC20 token named MKT and crafting a web3 interface for functionalities such as balance verification and token transfer.

Throughout this guide, the following tools and libraries will be utilized:
- Scarb 0.7.0 with Cairo 2.2.0
- Starkli 0.1.9
- Oppenzeppelin libraries v0.7.0
- Starknet React v1.0.4
- NodeJS v19.6.1
- Next.js 13.1.6
- Visual Studio Code
- Vercel
Initiating a New Starknet Project
Begin by establishing a new Starknet project named "erc20" using Scarb:
mkdir erc20
cd erc20
scarb init --name erc20
Subsequently, update the Scarb.toml to include the essential OpenZeppelin libraries. Post edits, the Scarb.toml should appear as:
[package]
name = "erc20"
version = "0.1.0"
# For more keys and definitions, visit https://docs.swmansion.com/scarb/docs/reference/manifest.html
[dependencies]
starknet = ">=2.2.0"
openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v0.8.0-beta.0" }
[[target.starknet-contract]]
Implementing the ERC20 Token
Begin by creating a new file named src/erc20.cairo
. In this file, the ERC20 token named MKT, along with its associated functions, will be defined:
#![allow(unused)] fn main() { #[starknet::contract] mod erc20 { use starknet::ContractAddress; use openzeppelin::token::erc20::ERC20; #[storage] struct Storage {} #[constructor] fn constructor( ref self: ContractState, initial_supply: u256, recipient: ContractAddress ) { let name = 'MyToken'; let symbol = 'MTK'; let mut unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::InternalImpl::initializer(ref unsafe_state, name, symbol); ERC20::InternalImpl::_mint(ref unsafe_state, recipient, initial_supply); } #[external(v0)] #[generate_trait] impl Ierc20Impl of Ierc20 { fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { let unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::ERC20Impl::balance_of(@unsafe_state, account) } fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { let mut unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::ERC20Impl::transfer(ref unsafe_state, recipient, amount) } } } }

Now edit src/lib.cairo
and replace the content with:
#![allow(unused)] fn main() { mod erc20; }

Upon completing your contract, proceed to compile it using Scarb:
scarb build
Subsequent to the compilation, declare the smart contract on the Starknet testnet:
starkli declare target/dev/erc20_erc20.sierra.json --account ../../demo-account.json --keystore ../../demo-key.json --compiler-version 2.1.0 --network goerli-1 --watch
The output should appear similar to:
Enter keystore password:
Declaring Cairo 1 class: 0x04940154eae35788e899ceb0ef2794eaa5ea6818af5c1c726d6d278fd4979713
... [shortened for brevity]
Class hash declared: 0x04940154eae35788e899ceb0ef2794eaa5ea6818af5c1c726d6d278fd4979713
In cases where no modifications have been made to the provided contract, a notification will indicate that the contract has previously been declared on Starknet:
Enter keystore password:
Not declaring class as it's already declared. Class hash: 0x04940154eae35788e899ceb0ef2794eaa5ea6818af5c1c726d6d278fd4979713
Deploying the ERC20 Contract
Proceed to deploy the MKT Token using Starkli. Provide these arguments for successful deployment:
Initial mint
: Mint 1,000,000 tokens. Given that the MKT token comprises 18 decimals (a standard of OpenZeppelin), the input required is 1,000,000 * 10^18 or 0xd3c21bcecceda1000000. Due to the contract's expectation of a u256 mint value, provide both low and high values: 0xd3c21bcecceda1000000 and 0 respectively.Receiver address
: Use a preferred address. In this example: 0x0334863e3e851de87fb4b6b6113aa2a6b40ea20f22dbec55536e4eac912206fc
starkli deploy 0x04940154eae35788e899ceb0ef2794eaa5ea6818af5c1c726d6d278fd4979713 --account ../../demo-account.json --keystore ../../demo-key.json --network goerli-1 --watch 0xd3c21bcecceda1000000 0 0x0334863e3e851de87fb4b6b6113aa2a6b40ea20f22dbec55536e4eac912206fc
The output should appear similar to:
Enter keystore password:
... [shortened for brevity]
Contract deployed: 0x001892d81e09cb2c2005f0112891dacb92a6f8ce571edd03ed1f3e549abcf37f
NOTE: The deployed address received will differ for every user. Retain this address, as it will replace instances in subsequent TypeScript files to match the specific contract address.
Well done! The Cairo ERC20 smart contract has been deployed successfully on Starknet.
Installing the Starknet React Library
With the contract in place, initiate the development of the web application. Begin by incorporating the Starknet React library:
npm add @starknet-react/core
Post-installation, confirm the version of the Starknet React library:
npm list @starknet-react/core
The output should display the installed version, such as @starknet-react/core@1.0.4
.
Setting Up a New React Project
Starknet React library provides the create-starknet
script that streamlines the setup of a Starknet application using TypeScript:
npx create-starknet erc20_web --use-npm
Once set up, make modifications to erc20_web/index.tsx
by replacing its content with the following code:
import Head from 'next/head'
import { useBlock } from '@starknet-react/core'
import WalletBar from '../components/WalletBar'
import { BlockTag } from 'starknet';
export default function Home() {
const { data, isLoading, isError } = useBlock({
refetchInterval: 3000,
blockIdentifier: BlockTag.latest,
})
return (
<>
<Head>
<title>Create Starknet</title>
<meta name="description" content="Generated by create-starknet" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<p>
A basic web3 example with Starknet
</p>
<div>
{isLoading
? 'Loading...'
: isError
? 'Error while fetching the latest block hash'
: `Latest block hash: ${data?.block_hash}`}
</div>
<WalletBar />
</main>
</>
)
}
To launch the web3 application:
cd erc20_web/
npm run dev
NOTE: Observe the server port that appears during launch. This will be useful for subsequent testing.
Enhancing Your React Application with Additional Features
To enhance the app's functionality, create two components for balance and transfer. Subsequently, update the Wallet.tsx
file to incorporate the new features:

Balance Component
Design a balance component inside components/Balance.tsx
and integrate the following code:
import { useAccount, useContractRead } from "@starknet-react/core";
import erc20ABI from '../assets/erc20.json';
function Balance() {
const { address } = useAccount();
const { data, isLoading, error, refetch } = useContractRead({
address: '0x001892d81e09cb2c2005f0112891dacb92a6f8ce571edd03ed1f3e549abcf37f',
abi: erc20ABI,
functionName: 'balance_of',
args: [address],
watch: false
});
if (isLoading) return <span>Loading...</span>;
if (error) return <span>Error: {JSON.stringify(error)}</span>;
return (
<div>
<p>Balance:</p>
<p>{data?data.toString(): 0}</p>
<p><button onClick={refetch}>Refresh Balance</button></p>
<hr/>
</div>
);
}
export default Balance;
NOTE: Replace the address with the address of your deployed contract.
Transfer Component
Craft a transfer component in components/Transfer.tsx
and embed the subsequent code:
import { useAccount, useContractWrite } from "@starknet-react/core";
import React, { useState, useMemo } from "react";
function Transfer() {
const { address } = useAccount();
const [count] = useState(1);
const [recipient, setRecipient] = useState('0x');
const [amount, setAmount] = useState('1000000000000000000');
const calls = useMemo(() => {
const tx = {
contractAddress: '0x001892d81e09cb2c2005f0112891dacb92a6f8ce571edd03ed1f3e549abcf37f',
entrypoint: 'transfer',
calldata: [recipient, amount, 0]
};
return Array(count).fill(tx);
}, [address, count, recipient, amount]);
const { write } = useContractWrite({ calls });
return (
<>
<p>Transfer:</p>
<p>
Recipient:
<input type="text" value={recipient} onChange={(e) => setRecipient(e.target.value)} />
</p>
<p>
Amount (default: 1 MKT with 18 decimals):
<input type="number" value={amount} onChange={(e) => setAmount(e.target.value)} />
</p>
<p><button onClick={() => write()}>Execute Transfer</button></p>
<hr/>
</>
);
}
export default Transfer;
NOTE: Replace contractAddress with the address of your deployed contract.
Updating the Wallet Component
Proceed to modify the components/Wallet.tsx
file. Replace any existing content with the following enhanced code:
import { useAccount, useConnectors } from '@starknet-react/core'
import { useMemo } from 'react'
import Balance from '../components/Balance'
import Transfer from '../components/Transfer'
function WalletConnected() {
const { address } = useAccount();
const { disconnect } = useConnectors();
const shortenedAddress = useMemo(() => {
if (!address) return '';
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}, [address]);
return (
<div>
<span>Connected: {shortenedAddress}</span>
<p><button onClick={disconnect}>Disconnect</button></p>
<hr/>
<Balance />
<Transfer />
</div>
);
}
function ConnectWallet() {
const { connectors, connect } = useConnectors();
return (
<div>
<span>Select a wallet:</span>
<p>
{connectors.map((connector) => (
<button key={connector.id} onClick={() => connect(connector)}>
{connector.id}
</button>
))}
</p>
</div>
);
}
export default function WalletBar() {
const { address } = useAccount();
return address ? <WalletConnected /> : <ConnectWallet />;
}
This updated code refines the Wallet component to offer a more interactive experience for users intending to connect or manage their wallets.
Finalizing the MKT Token Application
To finalize the application setup, we need the ABI file for the MKT token. Follow the steps below to generate and integrate it:
- At the root of your project, create a new directory named
assets/
. - Inside the
assets/
directory, create an empty JSON file namederc20.json
. - Go back to your ERC20 Cairo project folder and locate the
erc20/target/erc20_erc20_sierra.json
file.

- Extract the ABI definition (ensuring you include the square brackets) and integrate it into the previously created
assets/erc20.json
file.

Well done! The basic MKT token application is now operational locally. Access it via http://localhost:3000
or the port noted from earlier server setup. The app allows users to connect their wallets, review their balances, and perform token transfers.

Deploying Your Project Online
To share your application with friends and allow them to check their balances and transfer tokens, publish your app online. Vercel offers a straightforward way to do this:
Set Up Vercel
- Register for an account at Vercel Signup.
- Install Vercel in your web application folder (
erc20_web
):
cd erc20_web/
npm i -g vercel
vercel init
- Authenticate your Vercel account:
vercel login
After entering your email, check your inbox and click on the "Verify" button.


On successful verification, you'll receive a confirmation in the console.
- Link your project to Vercel:
vercel link
- Upload it:
vercel
- Publish your project:
vercel --prod
Congratulations! Your MKT token web3 application is now accessible to everyone.

Engage with your app by:
- Connecting your wallet:

- Checking your balance:

- Transferring tokens:

Wrapping Up
Throughout this tutorial, you've walked through the steps to craft a web3 application using React and Starknet Cairo. This application, complete with an ERC20 smart contract, offers a modern web interface for user interaction. Here's a snapshot of your achievements:
-
Project Initialization: Set up a Starknet project with Scarb and incorporated OpenZeppelin libraries.
-
Crafting the ERC20 Contract: Developed an ERC20 token using Cairo, enriched with functionalities like balance checks and token transfers. This was then compiled and launched on the Starknet network.
-
React Application: Built a React application powered by Starknet React, featuring components dedicated to balance inquiries and token transactions.
-
ABI Creation: Produced the ABI for the MKT token, a critical component to liaise with the contract.
-
Online Deployment: Brought your application to a wider audience by deploying it on Vercel. This empowered users to connect their wallets, scrutinize their balances, and execute token transactions.
The insights you've gathered from this tutorial lay a solid groundwork for creating intricate web3 applications. You're now equipped with the prowess to craft more intricate decentralized applications and smart contracts. The vast expanse of decentralized finance and blockchain is ripe for your innovative inputs. Dive in and happy coding!
Million Dollar Homepage
Starknet Homepage is a decentralized application on the Starknet blockchain. It provides a virtual space where users can claim and personalize sections of a 100x100 grid, known as "Starknet Homepage". Each section is a 10x10 pixel area. Users can acquire these sections by minting non-fungible tokens (NFTs) and then personalizing them with images and other content.
View the live app on testnet here.

This initiative is an adaptation of the renowned Million Dollar Homepage and was conceived at the Starknet Summit 2023 Hacker House in Palo Alto, California. The following is a guide to understanding how this project was developed using the available tools in the ecosystem.
Tools Utilized:
Initial Setup
The Starknet-react
app offers a command to initialize a Starknet app. This command sets up the foundational structure needed for a NextJS application.
npx create-starknet
The StarknetConfig
component accepts a connectors
prop, which defines wallet connection options for the user. Additionally, it can take a defaultProvider
to set the network the application should connect to by default.
const connectors = [
new InjectedConnector({ options: { id: "braavos" } }),
new InjectedConnector({ options: { id: "argentX" } }),
];
const provider = new Provider({
sequencer: { network: constants.NetworkName.SN_GOERLI },
});
return (
<StarknetConfig
autoConnect
defaultProvider={provider}
connectors={connectors}
>
<CacheProvider value={emotionCache}>
<ThemeProvider theme={theme}>
<Component {...pageProps} />
</ThemeProvider>
</CacheProvider>
</StarknetConfig>
);
Both CacheProvider
and ThemeProvider
are components that facilitate the seamless integration of MaterialUI with NextJS. For a comprehensive setup guide on these components, please refer to this link.
Main Functionality
The core functionality of the Starknet Homepage centers around selecting a 4-sided region on a matrix, representing the desired 10x10 cells, and minting a token based on those cells. The responsibility of the smart contract is to validate whether the selected cells are available for minting. If a user owns Starknet Homepage tokens, they can access a dropdown to modify the token's content, including the associated image and link on the grid.
The app's primary requirements are:
- Wallet connectivity
- Grid for displaying existing tokens
- Cell selection capability
- Multicall function for token approval and minting
- Dropdown to view owned tokens
- On-chain representation of the entire 1 million pixel grid
A significant aspect to consider is the string limitation in Cairo contracts. To store links of varying sizes, they are stored as arrays of felt252
s. The contract uses the following logic for this purpose:
#![allow(unused)] fn main() { impl StoreFelt252Array of Store<Array<felt252>> { fn read(address_domain: u32, base: StorageBaseAddress) -> SyscallResult<Array<felt252>> { StoreFelt252Array::read_at_offset(address_domain, base, 0) } fn write( address_domain: u32, base: StorageBaseAddress, value: Array<felt252> ) -> SyscallResult<()> { StoreFelt252Array::write_at_offset(address_domain, base, 0, value) } fn read_at_offset( address_domain: u32, base: StorageBaseAddress, mut offset: u8 ) -> SyscallResult<Array<felt252>> { let mut arr: Array<felt252> = ArrayTrait::new(); // Read the stored array's length. If the length is superior to 255, the read will fail. let len: u8 = Store::<u8>::read_at_offset(address_domain, base, offset) .expect('Storage Span too large'); offset += 1; // Sequentially read all stored elements and append them to the array. let exit = len + offset; loop { if offset >= exit { break; } let value = Store::<felt252>::read_at_offset(address_domain, base, offset).unwrap(); arr.append(value); offset += Store::<felt252>::size(); }; Result::Ok(arr) } fn write_at_offset( address_domain: u32, base: StorageBaseAddress, mut offset: u8, mut value: Array<felt252> ) -> SyscallResult<()> { // // Store the length of the array in the first storage slot. 255 of elements is max let len: u8 = value.len().try_into().expect('Storage - Span too large'); Store::<u8>::write_at_offset(address_domain, base, offset, len); offset += 1; // Store the array elements sequentially loop { match value.pop_front() { Option::Some(element) => { Store::<felt252>::write_at_offset(address_domain, base, offset, element); offset += Store::<felt252>::size(); }, Option::None => { break Result::Ok(()); } }; } } fn size() -> u8 { 255 / Store::<felt252>::size() } } }
The storage method for links in the contract state is structured as:
#![allow(unused)] fn main() { struct Cell { token_id: u256, xpos: u8, ypos: u8, width: u8, height: u8, img: Array<felt252>, link: Array<felt252>, } }
The OpenZeppelin Cairo Contracts library played a crucial role in speeding up the development of the ERC721 contract for Starknet Homepage. You can find the contract for review here. Once you have installed the library, you can refer to the following example for typical usage:
#![allow(unused)] fn main() { #[starknet::contract] mod MyToken { use starknet::ContractAddress; use openzeppelin::token::erc20::ERC20; #[storage] struct Storage {} #[constructor] fn constructor( ref self: ContractState, initial_supply: u256, recipient: ContractAddress ) { let name = 'MyToken'; let symbol = 'MTK'; let mut unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::InternalImpl::initializer(ref unsafe_state, name, symbol); ERC20::InternalImpl::_mint(ref unsafe_state, recipient, initial_supply); } #[external(v0)] fn name(self: @ContractState) -> felt252 { let unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::ERC20Impl::name(@unsafe_state) } ... } }
Component Logic
Grid
The Grid component represents a 100x100 matrix, with each cell being 100 pixels. This layout corresponds to the data structure found in the smart contract. To showcase the tokens already minted on the Homepage, the app employs a React Hook from starknet-react
to invoke the getAllTokens
function from the contract.
const [allNfts, setAllNfts] = useState<any[]>([]);
const { data, isLoading } = useContractRead({
address: STARKNET_HOMEPAGE_ERC721_ADDRESS,
functionName: "getAllTokens",
abi: starknetHomepageABI,
args: [],
});
useEffect(() => {
if (!isLoading) {
const arr = data?.map((nft) => {
return deserializeTokenObject(nft);
});
setAllNfts(arr || []);
}
}, [data, isLoading]);
Deserialization ensures the data from the Starknet contract is aptly transformed for frontend use. This process involves decoding the array of felt252
s into extensive strings.
import { shortString, num } from "starknet";
const deserializeFeltArray = (arr: any) => {
return arr
.map((img: bigint) => {
return shortString.decodeShortString(num.toHex(img));
})
.join("");
};
...
img: deserializeFeltArray(tokenObject.img),
link: deserializeFeltArray(tokenObject.link),
...
Furthermore, the Grid component manages the cell selection process, leading to the minting of a corresponding token. Once an area is chosen, a modal appears displaying the mint details and other necessary inputs for the call data. The intricacies of the multicall will be addressed subsequently.

Modals
Modals offer a convenient means to present varied functionalities within the app, such as wallet connection, token minting, and token editing.

A recognized best practice is to invoke the React hook for shared information at a top-level, ensuring components like the WalletBar
remain streamlined and focused.
const { address } = useAccount();
return (
...
<WalletBar account={address} />
...
)
Below, the WalletConnected
function displays the connected wallet's address, while the ConnectWallet
function allows users to select and connect their wallet. The WalletBar
function renders the appropriate modal based on the connection status.
function WalletConnected({ address }: { address: string }) {
const { disconnect } = useConnectors();
const { chain } = useNetwork();
const shortenedAddress = useMemo(() => {
if (!address) return "";
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}, [address]);
return (
<StyledBox>
<StyledButton color="inherit" onClick={disconnect}>
{shortenedAddress}
</StyledButton>
<span> Connected to {chain && chain.name}</span>
</StyledBox>
);
}
function ConnectWallet() {
const { connectors, connect } = useConnectors();
const [open, setOpen] = useState(false);
const theme = useTheme();
const handleClickOpen = () => {
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
return (
<StyledBox>
<StyledButton color="inherit" onClick={handleClickOpen}>
Connect Wallet
</StyledButton>
<Dialog open={open} onClose={handleClose}>
<DialogTitle>Connect to a wallet</DialogTitle>
<DialogContent>
<DialogContentText>
<Grid container direction="column" alignItems="flex-start" gap={1}>
{connectors.map((connector) => (
<ConnectWalletButton
key={connector.id}
onClick={() => {
connect(connector);
handleClose();
}}
sx={{ margin: theme.spacing(1) }}
>
{connector.id}
<Image
src={`/${connector.id}-icon.png`}
alt={connector.id}
width={30}
height={30}
/>
</ConnectWalletButton>
))}
</Grid>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="inherit">
Cancel
</Button>
</DialogActions>
</Dialog>
</StyledBox>
);
}
export default function WalletBar({
account,
}: {
account: string | undefined;
}) {
return account ? <WalletConnected address={account} /> : <ConnectWallet />;
}
Token Dropdown
The dropdown component is dedicated to showcasing the tokens associated with the currently connected wallet. To retrieve these tokens, a transaction like the one shown below can be executed. The sole argument for this function is the contract address of the intended owner.
const readTx = useMemo(() => {
const tx = {
address: STARKNET_HOMEPAGE_ERC721_ADDRESS,
functionName: "getTokensByOwner",
abi: starknetHomepageABI,
args: [account || "0x0000000"],
};
return tx;
}, [account]);
const { data, isLoading } = useContractRead(readTx);
Multicall Contract Interaction
The provided code offers an illustration of a multicall, specifically to approve a transaction for the mint price transfer followed by the actual minting action. Notably, the shortString
module from starknet.js
plays a pivotal role; it encodes and segments a lengthy string into an array of felt252
s, the expected argument type for the contract on Starknet.
The useContractWrite
is a Hook dedicated to executing a Starknet multicall, which can be employed for a singular transaction or multiple ones.
const calls = useMemo(() => {
const splitNewImage: string[] = shortString.splitLongString(newImage);
const splitNewLink: string[] = shortString.splitLongString(newLink);
const tx2 = {
contractAddress: STARKNET_HOMEPAGE_ERC721_ADDRESS,
entrypoint: "mint",
calldata: [
startCell.col,
startCell.row,
width,
height,
splitNewImage,
splitNewLink,
],
};
const price = selectedCells.length * 1000000000000000;
const tx1 = {
contractAddress: ERC_20_ADDRESS,
entrypoint: "approve",
calldata: [STARKNET_HOMEPAGE_ERC721_ADDRESS, `${price}`, "0"],
};
return [tx1, tx2];
}, [startCell, newImage, newLink, width, height, selectedCells.length]);
const { writeAsync: writeMulti } = useContractWrite({ calls });
Another crucial aspect to point out is the calldata
of the approve function for the ether transfer: calldata: [STARKNET_HOMEPAGE_ERC721_ADDRESS, '${price}', "0"],
. The amount argument is split into two parts because it's a u256
, which is composed of two separate felt252
values.
Once the multicall is prepared, the next step is to initiate the function and sign the transaction using the connected wallet.
const handleMintClick = async (): Promise<void> => {
setIsMintLoading(true);
try {
await writeMulti();
setIsMintLoading(false);
setState((prevState) => ({
...prevState,
showPopup: false,
selectedCells: [],
mintPrice: undefined,
}));
} catch (error) {
console.error("Error approving transaction:", error);
}
};
Conditional Multicall for Token Editing
Another instructive illustration of a conditional multicall setup is the modal used to modify the data associated with a token.

There are scenarios where the user may wish to alter just one attribute of the token, rather than both. Consequently, a conditional multicall configuration becomes necessary. It's essential to recall that the token id in the Cairo contract is defined as a u256
, implying it comprises two felt252
values.
const calls = useMemo(() => {
const txs = [];
const splitNewImage: string[] = shortString.splitLongString(newImage);
const splitNewLink: string[] = shortString.splitLongString(newLink);
if (newImage !== "" && nft) {
const tx1 = {
contractAddress: STARKNET_HOMEPAGE_ERC721_ADDRESS,
entrypoint: "setTokenImg",
calldata: [nft.token_id, 0, splitNewImage],
};
txs.push(tx1);
}
if (newLink !== "" && nft) {
const tx2 = {
contractAddress: STARKNET_HOMEPAGE_ERC721_ADDRESS,
entrypoint: "setTokenLink",
calldata: [nft.token_id, 0, splitNewLink],
};
txs.push(tx2);
}
return txs;
}, [nft, newImage, newLink]);
Starknet Homepage Overview
- Grid Component: Represents a 100x100 matrix, allowing users to select cells and mint corresponding tokens. It fetches existing tokens using the
getAllTokens
function from the contract and displays them. - Modals: Serve as the user interface for actions like wallet connection, token minting, and token editing.
- Token Dropdown: Displays tokens associated with a connected wallet. It retrieves these tokens using the
getTokensByOwner
function. - Multicall Contract Interaction: Enables token minting and editing. This process utilizes conditional multicalls based on user preferences, especially for editing token attributes.
Throughout the platform, string limitations in Cairo contracts require encoding lengthy strings into arrays of felt252
s. The OpenZeppelin Cairo Contracts library significantly expedites the development of the ERC721 contract for the Starknet Homepage.
Starknet-py: Python SDK 🚧
Starknet.py is a Python SDK designed for integrating websites, decentralized applications, backends, and more, with Starknet. It serves as a bridge, enabling smooth interaction between your application and the Starknet blockchain.
- For detailed information, documentation, and getting started guides, visit the Starknet.py documentation.
- To access the source code, contribute, or view the latest updates, check out the Starknet.py GitHub repository.
- For community support, discussions, and staying connected with other developers, join the Starknet Discord community. Look for the
🐍starknet-py
channel in Starknet Discord.
Starknet-rs: Rust SDK 🚧
Foundry Forge: Testing
Merely deploying contracts is not the end game. Many tools have offered this capability in the past. Forge sets itself apart by hosting a Cairo VM instance, enabling the sequential execution of tests. It employs Scarb for contract compilation.
To utilize Forge, define test functions and label them with test attributes. Users can either test standalone Cairo functions or integrate contracts, dispatchers, and test contract interactions on-chain.
snForge
Command-Line Usage
This section guides you through the Starknet Foundry snforge
command-line tool. Learn how to set up a new project, compile the code, and execute tests.
To start a new project with Starknet Foundry, use the --init
command and replace project_name
with your project's name.
snforge --init project_name
Once you've set up the project, inspect its layout:
cd project_name
tree . -L 1
The project structure is as follows:
.
├── README.md
├── Scarb.toml
├── src
└── tests
src/
holds your contract source code.tests/
is the location of your test files.Scarb.toml
is for project andsnforge
configurations.
Ensure the CASM code generation is active in the Scarb.toml
file:
# ...
[[target.starknet-contract]]
casm = true
# ...
To run tests using snforge
:
snforge
Collected 2 test(s) from the `test_name` package
Running 0 test(s) from `src/`
Running 2 test(s) from `tests/`
[PASS] tests::test_contract::test_increase_balance
[PASS] tests::test_contract::test_cannot_increase_balance_with_zero_value
Tests: 2 passed, 0 failed, 0 skipped
Integrating snforge
with Existing Scarb Projects
For those with an established Scarb project who wish to incorporate snforge
, ensure the snforge_std package
is declared as a dependency. Insert the line below in the [dependencies] section of your Scarb.toml
:
# ...
[dependencies]
snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "[VERSION]" }
Ensure the tag version corresponds with your snforge
version. To verify your snforge
version:
snforge --version
Or, add this dependency using the scarb
command:
scarb add snforge_std --git https://github.com/foundry-rs/starknet-foundry.git --tag VERSION
With these steps, your existing Scarb project is now snforge
-ready.
Testing with snforge
Utilize Starknet Foundry's snforge
command to efficiently run tests.
Executing Tests
Navigate to the package directory and issue this command to run tests:
snforge
Sample output might resemble:
Collected 3 test(s) from `package_name` package
Running 3 test(s) from `src/`
[PASS] package_name::executing
[PASS] package_name::calling
[PASS] package_name::calling_another
Tests: 3 passed, 0 failed, 0 skipped
Example: Testing a Simple Contract
The example provided below demonstrates how to test a Starknet contract using snforge
.
#![allow(unused)] fn main() { #[starknet::interface] trait IHelloStarknet<TContractState> { fn increase_balance(ref self: TContractState, amount: felt252); fn get_balance(self: @TContractState) -> felt252; } #[starknet::contract] mod HelloStarknet { #[storage] struct Storage { balance: felt252, } #[external(v0)] impl HelloStarknetImpl of super::IHelloStarknet<ContractState> { // Increases the balance by the specified amount. fn increase_balance(ref self: ContractState, amount: felt252) { self.balance.write(self.balance.read() + amount); } // Returns the balance. fn get_balance(self: @ContractState) -> felt252 { self.balance.read() } } } }
Remember, the identifier following mod
signifies the contract name. Here, the contract name is HelloStarknet
.
Craft the Test
Below is a test for the HelloStarknet
contract. This test deploys HelloStarknet
and interacts with its functions:
#![allow(unused)] fn main() { use snforge_std::{ declare, ContractClassTrait }; #[test] fn call_and_invoke() { // Declare and deploy the contract let contract = declare('HelloStarknet'); let contract_address = contract.deploy(@ArrayTrait::new()).unwrap(); // Instantiate a Dispatcher object for contract interactions let dispatcher = IHelloStarknetDispatcher { contract_address }; // Invoke a contract's view function let balance = dispatcher.get_balance(); assert(balance == 0, 'balance == 0'); // Invoke another function to modify the storage state dispatcher.increase_balance(100); // Validate the transaction's effect let balance = dispatcher.get_balance(); assert(balance == 100, 'balance == 100'); } }
To run the test, execute the snforge
command. The expected output is:
Collected 1 test(s) from using_dispatchers package
Running 1 test(s) from src/
[PASS] using_dispatchers::call_and_invoke
Tests: 1 passed, 0 failed, 0 skipped
Example: Testing ERC20 Contract
There are several methods to test smart contracts, such as unit tests, integration tests, fuzz tests, fork tests, E2E tests, and using foundry cheatcodes. This section discusses testing an ERC20 example contract from the starknet-js
subchapter examples using unit and integration tests, filtering, foundry cheatcodes, and fuzz tests through the snforge
CLI.
ERC20 Contract Example
After setting up your foundry project, add the following dependency to your Scarb.toml
(in this case we are using version 0.7.0 of the OpenZeppelin Cairo contracts, but you can use any version you want):
openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v0.7.0" }
Here's a basic ERC20 contract:
#![allow(unused)] fn main() { use starknet::ContractAddress; #[starknet::interface] trait Ierc20<TContractState> { fn balance_of(self: @TContractState, account: ContractAddress) -> u256; fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; } #[starknet::contract] mod erc20 { use starknet::ContractAddress; use openzeppelin::token::erc20::ERC20; #[storage] struct Storage {} #[constructor] fn constructor( ref self: ContractState, initial_supply: felt252, recipient: ContractAddress ) { let name = 'MyToken'; let symbol = 'MTK'; let mut unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::InternalImpl::initializer(ref unsafe_state, name, symbol); ERC20::InternalImpl::_mint(ref unsafe_state, recipient, initial_supply.into()); } #[external(v0)] impl Ierc20Impl of super::Ierc20<ContractState> { fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { let unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::ERC20Impl::balance_of(@unsafe_state, account) } fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { let mut unsafe_state = ERC20::unsafe_new_contract_state(); ERC20::ERC20Impl::transfer(ref unsafe_state, recipient, amount) } } } }
This contract allows minting tokens to a recipient during deployment, checking balances, and transferring tokens, relying on the openzeppelin ERC20 library.
Test Preparation
Organize your test file and include the required imports:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use array::ArrayTrait; use result::ResultTrait; use option::OptionTrait; use traits::TryInto; use starknet::ContractAddress; use starknet::Felt252TryIntoContractAddress; use snforge_std::{declare, ContractClassTrait}; // Additional code here. } }
For testing, you'll need a helper function to deploy the contract instance. This function requires a supply
amount and recipient
address:
#![allow(unused)] fn main() { use snforge_std::{declare, ContractClassTrait}; fn deploy_contract(name: felt252) -> ContractAddress { let recipient = starknet::contract_address_const::<0x01>(); let supply: felt252 = 20000000; let contract = declare(name); let mut calldata = array![supply, recipient.into()]; contract.deploy(@calldata).unwrap() } // Additional code here. }
Use declare
and ContractClassTrait
from snforge_std
. Then, initialize the supply
and recipient
, declare the contract, compute the calldata, and deploy.
Writing the Test Cases
Verifying the Balance After Deployment
To begin, test the deployment helper function to confirm the recipient's balance:
#![allow(unused)] fn main() { // ... use erc20_contract::erc20::Ierc20SafeDispatcher; use erc20_contract::erc20::Ierc20SafeDispatcherTrait; #[test] #[available_gas(3000000000000000)] fn test_balance_of() { let contract_address = deploy_contract('erc20'); let safe_dispatcher = Ierc20SafeDispatcher { contract_address }; let recipient = starknet::contract_address_const::<0x01>(); let balance = safe_dispatcher.balance_of(recipient).unwrap(); assert(balance == 20000000, 'Invalid Balance'); } }
Execute snforge
to verify:
Collected 1 test from erc20_contract package
[PASS] tests::test_erc20::test_balance_of
Utilizing Foundry Cheat Codes
When testing smart contracts, simulating different conditions is essential. Foundry Cheat Codes
from the snforge_std
library offer these simulation capabilities for Starknet smart contracts.
These cheat codes consist of helper functions that adjust the smart contract's environment. They allow developers to modify parameters or conditions to examine contract behavior in specific scenarios.
Using snforge_std
's cheat codes, you can change elements like block numbers, timestamps, or even the caller of a function. This guide focuses on start_prank
and stop_prank
. You can find a reference to available cheat codes here
Below is a transfer test example:
#![allow(unused)] fn main() { use snforge_std::{declare, ContractClassTrait, start_prank, stop_prank}; #[test] #[available_gas(3000000000000000)] fn test_transfer() { let contract_address = deploy_contract('erc20'); let safe_dispatcher = Ierc20SafeDispatcher { contract_address }; let sender = starknet::contract_address_const::<0x01>(); let receiver = starknet::contract_address_const::<0x02>(); let amount : felt252 = 10000000; // Set the function's caller start_prank(contract_address, sender); safe_dispatcher.transfer(receiver.into(), amount.into()); let balance_after_transfer = safe_dispatcher.balance_of(receiver).unwrap(); assert(balance_after_transfer == 10000000, 'Incorrect Amount'); // End the prank stop_prank(contract_address); } }
Executing snforge
for the tests displays:
Collected 2 tests from erc20_contract package
[PASS] tests::test_erc20::test_balance_of
[PASS] tests::test_erc20::test_transfer
In this example, start_prank
determines the transfer function's caller, while stop_prank
concludes the prank.
Full `ERC20 test example` file
#[cfg(test)] mod tests { use array::ArrayTrait; use result::ResultTrait; use option::OptionTrait; use traits::TryInto; use starknet::ContractAddress; use starknet::Felt252TryIntoContractAddress; use snforge_std::{declare, ContractClassTrait, start_prank, stop_prank};
use erc20_contract::erc20::Ierc20SafeDispatcher;
use erc20_contract::erc20::Ierc20SafeDispatcherTrait;
fn deploy_contract(name: felt252) -> ContractAddress {
let recipient = starknet::contract_address_const::<0x01>();
let supply : felt252 = 20000000;
let contract = declare(name);
let mut calldata = array![supply, recipient.into()];
contract.deploy(@calldata).unwrap()
}
#[test]
#[available_gas(3000000000000000)]
fn test_balance_of() {
let contract_address = deploy_contract('erc20');
let safe_dispatcher = Ierc20SafeDispatcher { contract_address };
let recipient = starknet::contract_address_const::<0x01>();
let balance = safe_dispatcher.balance_of(recipient).unwrap();
assert(balance == 20000000, 'Invalid Balance');
}
#[test]
#[available_gas(3000000000000000)]
fn test_transfer() {
let contract_address = deploy_contract('erc20');
let safe_dispatcher = Ierc20SafeDispatcher { contract_address };
let sender = starknet::contract_address_const::<0x01>();
let receiver = starknet::contract_address_const::<0x02>();
let amount : felt252 = 10000000;
start_prank(contract_address, sender);
safe_dispatcher.transfer(receiver.into(), amount.into());
let balance_after_transfer = safe_dispatcher.balance_of(receiver).unwrap();
assert(balance_after_transfer == 10000000, 'Incorrect Amount');
stop_prank(contract_address);
}
}
Fuzz Testing
Fuzz testing introduces random inputs to the code to identify vulnerabilities, security issues, and unforeseen behaviors. While you can manually provide these inputs, automation is preferable when testing a broad set of values. See the example below in test_fuzz.cairo
:
#![allow(unused)] fn main() { fn mul(a: felt252, b: felt252) -> felt252 { return a * b; } #[test] fn test_fuzz_sum(x: felt252, y: felt252) { assert(mul(x, y) == x * y, 'incorrect'); } }
Running snforge
produces:
Collected 1 test(s) from erc20_contract package
Running 0 test(s) from src/
Running 1 test(s) from tests/
[PASS] tests::test_fuzz::test_fuzz_sum (fuzzer runs = 256)
Tests: 1 passed, 0 failed, 0 skipped
Fuzzer seed: 6375310854403272271
The fuzzer supports these types by November 2023:
- u8
- u16
- u32
- u64
- u128
- u256
- felt252
Fuzzer Configuration
You can set the number of runs and the seed for a test:
#![allow(unused)] fn main() { #[test] #[fuzzer(runs: 100, seed: 38)] fn test_fuzz_sum(x: felt252, y: felt252) { assert(mul(x, y) == x * y, 'incorrect'); } }
Or, use the command line:
$ snforge --fuzzer-runs 500 --fuzzer-seed 4656
Or in scarb.toml
:
# ...
[tool.snforge]
fuzzer_runs = 500
fuzzer_seed = 4656
# ...
For more insight on fuzz tests, you can view it here
Filter Tests
To execute specific tests, use a filter string with the snforge
command. Tests matching the filter based on their absolute module tree path will be executed.
For instance, to run all tests with the string 'test_' in their name:
snforge test_
Expected output:
Collected 3 test(s) from erc20_contract package
Running 0 test(s) from src/
Running 3 test(s) from tests/
[PASS] tests::test_erc20::tests::test_balance_of
[PASS] tests::test_erc20::tests::test_transfer
[PASS] tests::test_fuzz::test_fuzz_sum (fuzzer runs = 256)
Tests: 3 passed, 0 failed, 0 skipped
Fuzzer seed: 10426315620495146768
All the tests with the string 'test_' in their test name went through.
Another example: To filter and run test_fuzz_sum
we can partially match the test name with the string 'fuzz_sum' like this:
snforge test_fuzz_sum
To execute an exact test, combine the --exact
flag with a fully qualified test name:
snforge package_name::test_name --exact
To halt the test suite upon the first test failure, use the --exit-first
flag:
snforge --exit-first
If a test fails, the output will resemble:
Collected 3 test(s) from erc20_contract package
Running 0 test(s) from src/
Running 3 test(s) from tests/
[FAIL] tests::test_erc20::tests::test_balance_of
Failure data:
original value: [381278114803728420489684244530881381], converted to a string: [Invalid Balance]
[SKIP] tests::test_erc20::tests::test_transfer
[SKIP] tests::test_fuzz::test_fuzz_sum
Tests: 0 passed, 1 failed, 2 skipped
Failures:
tests::test_erc20::tests::test_balance_of
Conclusion
Starknet Foundry offers a notable step forward in Starknet contract development and testing. This toolset sharpens the process of creating, deploying, and testing Cairo contracts. Its main components, Forge and Cast, provide developers with robust tools for Cairo contract work.
Forge shines with its dual functionality: deploying and thoroughly testing Cairo contracts. It directly supports test writing in Cairo, removing the need for other languages and simplifying the task. Moreover, Forge seamlessly integrates with Scarb, emphasizing its adaptability, especially with existing Scarb projects.
The snforge
command-line tool makes initializing, setting up, and testing Starknet contracts straightforward.
Security Considerations
In blockchain programming, understanding and mitigating smart contract vulnerabilities is vital to maintain user trust. This is as true for Cairo as any other language.
We'll cover common security issues and Starknet-specific vulnerabilities in Cairo, along with strategies to safeguard your contracts.
Your insights can enhance this chapter. To contribute, submit a pull request to the Book repo.
Note: Some code examples here are simplified pseudo-code, meant for concept explanation, not for production use.
1. Access Control
Access control vulnerabilities occur when a smart contract's functions are insufficiently protected, allowing unauthorized actions. This can result in unexpected behavior and data manipulation.
Take, for instance, a smart contract for token minting without proper access control:
#![allow(unused)] fn main() { #[starknet::contract] mod Token { #[storage] struct Storage { total_supply: u256, // Stores the total supply of tokens. } #[external(v0)] impl ITokenImpl of IToken { fn mint_tokens(ref self: ContractState, amount: u256) { // The mint_tokens function updates the total supply. // Without access control, any user can call this function, posing a risk. self.total_supply.write(self.total_supply.read() + amount); } } } }
In this code, the mint_tokens
function is vulnerable because any user can call it, leading to potential token supply exploitation. Implementing access controls would restrict this function to authorized users only.
Recommendation
To prevent access control vulnerabilities, integrate authorization mechanisms like role-based access control (RBAC) or ownership checks. You can develop a custom solution or use templates from sources like OpenZeppelin.
In our earlier example, we can enhance security by adding an owner variable, initializing the owner in the constructor, and including a verification in the mint_tokens
function to allow only the owner to mint tokens.
#![allow(unused)] fn main() { #[starknet::contract] mod Token { #[storage] struct Storage { owner: ContractAddress, // New variable to store the contract owner's address. total_supply: u256, } #[constructor] fn constructor(ref self: ContractState,) { let sender = get_caller_address(); // Get the address of the contract creator. self.owner.write(sender); // Set the creator as the owner. } #[external(v0)] impl ITokenImpl of IToken { fn mint_tokens(ref self: ContractState, amount: u256) { // Check if the caller is the owner before minting tokens. let sender = get_caller_address(); assert(sender == self.owner.read()); // Assert ensures only the owner can mint. self.total_supply.write(self.total_supply.read() + amount); } } } }
By establishing robust access control, you ensure that only authorized entities execute your smart contract functions, significantly reducing the risk of unauthorized interference.
2. Reentrancy
Reentrancy vulnerabilities arise when a smart contract calls an external contract before updating its state. This allows the external contract to recursively call the original function, potentially leading to unintended behavior.
Consider a game contract where whitelisted addresses can mint an NFT sword and then execute an on_receive_sword()
function before returning it. This NFT contract is at risk of a reentrancy attack, where an attacker can mint multiple swords.
#![allow(unused)] fn main() { #[storage] struct Storage { available_swords: u256, // Stores available swords. sword: LegacyMap::<ContractAddress, u256>, // Maps swords to addresses. whitelisted: LegacyMap::<ContractAddress, u256>, // Tracks whitelisted addresses. ... ... } #[constructor] fn constructor(ref self: ContractState,) { self.available_swords.write(100); // Initializes the sword count. } #[external(v0)] impl IGameImpl of IGame { fn mint_one_sword(ref self: ContractState) { let sender = get_caller_address(); if self.whitelisted.read(sender) { // Update the sword count before minting. let sword_count = self.available_swords.read(); self.available_swords.write(sword_count - 1); // Mint a sword. self.sword.write(sender, 1); // Callback to sender's contract. let callback = ICallerDispatcher { contract_address: sender }.on_receive_sword(); // Remove sender from whitelist after callback to prevent reentrancy. self.whitelisted.write(sender, false); } } }
An attacker's contract can implement the on_receive_sword
function to exploit the reentry vulnerability and mint multiple swords by calling mint_one_sword
again before removing the sender from the whitelist
:
#![allow(unused)] fn main() { fn on_receive_sword(ref self: ContractState) { let nft_sword_contract = get_caller_address(); let call_number: felt252 = self.total_calls.read(); self.total_calls.write(call_number + 1); if call_number < 10 { // Attempt to mint a sword again. let call = ISwordDispatcher { contract_address: nft_sword_contract }.mint_one_sword(); } } }
Reentrancy protections are critical in many ERC standards with safeTransfer
functions (like ERC721, ERC777, ERC1155, ERC223) and in flash loans, where borrower contracts need to safely use and return funds.
Recommendation:
To prevent reentrancy attacks, use the check-effects-interactions pattern. This means updating your contract's internal state before interacting with external contracts. In the previous example, remove the sender from the whitelist before making the external call.
#![allow(unused)] fn main() { if self.whitelisted.read(sender) { // Update the sword count first. let sword_count = self.available_swords.read(); self.available_swords.write(sword_count - 1); // Mint a sword to the caller. self.sword.write(sender, 1); // Crucially, remove the sender from the whitelist before the external call. self.whitelisted.write(sender, false); // Only then, make the callback to the sender. let callback = ICallerDispatcher { contract_address: sender }.on_receive_sword(); } }
Adhering to this pattern enhances the security of your smart contract by minimizing the risk of reentrancy attacks and preserving the integrity of its internal state.
3. Tx.Origin Authentication
In Solidity, tx.origin
is a global variable that stores the address of the transaction initiator, while msg.sender
stores the address of the transaction caller. In Cairo, we have the account_contract_address
global variable and get_caller_address
function, which serve the same purpose.
Using account_contract_address
(the equivalent of tx.origin
) for authentication in your smart contract functions can lead to phishing attacks. Attackers can create custom smart contracts and trick users into placing them as intermediaries in a transaction call, effectively impersonating the contract owner.
For example, consider a Cairo smart contract that allows transferring funds to the owner and uses account_contract_address
for authentication:
#![allow(unused)] fn main() { use starknet::get_caller_address; use box::BoxTrait; struct Storage { owner: ContractAddress, // Stores the owner's address. } #[constructor] fn constructor(){ // Initialize the owner as the contract deployer. let contract_deployer = get_caller_address(); self.owner.write(contract_deployer) } #[external(v0)] impl ITokenImpl of IToken { fn transferTo(ref self: ContractState, to: ContractAddress, amount: u256) { let tx_info = starknet::get_tx_info().unbox(); let authorizer: ContractAddress = tx_info.account_contract_address; // Verifies the transaction initiator as the owner. assert(authorizer == self.owner.read()); // Processes the fund transfer. self.balance.write(to + amount); } } }
An attacker can trick the owner into using a malicious contract, allowing the attacker to call the transferTo
function and impersonate the contract owner:
#![allow(unused)] fn main() { #[starknet::contract] mod MaliciousContract { ... ... #[external(v0)] impl IMaliciousContractImpl of IMaliciousContract { fn transferTo(ref self: ContractState, to: ContractAddress, amount: u256) { // Malicious callback to transfer funds. let callback = ICallerDispatcher { contract_address: sender }.transferTo(ATTACKER_ACCOUNT, amount); } } }
Recommendation:
To guard against phishing attacks, replace account_contract_address
(origin) authentication with get_caller_address
(sender) in the transferTo
function:
#![allow(unused)] fn main() { use starknet::get_caller_address; struct Storage { owner: ContractAddress, // Stores the owner's address. } #[constructor] fn constructor(){ // Initialize the owner as the contract deployer. let contract_deployer = get_caller_address(); self.owner.write(contract_deployer) } #[external(v0)] impl ITokenImpl of IToken { fn transferTo(ref self: ContractState, to: ContractAddress, amount: u256) { let authorizer = get_caller_address(); // Verify that the caller is the owner. assert(authorizer == self.owner.read()); // Execute the fund transfer. self.balance.write(to + amount); } } }
This change ensures secure authentication, preventing unauthorized users from executing critical functions and safeguarding against phishing attempts.
4. Handling Overflow and Underflow in Smart Contracts
Overflow and underflow vulnerabilities arise from assigning values too large (overflow) or too small (underflow) for a specific data type.
Consider the felt252
data type: adding or subtracting values beyond its range can yield incorrect results:
#![allow(unused)] fn main() { fn overflow_felt252() -> felt252 { // Assigns the maximum felt252 value: 2^251 + 17 * 2^192 let max: felt252 = 3618502788666131106986593281521497120414687020801267626233049500247285301248 + 17 * 6277101735386680763835789423207666416102355444464034512896; // Attempting to add beyond the maximum value. max + 3 } fn underflow_felt252() -> felt252 { let min: felt252 = 0; // Same maximum value as in overflow. let subtract = (3618502788666131106986593281521497120414687020801267626233049500247285301248 + 17 * 6277101735386680763835789423207666416102355444464034512896); // Subtracting more than the minimum, leading to underflow. min - subtract } }
Executing these functions will result in incorrect values due to overflow and underflow, as illustrated in the following image:

Recommendation:
To prevent incorrect results, use protected data types like u128
or u256
, which are designed to manage overflows and underflows.
Here's how you can use the u256
data type to handle these issues:
#![allow(unused)] fn main() { fn overflow_u256() -> u256 { let max_u128: u128 = 0xffffffffffffffffffffffffffffffff_u128; // Maximum u128 value. let max: u256 = u256 { low: max_u128, high: max_u128 }; // Maximum u256 value. let three: u256 = u256 { low: 3_u128, high: 0_u128 }; // Value of 3. max + three // Attempting to add beyond max, will trigger overflow protection. } fn underflow_u256() -> u256 { let min: u256 = u256 { low: 0_u128, high: 0_u128 }; // Zero value for u256. let three: u256 = u256 { low: 3_u128, high: 0_u128 }; // Value of 3. min - three // Attempting to subtract from zero, will trigger underflow protection. } }
When these functions encounter overflows or underflows, the transaction will revert, as shown in these images:


Failure reasons for u256
:
- Overflow:
0x753235365f616464204f766572666c6f77=u256_add Overflow
- Underflow:
0x753235365f737562204f766572666c6f77=u256_sub Overflow
Similarly, the u128
data type can be used to handle overflow and underflow:
#![allow(unused)] fn main() { fn overflow_u128() -> u128 { let max: u128 = 0xffffffffffffffffffffffffffffffff_u128; // Maximum u128 value. max + 3_u128 // Adding to max, overflow protection triggers if necessary. } fn underflow_u128() -> u128 { let min: u128 = 0_u128; // Zero value for u128. min - 3_u128 // Subtracting from zero, underflow protection activates if needed. } }
Overflow or underflow in u128 will similarly revert the transaction, with corresponding failure reasons:


Failure reasons for u128:
- Overflow:
0x753132385f616464204f766572666c6f77=u128_add Overflow
- Underflow:
0x753132385f737562204f766572666c6f77=u128_sub Overflow
Using these data types, you can ensure safer arithmetic operations in your smart contracts, avoiding unintended consequences of overflows and underflows.
5. Private Data On-Chain.
Storing secret values in smart contracts presents a challenge because all on-chain data is publicly accessible, even if the code isn't published. For example, consider a smart contract storing a password (12345678) using a constructor parameter:
#![allow(unused)] fn main() { #[starknet::contract] mod StoreSecretPassword { struct Storage { password: felt252, // Field to store the password. } #[constructor] fn constructor(_password: felt252) { // Writing the password to the storage. self.password.write(_password); } } }

However, understanding Cairo's storage layout, we can create a script to read the stored variable:
import { Provider, hash } from "starknet";
const provider = new Provider({
sequencer: {
network: "goerli-alpha",
},
});
var passHash = hash.starknetKeccak("password");
console.log(
"getStor=",
await provider.getStorageAt(
"0x032d0392eae7440063ea0f3f50a75dbe664aaa1df76b4662223430851a113369",
passHash,
812512,
),
);
Executing this script reveals the stored password value (hex value of 12345678):

Moreover, using a block explorer, we can view the deployed parameters in the transaction:

Recommendation:
If your smart contract requires storing private data on-chain, consider off-chain encryption before sending data to the blockchain. Alternatively, explore options like hashes, merkle trees, or commit-reveal patterns to maintain data privacy.
Call for Contributions: Additional Vulnerabilities
We've discussed several common vulnerabilities in Cairo smart contracts, but many other security risks need attention. We invite community contributions to expand this chapter with more vulnerabilities:
- Storage Collision
- Flash Loan Attacks
- Oracle Manipulation
- Bad Randomness
- Denial of Service
- Untrusted Delegate Calls
- Public Burn
If you have expertise in these areas, please consider contributing your knowledge, including explanations and examples of these vulnerabilities. Your input will greatly benefit the Starknet and Cairo developer community, aiding in the development of more secure and resilient smart contracts.
We appreciate your support in enhancing the safety and security of the Starknet ecosystem for developers and users alike.
Starknet Security Tools
Starknet offers a range of tools for testing the security of smart contracts. We invite developers to improve existing tools or create new ones.
This section covers:
- Tools for security testing.
- Security considerations for smart contracts.
Below is an overview of the tools for Starknet security testing discussed in this chapter:
- Cairo-fuzzer: A tool for smart contract developers to test security. It functions both as a standalone tool and as a library.
- Caracal: A static analysis tool for Starknet smart contracts, utilizing the SIERRA representation.
- Thoth: A comprehensive Cairo/Starknet security toolkit. It includes analyzers, disassemblers, and decompilers.
Cairo-fuzzer
Cairo-fuzzer is a tool designed for smart contract developers to assess security. It operates both independently and as a library.
Features

- Execute Cairo contracts.
- Execute Starknet contracts.
- Replay fuzzing corpus.
- Minimize fuzzing corpus.
- Load previous corpus.
- Manage multiple arguments.
- Utilize workspace architecture.
- Import dictionaries.
- Integrate Cairo-fuzzer as a library.
Usage
To use Cairo-fuzzer, run the following command:
cargo run --release -- --cores 3 --contract tests/fuzzinglabs.json --function "Fuzz_symbolic_execution"
This outputs:
For more usage information, follow our tutorial
CMDLINE (--help):
Usage: cairo-fuzzer [OPTIONS]
Options:
--cores <CORES> Set the number of threads to run [default: 1]
--contract <CONTRACT> Set the path of the JSON artifact to load [default: ]
--function <FUNCTION> Set the function to fuzz [default: ]
--workspace <WORKSPACE> Workspace of the fuzzer [default: fuzzer_workspace]
--inputfolder <INPUTFOLDER> Path to the inputs folder to load [default: ]
--crashfolder <CRASHFOLDER> Path to the crashes folder to load [default: ]
--inputfile <INPUTFILE> Path to the inputs file to load [default: ]
--crashfile <CRASHFILE> Path to the crashes file to load [default: ]
--logs Enable fuzzer logs in file
--seed <SEED> Set a custom seed (only applicable for 1 core run)
--run-time <RUN_TIME> Number of seconds this fuzzing session will last
--config <CONFIG> Load config file
--replay Replay the corpus folder
--minimizer Minimize Corpora
-h, --help Print help information
Caracal
Caracal is a static analysis tool for Starknet smart contracts, specifically analyzing their SIERRA representation.
Features
- Vulnerability detectors for Cairo code.
- Report printers.
- Taint analysis.
- Data flow analysis framework.
- Compatibility with Scarb projects.
Installation
Precompiled Binaries
Download precompiled binaries from the releases page. Use binary version v0.1.x for Cairo compiler 1.x.x, and v0.2.x for Cairo compiler 2.x.x.
Building from Source
Requirements
- Rust compiler
- Cargo
Installation Steps
Clone and build from the repository:
cargo install --git https://github.com/crytic/caracal --profile release --force
Building from a Local Copy:
If you prefer to build from a local copy:
git clone https://github.com/crytic/caracal
cd caracal
cargo install --path . --profile release --force
Thoth
Thoth (pronounced "taut" or "toss") is a security toolkit for Cairo/Starknet. Written in Python 3, it includes analyzers, disassemblers, and decompilers. Thoth is capable of generating call graphs, control-flow graphs (CFG), and data-flow graphs for Sierra files or Cairo/Starknet compilation artifacts. It also features tools like a symbolic execution engine and a symbolic bounded model checker.
Features
-
Remote & Local Analysis: Works with contracts on Mainnet/Goerli and local compilations.
-
Decompiler: Transforms assembly into decompiled code using SSA (Static Single Assignment).
-
Call Flow Analysis: Generates Call Flow Graphs.
-
Static Analysis: Conducts various types of analyses (security/optimization/analytics) on contracts.
-
Symbolic Execution: Finds variable values for specific paths in functions and generates test cases.
-
Data Flow Analysis: Produces Data Flow Graphs (DFG) for each function.
-
Disassembler: Converts bytecode to assembly.
-
Control Flow Analysis: Creates Control Flow Graphs (CFG).
-
Cairo Fuzzer Inputs: Generates inputs for Cairo fuzzer.
-
Sierra Files Analysis: Analyzes Sierra files.
-
Sierra Files Symbolic Execution: Performs symbolic execution on Sierra files.
-
Symbolic Bounded Model Checker: Functions as a symbolic bounded model checker.
Installation
Install Thoth using the following commands:
sudo apt install graphviz
git clone https://github.com/FuzzingLabs/thoth && cd thoth
pip install .
thoth -h
Architecture
This is an introduction to Starknet’s Layer 2 architecture,
Starknet is a coordinated system, with each component—Sequencers, Provers, and nodes—playing a specific yet interconnected role. Although Starknet hasn’t fully decentralized yet, it’s actively moving toward that goal. This understanding of the roles and interactions within the system will help you better grasp the intricacies of the Starknet ecosystem.
High-Level Overview
Starknet’s operation begins when a transaction is received by a gateway, which serves as the Mempool. This stage could also be managed by the Sequencer. The transaction is initially marked as "RECEIVED." The Sequencer then incorporates the transaction into the network state and tags it as "ACCEPTED_ON_L2." The final step involves the Prover, which executes the operating system on the new block, calculates its proof, and submits it to the Layer 1 (L1) for verification.

Starknet architecture
In essence, Starknet’s architecture involves multiple components:
-
The Sequencer is responsible for receiving transactions, ordering them, and producing blocks. It operates similarly to validators in Ethereum or Bitcoin.
-
The Prover is tasked with generating proofs for the created blocks and transactions. It uses Cairo’s Virtual Machine to run provable programs, thereby creating execution traces necessary for generating STARK proofs.
-
Layer 1 (L1), in this case Ethereum, hosts a smart contract capable of verifying these STARK proofs. If the proofs are valid, Starknet’s state root on L1 is updated.
Starknet’s state is a comprehensive snapshot maintained through Merkle trees, much like in Ethereum. This establishes the architecture of the validity roll-up and the roles of each component.
For a more in-depth look at each component, read on.
After exploring the introductory overview of the different components, delve deeper into their specific roles by referring to their dedicated subchapters in this Chapter.
Sequencers
Sequencers are the backbone of the Starknet network, akin to Ethereum’s validators. They usher transactions into the system.
Validity rollups excel at offloading some network chores, like bundling and processing transactions, to specialized players. This setup is somewhat like how Ethereum and Bitcoin delegate security to miners. Sequencing, like mining, demands hefty resources.
For networks like Starknet and other platforms utilizing Validity rollups, a similar parallel is drawn. These networks outsource transaction processing to specialized entities and then verify their work. These specialized entities in the context of Validity rollups are known as "Sequencers."
Instead of providing security, as miners do, Sequencers provide transaction capacity. They order (sequence) multiple transactions into a single batch, executes them, and produce a block that will later be proved by the Prover and submmited to the Layer 1 network as a single, compact proof, known as a "rollup." In other words, just as validators in Ethereum and miners in Bitcoin are specialized actors securing the network, Sequencers in Validity rollup-based networks are specialized actors that provide transaction capacity.
This mechanism allows Validity (or ZK) rollups to handle a higher volume of transactions while maintaining the security of the underlying Ethereum network. It enhances scalability without compromising on security.
Sequencers follow a systematic method for transaction processing:
-
Sequencing: They collect transactions from users and order (sequence) them.
-
Executing: Sequencers then process these transactions.
-
Batching: Transactions are grouped together in batches or blocks for efficiency.
-
Block Production: Sequencers produce blocks that contain batches of processed transactions.
Sequencers must be reliable and highly available, as their role is critical to the network’s smooth functioning. They need powerful and well-connected machines to perform their role effectively, as they must process transactions rapidly and continuously.
The current roadmap for Starknet includes decentralizing the Sequencer role. This shift towards decentralization will allow more participants to become Sequencers, contributing to the robustness of the network.
For more details in the Sequencer role, refer to the dedicated subchapter in this Chapter.
Provers
Provers serve as the second line of verification in the Starknet network. Their main task is to validate the work of the Sequencers (when they receive the block produced by the Sequencer) and to generate proofs that these processes were correctly performed.
The duties of a Prover include:
-
Receiving Blocks: Provers obtain blocks of processed transactions from Sequencers.
-
Processing: Provers process these blocks a second time, ensuring that all transactions within the block have been correctly handled.
-
Proof Generation: After processing, Provers generate a proof of correct transaction processing.
-
Sending Proof to Ethereum: Finally, the proof is sent to the Ethereum network for validation. If the proof is correct, the Ethereum network accepts the block of transactions.
Provers need even more computational power than Sequencers because they have to calculate and generate proofs, a process that is computationally heavy. However, the work of Provers can be split into multiple parts, allowing for parallelism and efficient proof generation. The proof generation process is asynchronous, meaning it doesn’t have to occur immediately or in real-time. This flexibility allows for the workload to be distributed among multiple Provers. Each Prover can work on a different block, allowing for parallelism and efficient proof generation.
The design of Starknet relies on these two types of actors — Sequencers and Provers — working in tandem to ensure efficient processing and secure verification of transactions.
For more details in the Prover role, refer to the dedicated subchapter in this Chapter.
Optimizing Sequencers and Provers: Debunking Common Misconceptions
The relationship between Sequencers and Provers in blockchain technology often sparks debate. A common misunderstanding suggests that either the Prover or the Sequencer is the main bottleneck. To set the record straight, let’s discuss the optimization of both components.
Starknet, utilizing the Cairo programming language, currently supports only sequential transactions. Plans are in place to introduce parallel transactions in the future. However, as of now, the Sequencer operates one transaction at a time, making it the bottleneck in the system.
In contrast, Provers operate asynchronously and can execute multiple tasks in parallel. The use of proof recursion allows for task distribution across multiple machines, making scalability less of an issue for Provers.
Given the asynchronous and scalable nature of Provers, focus in Starknet has shifted to enhancing the Sequencer’s efficiency. This explains why current development efforts are primarily aimed at the sequencing side of the equation.
Nodes
When it comes to defining what nodes do in Bitcoin or Ethereum, people often misinterpret their role as keeping track of every transaction within the network. This, however, is not entirely accurate.
Nodes serve as auditors of the network, maintaining the state of the network, such as how much Bitcoin each participant owns or the current state of a specific smart contract. They accomplish this by processing transactions and preserving a record of all transactions, but that’s a means to an end, not the end itself.
In Validity rollups and specifically within Starknet, this concept is somewhat reversed. Nodes don’t necessarily have to process transactions to get the state. In contrast to Ethereum or Bitcoin, Starknet nodes aren’t required to process all transactions to maintain the state of the network.
There are two main ways to access network state data: via an API gateway or using the RPC protocol to communicate with a node. Operating your own node is typically faster than using a shared architecture, like the gateway. Over time, Starknet plans to deprecate APIs and replace them with a JSON RPC standard, making it even more beneficial to operate your own node.
It’s worth noting that encouraging more people to run nodes increases the resilience of the network and prevents server flooding, which has been an issue in networks in other L2s.
Currently, there are primarily three methods for a node to keep track of the network’s state and we can have nodes implement any of these methods:
-
Replaying Old Transactions: Like Ethereum or Bitcoin, a node can take all the transactions and re-execute them. Although this approach is accurate, it isn’t scalable unless you have a powerful machine that’s capable of handling the load. If you can replay all transactions, you can become a Sequencer.
-
Relying on L2 Consensus: Nodes can trust the Sequencer(s) to execute the network correctly. When the Sequencer updates the state and adds a new block, nodes accept the update as accurate.
-
Checking Proof Validation on L1: Nodes can monitor the state of the network by observing L1 and ensuring that every time a proof is sent, they receive the updated state. This way, they don’t have to trust anyone and only need to keep track of the latest valid transaction for Starknet.
Each type of node setup comes with its own set of hardware requirements and trust assumptions.
Nodes That Replay Transactions
Nodes that replay transactions require powerful machines to track and execute all transactions. These nodes don’t have trust assumptions; they rely solely on the transactions they execute, guaranteeing that the state at any given point is valid.
Nodes That Rely on L2 Consensus
Nodes relying on L2 consensus require less computational power. They need sufficient storage to keep the state but don’t need to process a lot of transactions. The trade-off here is a trust assumption. Currently, Starknet revolves around one Sequencer, so these nodes are trusting Starkware not to disrupt the network. However, once a consensus mechanism and leader election amongst Sequencers are in place, these nodes will only need to trust that a Sequencer who staked their stake to produce a block is not willing to lose it.
Nodes That Check Proof Validation on L1
Nodes that only update their state based on proof validation on L1 require the least hardware. They have the same requirements as an Ethereum node, and once Ethereum light nodes become a reality, maintaining such a node could be as simple as using a smartphone. The only trade-off is latency. Proofs are not sent to Ethereum every block but intermittently, resulting in delayed state updates. Plans are in place to produce proofs more frequently, even if they are not sent to Ethereum immediately, allowing these nodes to reduce their latency. However, this development is still a way off in the Starknet roadmap.
Conclusion
Through this chapter, we delve into Starknet’s structure, uncovering the importance of Sequencers, Provers, and nodes. Each plays a unique role, but together, they create a highly scalable, efficient, and secure network that marks a significant step forward in Layer 2 solutions. As Starknet evolves towards decentralization, understanding these roles will provide valuable insight into the inner workings of this network.
As we venture further into the Starknet universe, our next stop will be an exploration of the transaction lifecycle before we dive into the heart of coding with Cairo.
Transaction Versions
Understanding Starknet's transaction types is essential to master its architecture and capabilities. Each transaction type serves a unique purpose, and getting a grip on their differences is crucial for proficient Starknet usage.
Starknet OS: The Backbone
Central to Starknet's functionality is the Starknet Operating System (OS), a Cairo program that fuels the network. This OS orchestrates key activities, including:
- Deploying contracts
- Executing transactions
- Facilitating L1<>L2 message exchanges
In Starknet terminology, "protocol level" alludes to modifications in the foundational Starknet OS Cairo program, ensuring its steadfastness.
Transaction Types
- Declare Transactions: Unique in their ability to introduce new classes, leading to potential new smart contracts.
- Invoke Transactions: They call upon an action but can't introduce new ones.
- Deploy Account Transactions: Designed for setting up smart wallet contracts.
Declare Transactions
Declare transactions are the sole mechanism for introducing new smart contracts to Starknet.
Recall programming in C++. Before employing a variable or function, it's first 'declared', signaling to the compiler its existence and type. Only then can you 'define' or use it. Declare transactions in Starknet operate on similar principles: they announce a new operation, prepping it for future use.
Versions:
- V0 - Suited for Cairo 0 contracts before nonces.
- V1 - Tailored for Cairo 0 with nonces.
- V2 (current) - Optimized for the modern Cairo contracts.
Here's a key distinction to understand between the different Cairo versions:
With Cairo 0, developers sent Cairo Assembly (CASM) code directly to the sequencer. But with the contemporary Cairo version, they send Sierra code to the Sequencer. Breaking it down, Cairo 0 compiled straight to CASM, while the current Cairo version compiles to Sierra, which subsequently compiles to CASM. A crucial difference is that Sierra executions are infallible and always provable, whereas in Cairo 0, transactions could fail. If they did, they became non-provable. The latest Cairo iteration ensures all code compiles to Sierra, making every transaction reliable.
When declaring a contract with the latest version, developers are essentially announcing Sierra code, not just raw CASM.
Examining the parameters of a V2 transaction reveals measures that ensure the class hash corresponds to the Sierra code being dispatched. The class hash encompasses the hash of the Cairo assembly code, but since developers send Sierra code, it's imperative to ensure that the dispatched code aligns with the indicated class hash.
// TODO -> Provide specifics about the parameters included in the transaction.
In essence, using the most recent Cairo version implies the utilization of the latest Declare transaction version.
Invoke Transactions
Unlike Declare transactions, Invoke transactions don't add new functions. They ask the network to carry out actions, such as executing or deploying contracts. This method contrasts with Ethereum, where a contract can either be deployed by sending a distinct transaction or by having another smart contract factory to deploy it. Starknet uses only the second method.
The Universal Deployer Contract (UDC) in Starknet illustrates this idea. UDC, a public utility, helps deploy contracts. This mirrors how in C++, a declared function is called to perform tasks.
In computer science terms, think of how functions operate in C++. After declaring a function or object, you invoke it to take action. Starknet's Invoke transaction works similarly, activating pre-declared contracts or functions.
Every Invoke transaction in Starknet undergoes __validate__
and __execute__
stages. The __validate__
step checks the transaction's correctness, similar to a syntax or logic check. After validation, the __execute__
phase processes the transaction.
This two-step process, focusing on utilizing existing functionalities, highlights Starknet's distinct transaction strategy.
Deploy Account Transactions
A challenge arises: How do you set up accounts without having one already? When creating your first smart wallet contract, deployment fees arise. How do you cover these without a smart wallet? The solution is deploy account transactions.
Uniquely in Starknet, addresses can accept funds even without an associated smart wallet. This trait is pivotal during deployment. Before an account is formally created, the __validate__
function checks the proposed deployment address (even if it lacks a smart wallet) for sufficient funds. If present, the constructor proceeds, resulting in account deployment. This method guarantees the new account's legitimacy and financial readiness.
Conclusion
It's vital to understand each transaction type. Declare transactions stand out for their role in presenting new functions. By likening the process to C++ declarations, developers can grasp the reasoning behind each transaction.
Transactions Lifecycle
This chapter outlines the path of a Starknet transaction from its initiation to its finalization.
Starknet processes transactions in distinct steps:
- A transaction starts by being sent to a gateway, a node, which acts as the Mempool.
- The Sequencer, currently a single service, first validates and then executes the transactions in order.
- If validated successfully, the status becomes RECEIVED.
- If not, the status is REJECTED.
- Successfully executed transactions are applied to the state and marked as ACCEPTED_ON_L2.
- Failed transactions during this phase are REVERTED.
- In the Prover stage, the system operates on the new block, computes its proof, and sends it to L1.
The following image shows the transaction flow:

Before exploring each step in-depth, let's clarify the different transaction statuses.
Each transaction has two primary status types:
- finality_status: Reflects the transaction's finality. Possible values are:
- RECEIVED: The transaction passed Mempool validation but hasn't been included in a block.
- ACCEPTED_ON_L2 and ACCEPTED_ON_L1: The transaction was added to a block on L2 or L1, respectively.
- execution_status: Indicates the transaction's execution outcome. Values include:
- REJECTED, REVERTED, or SUCCEEDED.
To obtain this information, query the transaction receipt returned by the Nodes. Refer to the Tooling chapter in the Starknet Book for methods like the transaction_receipt
command in starkli or the fetch_transaction_receipt
method in the starknet-py library. We will use these tools throughout this chapter.
Nonces in Starknet
Initially, Starknet did not incorporate nonces. This omission meant that the same transaction could be sent multiple times with an identical nonce, leading to duplicate hashes—a problem. In Ethereum, nonces not only sequence transactions but also ensure each has a unique hash. Similarly, Starknet employs nonces to assign a distinct hash to every transaction.
Starknet's current stance on nonces mandates that they be sequential. In other words, when you transmit a transaction from your account, its nonce must precisely follow the previous transaction's nonce.
Although nonce abstraction would allow developers to manage this logic at the smart contract level, Starknet is reviewing this feature. However, its implementation is not deemed a priority.
Transaction Creation
A transaction starts with its preparation. The sender:
- Queries their account nonce, which acts as a unique identifier for the transaction.
- Signs the transaction.
- Sends it to their Node.
The Node, analogous to a post office, receives the transaction and broadcasts it on the Starknet network, primarily to the Sequencer. As the network evolves, the transaction will be broadcasted to multiple Sequencers.
Before broadcasting the transaction to the Sequencer, the gateways perform a validation step, such as checking that the max fee exceeds a minimum fee and the account's balance is greater than the max fee. The transaction will be saved in the storage if the validation function passes.
The Sequencer's Role
On receiving the transaction, the Sequencer acknowledges its receipt but hasn't processed it yet—similar to Ethereum's mempool state.
Sequencer's Process:
- Receive the transaction.
- Validate it.
- Execute it.
- Update the state.
Remember, Starknet processes transactions sequentially. The nonce won't change until the Sequencer processes the transaction. This can complicate backend application development, potentially causing errors if sending multiple transactions consecutively.
Acceptance on Layer-2 (L2)
Once the Sequencer validates and executes a transaction, it updates the state without waiting for block creation. The transaction finality status changes from 'RECEIVED' to 'ACCEPTED ON L2' at this stage and the execution status to 'SUCCEEDED'.
Following the state update, the transaction is included in a block. However, the block isn't emitted immediately. The Sequencer decides the opportune moment to emit the block, either when there are enough transactions to form a block or after a certain time has passed. When the block is emitted, the block becomes available for other Nodes to query.
The transaction will have the following status:
- Finality status: ACCEPTED_ON_L2
- Execution status: SUCCEEDED
If a transaction fails during execution, it will be included in the block with the status 'REVERTED'. In other words, REVERTED transactions
It's essential to remember that at this stage, no proof has been generated, and the transaction relies on L2 consensus for security against censorship. There remains a slim possibility of transaction reversal if all Sequencers collude. Therefore, these stages should be seen as different layers of transaction finality.
Acceptance on Layer-1 (L1)
The final step in the transaction's lifecycle is its acceptance on Layer-1 (L1). A Prover receives the block containing the transaction, re-executes the block, generates a proof, and sends it to Ethereum. Specifically, the proof is sent to a smart contract on Ethereum called the Verifier smart contract, which checks the proof's validity. If valid, the transaction's status changes to 'accepted on L1', signifying the transaction's security by Ethereum consensus.
Transaction Status Transition:
- Accepted on L2 -> Accepted on L1
[Optional] Transaction Finality in Starknet
Transaction finality refers to the point at which a transaction is considered irreversible and is no longer susceptible to being reversed or undone. It's the assurance that once a transaction is committed, it can't be altered or rolled back, hence securing the integrity of the transaction and the system as a whole.
Let's dive into the transaction finality in both Starknet and Ethereum, and how they compare.
Ethereum Transaction Finality
Ethereum operates on a Proof of Stake (PoS) consensus mechanism. A transaction has the finality status when it is part of a block that can't change without a significant amount of ETH getting burned. The number of blocks required to ensure that a transaction won't be rolled back is called 'blocks to finality', and the time to create those blocks is called 'time to finality'.
It is considered to be an average of 6 blocks to reach the finality status; given that a new block is validated each 12 seconds, the average time to finality for a transaction is 75 seconds.
Starknet Transaction Finality
Starknet, a Layer-2 (L2) solution on Ethereum, has a two-step transaction finality process. The first step is when the transaction gets accepted on Layer-2 (Starknet), and the second step is when the transaction gets accepted on Layer-1 (Ethereum).
Accepted on L2: When a transaction is processed by the Sequencer and included in a block on Starknet, it reaches L2 finality. However, this finality relies on the L2 consensus and comes with a slight risk of collusion among Sequencers leading to transaction reversal. Accepted on L1: The absolute finality comes when the block containing the transaction gets a proof generated, the proof is validated by the Verifier contract on Ethereum, and the state is updated on Ethereum. At this point, the transaction is as secure as the Ethereum's PoW consensus can provide, meaning it becomes computationally infeasible to alter or reverse.
Comparison
The main difference between Ethereum and Starknet's transaction finality lies in the stages of finality and their reliance on consensus mechanisms.
Ethereum's transaction finality becomes increasingly unlikely to be reversed as more blocks are added. Starknet's finality process is two-fold. The initial finality (L2) is quicker but relies on L2 consensus and carries a small risk of collusion. The ultimate finality (L1) is slower, as it involves generation and validation of proofs and updates on Ethereum. However, once reached, it provides the same level of security as an Ethereum transaction.
REJECTED Transactions
When a transaction passes validation in the Mempool but fails during the sequencer's validate phase, it receives the REJECTED status. Such transactions are not included in any block and maintain the finality_status
as RECEIVED. This rejection can occur for reasons including:
- Check max_fee is higher than the minimal tx cost
- Check Account balance is at least max_fee
- Check nonce. A mismatched nonce, where the transaction's nonce doesn't align with the account's expected next nonce.
- Execute validate (here a repeated contract declaration will fail and the transaction will be rejected)
- Limit #txs per account in the Gateway
Such transaction will have the following status:
- Finality status: RECEIVED
- Execution status: REJECTED
To demonstrate a transaction with an invalid nonce, consider the Python code below (get_transaction_receipt.py
). Using the starknet-py
library, it fetches a rejected transaction:
import asyncio
from starknet_py.net.gateway_client import GatewayClient
async def fetch_transaction_receipt(transaction_id: str, network: str = "testnet"):
client = GatewayClient(network)
call_result = await client.get_transaction_receipt(transaction_id)
return call_result
receipt = asyncio.run(fetch_transaction_receipt("0x6d6e6575b85913ee8dfb170fe0db418f58f9422a0c6115350a79f9b38a1f5b8"))
print(receipt)
Execute the code with:
python3 get_transaction_receipt.py
The resulting transaction receipt will include:
execution_status=<TransactionExecutionStatus.REJECTED: 'REJECTED'>, finality_status=<TransactionFinalityStatus.RECEIVED: 'RECEIVED'>,
block_number=None,
actual_fee=0
It's important to note that the user isn't charged a fee because the transaction didn't execute in the Sequencer.
Handling of Reverted Transactions
A transaction can be reverted due to failed execution, the transaction will still be included in a block, and the account will be charged for the resources consumed.
This adds a trust assumption for the Sequencer to be honest and non-censoring. In later versions, there will be an OS change that will enable the Sequencer to prove that a transaction failed and charge the correct amount of gas for it, thus making it censorship-resistant with provably failed transactions.
Transaction Status Transition
- Received -> Reverted
Transaction Lifecycle Summary
The following outlines the various steps in a transaction's lifecycle:

Conclusion
The lifecycle of a Starknet transaction is a carefully curated journey, ensuring efficient, secure, and transparent transaction processing. It incorporates everything from transaction creation, Sequencer processing, Layer-2 and Layer-1 validation, to handling rejected and reverted transactions. By comprehending this lifecycle, developers and users can better navigate the Starknet ecosystem and leverage its capabilities to the fullest.
Fee Mechanism
NOTE: This section is a work in progress. Contributions are welcome.
Implementing a fee system enhances Starknet's performance. Without fees, the system risks becoming overwhelmed by numerous transactions, even with optimizations.
Fee Collection
When a transaction occurs on Layer 2 (L2), Starknet collects the corresponding fee using ERC-20 tokens. The transaction submitter pays the fee, and the sequencer receives it.
Fee Calculation
Fee Measurement
Currently, fees are denominated in ETH. To determine the expected fee, multiply the transaction's gas estimate by the gas price:
expected_fee = gas_estimate * gas_price;
Fee Computation
To grasp fee computation, understand these terms:
-
Built-In: These are predefined operations in your code, simplifying common tasks or calculations. The following are built-ins:
- Cairo Steps: These building blocks in Cairo facilitate various program operations. Essential for running smart contracts and apps on blockchain platforms, the steps used influence a program's cost and efficiency.
- Pedersen Hashes: A method to convert data into a distinct code, similar to a data fingerprint, ensuring data integrity on blockchains.
- Range Checks: Safety measures in programs, ensuring numbers or values stay within designated limits to avoid errors.
- Signature Verifications: These confirm that a digital signature matches the anticipated one, verifying the sender's authenticity.
-
Weight: Indicates the significance or cost of an operation, showing how resource-intensive an action is in the program.
Computation
In Cairo, each execution trace is divided into distinct slots dedicated to specific built-in components, influencing fee calculation.
Consider a trace containing the following component limits:
Component | Limit |
---|---|
Cairo Steps | 200,000,000 |
Pedersen Hashes | 5,000,000 |
Signature Verifications | 1,000,000 |
Range Checks | 2,500,000 |
When a component reaches its maximum, the proof is sent to Layer 1. It's imperative to set these component divisions beforehand as they cannot be adjusted dynamically.
Assuming a transaction utilizes 10,000 Cairo steps and 500 Pedersen hashes, it could accommodate 40,000 such transactions in this trace (given the calculation 20,000,000/500). The gas price becomes 1/40,000 of the proof submission cost. In this instance, the number of Cairo steps isn't the constraining factor, so it isn't factored into our performance estimate.
Typically, the sequencer determines a vector, CairoResourceUsage
, for every transaction. This vector accounts for:
- The count of Cairo steps.
- The application count of each Cairo built-in (like range checks and Pedersen hashes).
The sequencer then pairs this data with the CairoResourceFeeWeights
vector, dictating the gas cost of each proof component.
For instance:
If a proof with 20,000,000 Pedersen hashes costs 5 million gas, then the Pedersen built-in has a weight of 0.25 gas per use (calculated as 5,000,000/20,000,000). Sequencers set these weight values based on proof parameters.
The fee is determined by the most restrictive component and is calculated as:
maxk[CairoResourceUsagek * CairoResourceFeeWeightsk]
Where "k" denotes the Cairo resource elements, encompassing step numbers and built-ins. The weightings for these components are:
Component | Gas Cost | Range |
---|---|---|
Cairo Step | 0.01 gwei/gas | per step |
Pedersen | 0.32 gwei/gas | per application |
Poseidon | 0.32 gwei/gas | per application |
Range Check | 0.16 gwei/gas | per application |
ECDSA | 20.48 gwei/gas | per application |
Keccak | 20.48 gwei/gas | per application |
Bitwise | 0.64 gwei/gas | per application |
EC_OP | 10.24 gwei/gas | per application |
Sequencers
Before diving in, make sure to check out the "Understanding Starknet: Sequencers, Provers, and Nodes" chapter for a quick exploration of Starknet’s architecture.
Three main layers exist in blockchain: data availability, ordering, and execution. Sequencers have evolved within this evolving modular landscape of blockchain technology. Most L1 blockchains, like Ethereum, handle all these tasks. Initially, blockchains served as distributed virtual machines focused on organizing and executing transactions. Even roll-ups running on Ethereum today often centralize sequencing (ordering) and execution while relying on Ethereum for data availability. This is the current state of Starknet, which uses Ethereum for data availability and a centralized Sequencer for ordering and execution. However, it is possible to decentralize sequencing and execution, as Starknet is doing.
Each of these layers plays a crucial role in achieving consensus. First, the data must be available. Second, it needs to be put in a specific order. That’s the main job of a Sequencer, whether run by a single computer or a decentralized protocol. Lastly, you execute transactions in the order they’ve been sequenced. This final step, done by the Sequencer too, determines the system’s current state and keeps all connected clients on the same page.
Introduction to Sequencers
The advent of Layer Two (L2) solutions like Roll-Ups has altered the blockchain landscape, improving scalability and efficiency. But what about transaction order? Is it still managed by the base layer (L1), or is an external system involved? Enter Sequencers. They ensure transactions are in the correct order, regardless of whether they’re managed by L1 or another system.
In essence, sequencing has two core tasks: sequencing (ordering) and executing (validation). First, it orders transactions, determining the canonical sequence of blocks for a given chain fork. It then appends new blocks to this sequence. Second, it executes these transactions, updating the system’s state based on a given function.
To clarify, we see sequencing as the act of taking a group of unordered transactions and producing an ordered block. Sequencers also confirm the resulting state of the machine. However, the approach explained here separates these tasks. While some systems handle both ordering and state validation simultaneously, we advocate for treating them as distinct steps.

Sequencer role in the Starknet network
Sequencers in Starknet
Let’s delve into Sequencers by focusing on Madara and Kraken, two high-performance Starknet Sequencers. A Sequencer must, at least, do two things: order and execute transactions.
-
Ordering: Madara handles the sequencing process, supporting methods from simple FCFS and PGA to complex ones like Narwhall & Bullshark. It also manages the mempool, a critical data structure that holds unconfirmed transactions. Developers can choose the consensus protocol through Madara’s use of Substrate, which offers multiple built-in options.
-
Execution: Madara lets you choose between two execution crates: Blockifier and Starknet_in_Rust. Both use the Cairo VM for their framework.
We also have the Kraken Sequencer as another option.
-
Ordering: It employs Narwhall & Bullshark for mempool management. You can choose from multiple consensus methods, like Bullshark, Tendermint, or Hotstuff.
-
Execution: Runs on Starknet_in_Rust. Execution can be deferred to either Cairo Native or Cairo VM.
Feature | Madara | Kraken |
---|---|---|
Ordering Method |
FCFS, PGA, Narwhall & Bullshark |
Narwhall & Bullshark |
Mempool Management |
Managed by Madara |
Managed using Narwhall & Bullshark |
Consensus Options |
Developer’s choice through Substrate |
Bullshark, Tendermint or Hotstuff |
Execution Crates |
Blockifier, Starknet_in_rust |
Starknet_in_rust |
Execution Framework |
Understanding the Execution Layer
-
Blockifier, a Rust component in Starknet Sequencers, generates state diffs and blocks. It uses Cairo VM. Its goal is to become a full Starknet Sequencer.
-
Starknet_in_Rust is another Rust component for Starknet that also generates state diffs and blocks. It uses Cairo VM.
-
Cairo Native stands out by converting Cairo’s Sierra code to MLIR. See an example here.
The Need for Decentralized Sequencers
For more details on the Decentralization of Starknet, refer to the dedicated subchapter in this Chapter.
Proving transactions doesn’t require to be decentralized (although in the near future Starknet will operate with decentralized provers). Once the order is set, anyone can submit a proof; it’s either correct or not. However, the process that determines this order should be decentralized to maintain a blockchain’s original qualities.
In the context of Ethereum’s Layer 1 (L1), Sequencers can be likened to Ethereum validators. They are responsible for creating and broadcasting blocks. This role is divided under the concept of "Proposer-Builder Separation" (PBS) (Hasu, 2023). Block builders form blocks (order the transactions), while block proposers, unaware of the block’s content, choose the most profitable one. This separation prevents transaction censorship at the protocol level. Currently, most Layer 2 (L2) Sequencers, including Starknet, perform both roles, which can create issues.
The drive toward centralized Sequencers mainly stems from performance issues like high costs and poor user experience on Ethereum for both data storage and transaction ordering. The challenge is scalability: how to expand without sacrificing decentralization. Opting for centralization risks turning the blockchain monopolistic, negating its unique advantages like network-effect services without monopoly.
With centralization, blockchain loses its core principles: credible neutrality and resistance to monopolization. What’s wrong with a centralized system? It raises the risks of censorship (via transaction reordering).
A centralized validity roll-up looks like this:
-
User Interaction & Selection: Users send transactions to a centralized Sequencer, which selects and orders them.
-
Block Formation: The Sequencer packages these ordered transactions into a block.
-
Proof & Verification: The block is sent to a proving service, which generates a proof and posts it to Layer 1 (L1) for verification.
-
Verification: Once verified on L1, the transactions are considered finalized and integrated into the L1 blockchain.

Centralized rollup
While centralized roll-ups can provide L1 security, they come with a significant downside: the risk of censorship. Hence, the push for decentralization in roll-ups.
Conclusion
This chapter has dissected the role of Sequencers in the complex ecosystem of blockchain technology, focusing on Starknet’s current state and future directions. Sequencers essentially serve two main functions: ordering transactions and executing them. While these tasks may seem straightforward, they are pivotal in achieving network consensus and ensuring security.
Given the evolving modular architecture of blockchain—with distinct layers for data availability, transaction ordering, and execution—Sequencers provide a crucial link. Their role gains more significance in the context of Layer 2 solutions, where achieving scalability without sacrificing decentralization is a pressing concern.
In Starknet, Sequencers like Madara and Kraken demonstrate the potential of high-performance, customizable solutions. These Sequencers allow for a range of ordering methods and execution frameworks, proving that there’s room for innovation even within seemingly rigid structures.
The discussion on "Proposer-Builder Separation" (PBS) highlights the need for role specialization to maintain a system’s integrity and thwart transaction censorship. This becomes especially crucial when we recognize that the current model of many L2 Sequencers, Starknet included, performs both proposing and building, potentially exposing the network to vulnerabilities.
To reiterate, Sequencers aren’t just a mechanism for transaction ordering and execution; they are a linchpin in blockchain’s decentralized ethos. Whether centralized or decentralized, Sequencers must strike a delicate balance between scalability, efficiency, and the overarching principle of decentralization.
As blockchain technology continues to mature, it’s worth keeping an eye on how the role of Sequencers evolves. They hold the potential to either strengthen or weaken the unique advantages that make blockchain technology so revolutionary.
Madara 🚧
TODO: ADD EXAMPLES OF HOW TO SET UP AND USE MADARA
Madara is a Starknet sequencer that operates on the Substrate framework, executing Cairo programs and Starknet smart contracts with the Cairo VM. Madara enables the launch and control of Starknet Appchains or L3s.
Get Started with Madara
Visit the GitHub repository for detailed instructions on installing and configuring Madara, including practical examples.
TODO: ADD EXAMPLES OF HOW TO SET UP AND USE MADARA
Provers
SHARP is like public transportation for proofs on Starknet, aggregating multiple Cairo programs to save costs and boost efficiency. It uses recursive proofs, allowing parallelization and optimization, making it more affordable for all users. Critical services like the gateway, validator, and Prover work together with a stateless design for flexibility. SHARP’s adoption by StarkEx, Starknet, and external users (through the Cairo Playground) highlights its significance and potential for future optimization.
This chapter will discuss SHARP, how it has evolved to incorporate recursive proofs, and its role in reducing costs and improving efficiency within the Starknet network.
What is SHARP?
SHARP, which stands for "Shared Prover", is a mechanism used in Starknet that aggregates multiple Cairo programs from different users, each containing different logic. These Cairo programs are then executed together, generating a single proof common to all the programs. Rather than sending the proof directly to the Solidity Verifier in Ethereum, it is initially sent to a STARK Verifier program written in Cairo. The STARK Verifier generates a new proof to confirm that the initial proofs were verified, which can be sent back into SHARP and the STARK Verifier. This recursive proof process will be discussed in more detail later in this chapter. Ultimately, the last proof in the series is sent to the Solidity Verifier on Ethereum. In other words, there are many proofs generated until we reach Ethereum and the Solidity Verifier.
The primary benefit of SHARP system lies in its ability to decrease costs and enhance efficiency within the Starknet network. It achieves this by aggregating multiple Cairo jobs, which are individual sets of computations. This aggregation allows the protocol to leverage the exponential amortization offered by STARK proofs.
Exponential amortization means that as the computational load of the proofs increases, the cost of verifying those proofs rises at a slower logarithmic rate than the computation increase. In other words, the computation itself grows slower than the verification cost. As a result, the cost of each transaction within the aggregated set is significantly reduced, making the overall process more cost-effective and accessible for users.
In SHARP and Cairo context, "jobs" refer to the individual Cairo programs or tasks submitted by different users. These jobs contain specific logic or computations that must be executed on the Starknet network.
Additionally, SHARP allows smaller users with limited computation to benefit from joining other jobs and share the cost of generating the proofs. This collaborative approach is similar to using public transportation instead of a private car, where the cost is distributed among all participants, making it more affordable for everyone.
Recursive Proofs in SHARP
One of the most powerful features of SHARP is its use of recursive proofs. Rather than directly sending the generated proofs to the Solidity Verifier, they are first sent to a STARK Verifier program written in Cairo. This Verifier, which is also a Cairo Program, receives the proof and creates a new Cairo job that is sent to the Prover. The Prover then generates a new proof to confirm that the initial proofs were verified. These new proofs can be sent back into SHARP and the STARK Verifier, restarting the process.
This process continues recursively, with each new proof being sent to the Cairo Verifier until a trigger is reached. At this point, the last proof in the series is sent to the Solidity Verifier on Ethereum. This approach allows for greater parallelization of the computation and reduces the time and cost associated with generating and verifying proofs.
Generated Proofs
|
V
STARK Verifier program (in Cairo)
|
V
Cairo Job
|
V
Prover
|
V
New Proof Generated
|
V
Repeat Process
|
V
Trigger Reached (last proof)
|
V
Solidity Verifier
At first glance, recursive proofs may seem more complex and time-consuming. However, there are several benefits to this approach:
-
Parallelization: Recursive proofs allow for work parallelization, reducing user latency and improving SHARP efficiency.
-
Cheaper on-chain costs: Parallelization enables SHARP to create larger proofs, which would have previously been limited by the availability of large cloud machines (which are rare and limited). As a result, on-chain costs are reduced.
-
Lower cloud costs: Since each job is shorter, the required memory for processing is reduced, resulting in lower cloud costs.
-
Optimization: Recursive proofs enable SHARP to optimize for various factors, including latency, on-chain costs, and time to proof.
-
Cairo support: Recursive proofs only require support in Cairo, without the need to add support in the Solidity Verifier.
Latency in Starknet encompasses the time taken for processing, confirming, and including transactions in a block. It is affected by factors like network congestion, transaction fees, and system efficiency. Minimizing latency ensures faster transaction processing and user feedback.
Time to proof, however, specifically pertains to the duration required to generate and verify cryptographic proofs for transactions or operations.
SHARP Backend Architecture and Data Pipeline
SHARP back end architecture consists of several services that work together to process Cairo jobs and generate proofs. These services include:
-
Gateway: Cairo jobs enter SHARP through the gateway.
-
Job Creator: It prevents job duplication and ensures that the system operates consistently, regardless of multiple identical requests.
-
Validator: This is the first important step. The validator service runs validation checks on each job, ensuring they meet the requirements and can fit within the prover machines. Invalid jobs are tagged as such and do not proceed to the Prover.
-
Scheduler: The scheduler service creates "trains" that aggregate jobs and send them to the Prover. Recursive jobs are paired and sent to the Prover together.
-
Cairo Runner: This service runs Cairo for the Prover’s needs. The Cairo Runner service runs Cairo programs, executing the necessary computations and generating the execution trace as an intermediate result. The Prover then uses this execution trace.
-
Prover: The Prover computes the proofs for each train (that contains a few jobs).
-
Dispatcher: The Dispatcher serves two functions in the SHARP system.
-
In the case of a recursive proof, the Dispatcher runs the Cairo Verifier program on the proof it has received from the Prover, resulting in a new Cairo job that goes back to the Validator.
-
In the case of a proof that needs to go on chain (e.g., to Ethereum), the Dispatcher creates "packages" from the proof, which can then be sent to the Blockchain Writer.
-
-
Blockchain Writer: Once the packages have been created by the Dispatcher, they are sent to the Blockchain Writer. The Blockchain Writer is responsible for sending the packages to the appropriate blockchain (e.g., Ethereum) for verification. This is an important step in the SHARP system, as it ensures that the proofs are properly verified and that the transactions are securely recorded on the blockchain.
-
Catcher: The Catcher monitors blockchain (e.g., Ethereum) transactions to ensure that they have been accepted. While the Catcher is relevant for internal monitoring purposes, it is important to note that if a transaction fails, the fact won’t be registered on-chain in the fact registry. As a result, the soundness of the system is still preserved even without the catcher.
SHARP is designed to be stateless (each Cairo job is executed in its own context and has no dependency on other jobs), allowing for greater flexibility in processing jobs.
Current SHARP Users
Currently, the primary users of SHARP include:
-
StarkEx
-
Starknet
-
External users who use the Cairo Playground
Challenges and Optimization
Optimizing the Prover involves numerous challenges and potential projects on which the Starkware team and the community are currently working:
-
Exploring more efficient hash functions: SHARP is constantly exploring more efficient hash functions for Cairo, the Prover, and Solidity.
-
Investigating smaller fields: Investigating smaller fields for recursive proof steps could lead to more efficient computations.
-
Adjusting various parameters: SHARP is continually adjusting various parameters of the STARK protocol, such as FRI parameters and block factors.
-
Optimizing the Cairo code: SHARP is optimizing the Cairo code to make it faster, resulting in a faster recursive prover.
-
Developing dynamic layouts: This will allow Cairo programs to scale resources depending on their needs.
-
Improving scheduling algorithm: This is another optimization path that can be taken. It is not within the Prover itself.
In particular, dynamic layouts (you can learn more about layouts here (TODO)) will allow Cairo programs to scale resources depending on their needs. This can lead to more efficient computation and better utilization of resources. Dynamic layouts allow SHARP to determine the required resources for a specific job and adjust the layout accordingly instead of relying on predefined layouts with fixed resources. This approach can provide tailored solutions for each job, improving overall efficiency.
Conclusion
In conclusion, SHARP is a critical component of Starknet’s architecture, providing a more efficient and cost-effective solution for processing Cairo programs and verifying their proofs. By leveraging the power of STARK technology and incorporating recursive proofs, SHARP plays a vital role in improving the overall performance and scalability of the Starknet network. The stateless nature of SHARP and the reliance on the cryptographic soundness of the STARK proving system make it an innovative and valuable addition to the blockchain ecosystem.
Nodes
This chapter will guide you through setting up and running a Starknet node, illustrating the layered tech stack concept, and explaining how to operate these protocols locally. Starknet, as a Layer 2 Validity Rollup, operates on top of Ethereum Layer 1, creating a protocol stack that each addresses different functionalities, similar to the OSI model for internet connections. This chapter is an edit of drspacemn's blog.
CONTRIBUTE: This guide shows how to run a Starknet node locally with a particular setup. You can contribute to this guide by adding more options for hardware and software, as well as other ways to run a Starknet nod (for example using Beerus). You can also contribute by adding more information about the Starknet stack and the different layers. Feel free to open a PR.
What is a Node in the Context of Ethereum and Blockchain?
In the context of Ethereum and blockchain, a node is an integral part of the network that validates and relays transactions. Nodes download a copy of the entire blockchain and are interconnected with other nodes to maintain and update the blockchain state. There are different types of nodes, such as full nodes, light nodes, and mining nodes, each having different roles and responsibilities within the network.
Overview of Starknet Technology
Starknet is a permissionless, zk-STARK-based Layer-2 network, aiming for full decentralization. It enables developers to build scalable decentralized applications (dApps) and utilizes Ethereum’s Layer 1 for proof verification and data availability. Key aspects of Starknet include:
-
Cairo execution environment: Cairo, the execution environment of Starknet, facilitates writing and execution of complex smart contracts.
-
Scalability: Starknet achieves scalability through zk-STARK proofs, minimizing the data needed to be posted on-chain.
-
Node network: The Starknet network comprises nodes that synchronize and process transactions, contributing to the network’s overall security and decentralization.
Starknet Stack
The Starknet stack can be divided into various layers, similar to OSI or TCP/IP models. The most appropriate model depends on your understanding and requirements. A simplified version of the modular blockchain stack might look like this:
-
Layer 1: Data Layer
-
Layer 2: Execution Layer
-
Layer 3: Application Layer
-
Layer 4: Transport Layer

Modular blockchain layers
Setup
There are various hardware specifications, including packaged options, that will enable you to run an Ethereum node from home. The goal here is to build the most cost-efficient Starknet stack possible (see here more options).
Minimum Requirements:
-
CPU: 2+ cores
-
RAM: 4 GB
-
Disk: 600 GB
-
Connection Speed: 8 mbps/sec
Recommended Specifications:
-
CPU: 4+ cores
-
RAM: 16 GB+
-
Disk 2 TB
-
Connection Speed: 25+ mbps/sec
You can refer to these links for the hardware:
Total — $553
Recommended operating system and software: Ubuntu LTS, Docker, and Docker Compose. Ensure you have the necessary tools installed with:
sudo apt install -y jq curl net-tools
Layer 1: Data Layer
The bottom-most layer of the stack is the data layer. Here, Starknet’s L2 leverages Ethereum’s L1 for proof verification and data availability. Starknet utilizes Ethereum as its L1, so the first step is setting up an Ethereum Full Node. As this is the data layer, the hardware bottleneck is usually the disk storage. It’s crucial to have a high capacity I/O SSD over an HDD because Ethereum Nodes require both an Execution Client and a Consensus Client for communication.
Ethereum provides several options for Execution and Consensus clients. Execution clients include Geth, Erigon, Besu (used here), Nethermind, and Akula. Consensus clients include Prysm, Lighthouse (used here), Lodestar, Nimbus, and Teku.
Your Besu/Lighthouse node will take approximately 600 GB of disk space. Navigate to a partition on your machine with sufficient capacity and run the following commands:
git clone https://github.com/starknet-edu/starknet-stack.git
cd starknet-stack
docker-compose -f dc-l1.yaml up -d
This will begin the fairly long process of spinning up our Consensus Client, Execution Client, and syncing them to the current state of the Goerli Testnet. If you would like to see the logs from either process you can run:
# tail besu logs
docker container logs -f $(docker ps | grep besu | awk '{print $1}')
# tail lighthouse logs
docker container logs -f $(docker ps | grep lighthouse | awk '{print $1}')
Lets make sure that everything that should be listening is listening:
# should see all ports in command output
# besu ports
sudo netstat -lpnut | grep -E '30303|8551|8545'
# lighthouse ports
sudo netstat -lpnut | grep -E '5054|9000'
We’ve used docker to abstract a lot of the nuance of running an Eth L1 node, but the important things to note are how the two processes EL/CL point to each other and communicate via JSON-RPC:
services:
lighthouse:
image: sigp/lighthouse:latest
container_name: lighthouse
volumes:
- ./l1_consensus/data:/root/.lighthouse
- ./secret:/root/secret
network_mode: "host"
command:
- lighthouse
- beacon
- --network=goerli
- --metrics
- --checkpoint-sync-url=https://goerli.beaconstate.info
- --execution-endpoint=http://127.0.0.1:8551
- --execution-jwt=/root/secret/jwt.hex
besu:
image: hyperledger/besu:latest
container_name: besu
volumes:
- ./l1_execution/data:/var/lib/besu
- ./secret:/var/lib/besu/secret
network_mode: "host"
command:
- --network=goerli
- --rpc-http-enabled=true
- --data-path=/var/lib/besu
- --data-storage-format=BONSAI
- --sync-mode=X_SNAP
- --engine-rpc-enabled=true
- --engine-jwt-enabled=true
- --engine-jwt-secret=/var/lib/besu/secret/jwt.hex
Once this is done, your Ethereum node should be up and running, and it will start syncing with the Ethereum network.
Layer 2: Execution Layer
The next layer in our Starknet stack is the Execution Layer. This layer is responsible for running the Cairo VM, which executes Starknet smart contracts. The Cairo VM is a deterministic virtual machine that allows developers to write complex smart contracts in the Cairo language. Starknet uses a similar JSON-RPC spec as Ethereum in order to interact with the execution layer.
In order to stay current with the propagation of the Starknet blockchain we need a client similar to Besu that we are using for L1. The efforts to provide full nodes for the Starknet ecosystem are: Pathfinder (used here), Papyrus, and Juno. However, different implementations are still in development and not yet ready for production.
Check that your L1 has completed its sync:
# check goerli etherscan to make sure you have the latest block https://goerli.etherscan.io
curl --location --request POST 'http://localhost:8545' \
--header 'Content-Type: application/json' \
--data-raw '{
"jsonrpc":"2.0",
"method":"eth_blockNumber",
"params":[],
"id":83
}'
# Convert the result, which is hex (remove 0x) to decimal. Example:
echo $(( 16#246918 ))
Start your L2 Execution Client and note that we are syncing Starknet’s state from our LOCAL ETH L1 NODE!
PATHFINDER_ETHEREUM_API_URL=http://127.0.0.1:8545
# from starknet-stack project root
docker-compose -f dc-l2.yaml up -d
To follow the sync:
docker container logs -f $(docker ps | grep pathfinder | awk '{print $1}')
Starknet Testnet_1 currently comprises 800,000+ blocks so this will take some time (days) to sync fully. To check L2 sync:
# compare `current_block_num` with `highest_block_num`
curl --location --request POST 'http://localhost:9545' \
--header 'Content-Type: application/json' \
--data-raw '{
"jsonrpc":"2.0",
"method":"starknet_syncing",
"params":[],
"id":1
}'
To check data sizes:
sudo du -sh ./* | sort -rh
Layer 3: Application Layer
We see the same need for data refinement as we did in the OSI model. On L1 packets come over the wire in a raw stream of bytes and are then processed and filtered by higher-level protocols. When designing a decentralized application Bob will need to be cognizant of interactions with his contract on chain, but doesn’t need to be aware of all the information occurring on Starknet.
This is the role of an indexer. To process and filter useful information for an application. Information that an application MUST be opinionated about and the underlying layer MUST NOT be opinionated about.
Indexers provide applications flexibility as they can be written in any programming language and have any data layout that suits the application.
To start our toy indexer run:
./indexer/indexer.sh
Again notice that we don’t need to leave our local setup for these interactions (http://localhost:9545).
Layer 4: Transport Layer
The transport layer comes into play when the application has parsed and indexed critical information, often leading to some state change based on this information. This is where the application communicates the desired state change to the Layer 2 sequencer to get that change into a block. This is achieved using the same full-node/RPC spec implementation, in our case, Pathfinder.
When working with our local Starknet stack, invoking a transaction locally might look like this:
curl --location --request POST 'http://localhost:9545' \
--header 'Content-Type: application/json' \
--data-raw '{
"jsonrpc": "2.0",
"method": "starknet_addInvokeTransaction",
"params": {
"invoke_transaction": {
"type": "INVOKE",
"max_fee": "0x4f388496839",
"version": "0x0",
"signature": [
"0x7dd3a55d94a0de6f3d6c104d7e6c88ec719a82f4e2bbc12587c8c187584d3d5",
"0x71456dded17015d1234779889d78f3e7c763ddcfd2662b19e7843c7542614f8"
],
"contract_address": "0x23371b227eaecd8e8920cd429d2cd0f3fee6abaacca08d3ab82a7cdd",
"calldata": [
"0x1",
"0x677bb1cdc050e8d63855e8743ab6e09179138def390676cc03c484daf112ba1",
"0x362398bec32bc0ebb411203221a35a0301193a96f317ebe5e40be9f60d15320",
"0x0",
"0x1",
"0x1",
"0x2b",
"0x0"
],
"entry_point_selector": "0x15d40a3d6ca2ac30f4031e42be28da9b056fef9bb7357ac5e85627ee876e5ad"
}
},
"id": 0
}'
However, this process involves setting up a local wallet and signing the transaction. For simplicity, we will use a browser wallet and StarkScan.
Steps:
-
Navigate to the contract on StarkScan and connect to your wallet.
-
Enter a new value and write the transaction:

Starkscan block explorer
Once the transaction is accepted on the Layer 2 execution layer, the event data should come through our application layer indexer.
Example Indexer Output:
Pulled Block #: 638703
Found transaction: 0x2053ae75adfb4a28bf3a01009f36c38396c904012c5fc38419f4a7f3b7d75a5
Events to Index:
[
{
"from_address": "0x806778f9b06746fffd6ca567e0cfea9b3515432d9ba39928201d18c8dc9fdf",
"keys": [
"0x1fee98324df9b8703ae8de6de3068b8a8dce40c18752c3b550c933d6ac06765"
],
"data": [
"0xa"
]
},
{
"from_address": "0x126dd900b82c7fc95e8851f9c64d0600992e82657388a48d3c466553d4d9246",
"keys": [
"0x5ad857f66a5b55f1301ff1ed7e098ac6d4433148f0b72ebc4a2945ab85ad53"
],
"data": [
"0x2053ae75adfb4a28bf3a01009f36c38396c904012c5fc38419f4a7f3b7d75a5",
"0x0"
]
},
{
"from_address": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
"keys": [
"0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9"
],
"data": [
"0x126dd900b82c7fc95e8851f9c64d0600992e82657388a48d3c466553d4d9246",
"0x46a89ae102987331d369645031b49c27738ed096f2789c24449966da4c6de6b",
"0x17c1e31c270",
"0x0"
]
}
]
Once the transaction is accepted on Layer 1, we can query the Starknet Core Contracts from our Layer 1 node to see the storage keys that have been updated on our data layer!
You have successfully navigated through the entire Starknet stack, from setting up your node, through executing and monitoring a transaction, to inspecting its effects on the data layer. This journey has equipped you with the understanding and the skills to interact with Starknet on a deeper level.
Conclusion: Understanding the Modular Nature of Starknet
Conceptual models, such as the ones used in this guide, are incredibly useful in helping us understand complex systems. They can be refactored, reformed, and nested to provide a clear and comprehensive view of how a platform like Starknet operates. For instance, the OSI Model, a foundational model for understanding network interactions, underpins our modular stack.
A key concept to grasp is Fractal Scaling. This concept allows us to extend our model to include additional layers beyond Layer 2, such as Layer 3. In this extended model, the entire stack recurs above our existing stack, as shown in the following diagram:

Fractal scaling in a modular blockchain environment
Just as Layer 2 compresses its transaction throughput into a proof and state change that is written to Layer 1, we can apply the same compression principle at Layer 3, proving and writing to Layer 2. This not only gives us more control over the protocol rules but also allows us to achieve higher compression ratios, enhancing the scalability of our applications.
In essence, Starknet’s modular and layered design, combined with the power of Fractal Scaling, offers a robust and scalable framework for building decentralized applications. Understanding this structure is fundamental to effectively leveraging Starknet’s capabilities and contributing to its ecosystem.
This concludes our journey into running a Starknet node and traversing its layered architecture. We hope that you now feel equipped to explore, experiment with, and innovate within the Starknet ecosystem.
The Book is a community-driven effort created for the community.
-
If you’ve learned something, or not, please take a moment to provide feedback through this 3-question survey.
-
If you discover any errors or have additional suggestions, don’t hesitate to open an issue on our GitHub repository.
Layer 3 (App Chains)
Appchains let you create a blockchain designed precisely for your application’s needs. These specialized blockchains allow customization in various aspects, such as hash functions and consensus algorithms. Moreover, they inherit the security features of the Layer 1 or Layer 2 blockchains they are built upon.
Example:
Layer 3 blockchains can exist on top of Layer 2 blockchains. You can even build additional layers (Layer 4 and so on) on top of Layer 3 for more complex solutions. A sample layout is shown in the following diagram.

Example of an environment with a Layers 3 and 4
In this example ecosystem, Layer 3 options include:
-
The Public Starknet (L2), which is a general-purpose blockchain for decentralized applications.
-
A L3 Starknet optimized for cost-sensitive applications.
-
Customized L3 Starknet systems designed for enhanced performance, using specific storage structures or data compression techniques.
-
StarkEx systems used by platforms like dYdX and Sorare, offering proven scalability through data availability solutions like Validium or Rollup.
-
Privacy-focused Starknet instances, which could also function as a Layer 4, for conducting transactions without including them in public Starknets.
Benefits of Layer 3
Layer 3 app chains (with Madara as an apt sequencer or other option), offer a variety of advantages due to its modularity and flexibility. Here’s an overview of the key benefits:
-
Quick Iteration: App chains enable rapid protocol changes, freeing you from the constraints of the public Layer 2 roadmap. For example, you could rapidly deploy new DeFi algorithms tailored to your user base.
-
Governance Independence: You maintain complete control over feature development and improvements, avoiding the need for decentralized governance consensus. This enables, for example, quick implementation of user-suggested features.
-
Cost Efficiency: Layer 3 offers substantial cost reductions, potentially up to 1 million times compared to Layer 1, making it economically feasible to run more complex applications.
-
Security: While there may be some trade-offs, such as reduced censorship resistance, the core security mechanisms remain strong.
-
Congestion Avoidance: App chains are shielded from network congestion, providing a more stable transaction environment, crucial for real-time applications like gaming.
-
Privacy Enhancements: Layer 3 can serve as a testing ground for privacy-centric features, which could include anonymous transactions or encrypted messaging services.
-
Innovation Platform: App chains act as experimental fields where novel features can be developed and tested. For instance, they could serve as a testbed for new consensus algorithms before these are considered for Layer 2.
In summary, Layer 3 provides the flexibility, cost-efficiency, and environment conducive for innovation, without significant compromise on security.
Madara as a Sequencer for Layer 3 App Chains
Madara is a specialized sequencer developed to execute transactions and group them into batches. Created by the StarkWare Exploration Team, it functions as a starting point for building Layer 3 Starknet appchains. This expands the possibilities for innovation within the Starknet ecosystem.
Madara’s flexibility allows for the creation of Layer 3 appchains optimized for various needs, for example:
-
Cost-Efficiency: Create an appchain for running a decentralized exchange (DEX) with lower fees compared to the public Starknet.
-
Performance: Build an appchain to operate a DEX with faster transaction times.
-
Privacy: Design an appchain to facilitate anonymous transactions or encrypted messaging services.
For more information on Madara, refer to the subchapter with the same title.
Solidity Verifier
Before exploring this chapter, review the Starknet Architecture chapter for foundational knowledge. Familiarity with concepts such as Sequencers, Provers, SHARP, and Sharp Jobs is assumed.
Starknet's Solidity Verifier plays a pivotal role in the rollup landscape, ensuring the truth of transactions and smart contracts.
Quick Overview: SHARP and Sharp Jobs
NOTE: For a more detailed explanation of SHARP and Sharp Jobs, refer to the Provers subchapter in the Starknet Architecture chapter. This is a brief review.
SHARP, or Shared Prover, in Starknet, aggregates various Cairo programs from distinct users. These programs, each with unique logic, run together, producing a common proof for all, optimizing cost and efficiency.

Sharp Workflow
Furthermore, SHARP supports combining multiple proofs into one, enhancing its efficiency by allowing parallel proof processing and verification.
SHARP verifies numerous Starknet transactions, like transfers, trades, and state updates. It also confirms smart contract executions.
To illustrate SHARP: Think of commuting by bus. The bus driver, the prover, transports passengers, the Cairo programs. The driver checks only the tickets of passengers alighting at the upcoming stop, much like SHARP. The prover forms a single proof for all Cairo programs in a batch, but verifies only the proofs of programs executing in the succeeding block.
Sharp Jobs. Known as Shared Prover Jobs, Sharp Jobs let multiple users present their Cairo programs for combined execution, distributing the proof generation cost. This shared approach makes Starknet more economical for users, enabling them to join ongoing jobs and leverage economies of scale.
Solidity Verifiers
A Solidity verifier is an L1 smart contract, crafted in Solidity, designed to validate STARK proofs from SHARP (Shared Prover).
Previous Architecture: Monolithic Verifier
Historically, the Solidity Verifier was a monolithic contract, both initiated and executed by the same contract. For illustration, the operator would invoke the update state
function on the main contract, providing the state to be modified and confirming its validity. Subsequently, the main contract would present the proof to both the verifier and the validium committee. Once they validated the proof, the state would be updated in the main contract.

Previous Architecture
However, this architecture faced several constraints:
- Batching transactions frequently surpassed the original geth32kb transaction size limit (later adjusted to 128kb) due to accumulating excessive transactions.
- The gas required often outstripped the block size (e.g., 8 Mgas), as the block couldn't accommodate a complete batch of proof.
- A prospective constraint was that the verifier wouldn't support proof bundling, which is fundamental for SHARP.
Current Architecture: Multiple Smart Contracts
The current verifier utilizes multiple smart contracts rather than being a singular, monolithic structure.
Here are some key smart contracts associated with the verifier:
GpsStatementVerifier
: This is the primary contract of the Sharp verifier. It verifies a proof and then registers the related facts usingverifyProofAndRegister
. It acts as an umbrella for various layouts, each namedCpuFrilessVerifier
. Every layout has a unique combination of built-in resources.

The system routes each proof to its relevant layout.
-
MemoryPageFactRegistry
: This registry maintains facts for memory pages, primarily used to register outputs for data availability in rollup mode. The Fact Registry is a separate smart contract ensuring the verification and validity of attestations or facts. The verifier function is separated from the main contract to ensure each segment works optimally within its limits. The main proof segment relies on other parts, but these parts operate independently. -
MerkleStatementContract
: This contract verifies merkle paths. -
FriStatementContract
: It focuses on verifying the FRI layers.
Sharp Verifier Contract Map
The Sharp Verifier Contract Map contains roughly 40 contracts, detailing various components of the Solidity verifier. The images below display the contracts and their Ethereum Mainnet addresses.

Sharp Verifier Contract Map

Sharp Verifier Contract Map (Continued)
These contracts function as follows:
- Proxy: This contract facilitates upgradability. It interacts with the
GpsStatementVerifier
contract using thedelegate_call
method. Notably, the state resides in theGpsStatementVerifier
contract, not in the proxy. - CallProxy: Positioned between the
Proxy
and theGpsStatementVerifier
contracts, it functions like a typical proxy. However, it avoids thedelegate_call
method and calls the function in the implementation contract directly. - CairoBootloaderProgram: Comprising numerical Cairo programs, it validates the Cairo program of a statement. The bootloader manages the logic executing Cairo programs to generate proof and program hash.
- PedersenHashPoints (X & Y Column): These lookup tables store vast amounts of data. Validation functions consult them to compute the Pedersen hash.
- EcdsaPoints (X & Y Column): Similar to the Pedersen hash, these tables assist in calculating the elliptic curve.
- CpuFrilessVerifier/CpuOods/CpuConstantPoly (0 - 7): These verifier contracts vary in layout as shown in the
GpsStatementVerifier
layout image. Each layout encompasses resources, built-ins, constraints, and more, designed for a specific task. Each has unique parameters for its constructor. - PoseidonPoseidon: These contracts back the new Poseidon built-in and contain Poseidon-specific lookup tables.
Constructor Parameters of Key Contracts
When constructing the primary verifier contracts, specific parameters are employed to facilitate functionality. These parameters reference other auxiliary contracts, decentralizing the logic and ensuring the main contract remains under the 24kb deployment limit.
Below is a visual representation of these parameters in relation to key contracts CpuFrilessVerifiers
and GpsStatementVerifier
:

CpuFrilessVerifier Constructor Parameters
CpuFrilessVerifiers
is designed to handle a diverse range of tasks. Its parameters encompass:
- Auxiliary Polynomial Contracts: These include
CpuConstraintPoly
,PedersenHashPointsxColumn
,PedersenHashPointsYColumn
,EcdsaPointsXColumn
, andEcdsaPointsYColumn
. - Poseidon-Related Contracts: Several
PoseidonPoseidonFullRoundKey
andPoseidonPoseidonPartialRoundKey
contracts. - Sampling and Memory: The contract uses
CpuOods
for out-of-domain sampling andMemoryPageFactRegistry
for memory-related tasks. - Verification: It integrates with
MerkleStatementContract
for merkle verification andFriStatementContract
for Fri-related tasks. - Security: The
num_security_bits
andmin_proof_of_work_bits
contracts ensure secure operation.
NOTE: For instances like CpuFrilessVerifier0
, specific contracts (e.g., CpuConstraintPoly0
, PoseidonPoseidonFullRoundKeyColumn0
, CpuOods0
) become particularly relevant.
GpsStatementVerifier Constructor Parameters
The GpsStatementVerifier
functions as the hub of verifier operations, necessitating various parameters for effective functioning:
- Bootloader: It references the
CairoBootloaderProgram
to initiate the system. - Memory Operations: This is facilitated by the
MemoryPageFactRegistry
contract. - Sub-Verifiers: It integrates a series of sub-verifiers (
CpuFrilessVerifier0
throughCpuFrilessVerifier7
) to decentralize tasks. - Verification: The hashes,
hashed_supported_cairo_verifiers
andsimple_bootloader_program_hash
, are essential for validation processes.
Interconnection of Contracts
The GpsStatementVerifier
serves as the primary verifier contract, optimized for minimal logic to fit within deployment size constraints. To function effectively:
- It relies on smaller verifier contracts, which are already deployed and contain varied verification logic.
- These smaller contracts, in turn, depend on other contracts, established during their construction.
In essence, while the diverse functionalities reside in separate contracts for clarity and size efficiency, they are all interlinked within the GpsStatementVerifier
.
For future enhancements or adjustments, the proxy and callproxy contracts facilitate upgradability, allowing seamless updates to the GpsStatementVerifier
without compromising its foundational logic.
Sharp Verification Flow

Sharp Verification Flow
-
The Sharp dispatcher transmits all essential transactions for verification, including: a.
MemoryPages
(usually many). b.MerkleStatements
(typically between 3 and 5). c.FriStatements
(generally ranging from 5 to 15). -
The Sharp dispatcher then forwards the proof using
verifyProofAndRegister
. -
Applications, such as the Starknet monitor, validate the status. Once verification completes, they send an
updateState
transaction.
Conclusion
Starknet transformed the Solidity Verifier from a single unit to a flexible, multi-contract system, highlighting its focus on scalability and efficiency. Using SHARP and refining verification steps, Starknet makes sure the Solidity Verifier stays a strong cornerstone in its setup.
Decentralization 🚧
Account Abstraction
Account Abstraction (AA) represents an approach to managing accounts and transactions in blockchain networks. It involves two key concepts:
-
Transaction Flexibility:
- Smart contracts validate their transactions, moving away from a universal validation model.
- Benefits include smart contracts covering gas fees, supporting multiple signers for one account, and using alternative cryptographic signatures.
-
User Experience Optimization:
- AA enables developers to design flexible security models, such as using different keys for routine and high-value transactions.
- It offers alternatives to seed phrases for account recovery, simplifying the user experience.
Technically, AA replaces Externally Owned Accounts (EOA) with a broader account concept. In this model, accounts are smart contracts, each with its unique rules and behaviors. These rules can govern transaction ordering, signatures, access controls, and more, offering extensive customization.
Key Definitions of AA:
- Definition 1: As described by Martin Triay at Devcon 6, AA allows smart contracts to pay for their transactions. This shifts away from traditional Externally Owned Accounts or Smart Wallets.
- Definition 2: Lightclient at Devcon 6 defines AA as validation abstraction. Unlike Ethereum's Layer 1 single validation method, AA permits various signature types, cryptographic methods, and execution processes.
Applications of Account Abstraction
Account Abstraction (AA) enhances the accessibility and security of self-custody in blockchain technology. Here are a few of the features that AA enables:
-
Hardware Signer:
- AA enables transaction signing with keys stored in a smartphone’s secure enclave, incorporating biometric identity for enhanced security and ease of use.
-
Social Recovery:
- In case of lost or compromised keys, AA allows for secure key replacement, removing the need for seed phrases and simplifying the user experience.
-
Key Rotation:
- If a key is compromised, it can be easily replaced without needing to transfer assets.
-
Session Keys:
- AA facilitates a sign in once feature for web3 applications, allowing transactions on your behalf and minimizing constant approvals.
-
Custom Transaction Validation Schemes:
- AA supports various signature schemes and security rules, enabling tailored security measures for individual needs.
AA also bolsters security in several ways:
-
Improved Key Management:
- Multiple devices can be linked to your wallet, ensuring account access even if one device is lost.
-
Diverse Signature and Validation Schemes:
- AA accommodates additional security measures like two-factor authentication for substantial transactions, catering to individual security needs.
-
Custom Security Policies:
- Security can be customized for different user types or devices, incorporating best practices from banking and web2 sectors.
Ethereum Account System
Understanding Ethereum's current account system is important to appreciating the benefits of Account Abstraction (AA). Ethereum's account system comprises two types:
-
Externally Owned Accounts (EOAs):
- Used by individuals, wallets, or entities external to the Ethereum network.
- Identified by addresses derived from the public key of a cryptographic signer, which includes a private key and a public key.
- The private key signs transactions or messages to prove ownership, while the public key verifies the signature.
- Transactions must be signed by the EOA's private key to modify the account state, ensuring security through unique cryptographic identity.
-
Contract Accounts (CAs):
- Essentially smart contracts on the Ethereum blockchain.
- Lack private keys and are activated by transactions or messages from EOAs.
- Their behavior is defined by their code.
Challenges in the current account model include:
-
Key Management:
- Loss of a private key means irreversible loss of account control and assets.
- If stolen, the thief gains complete access to the account and its assets.
-
User Experience:
- The Ethereum account model currently lacks user-friendly key or account recovery options.
- Complex interfaces like crypto wallets can deter non-technical users, limiting broader adoption.
-
Lack of Flexibility:
- The traditional model restricts custom transaction validation schemes, limiting potential security and access control enhancements.
AA aims to address these issues, presenting opportunities for improved security, scalability, and user experience.
Why Isn’t Account Abstraction Implemented in Ethereum’s Layer 1 Yet?
Ethereum's Layer 1 (L1) currently lacks support for Account Abstraction (AA) at the protocol level, not due to a lack of interest or recognition of its value, but rather because of the complexity involved in its integration.
Key challenges to implementing AA in Ethereum’s L1 include:
-
Entrenched Nature of Externally Owned Accounts (EOAs):
- EOAs are integral to Ethereum's core protocol.
- Modifying them to support AA is a daunting task, especially as Ethereum's value and usage continue to grow.
-
Limitations of the Ethereum Virtual Machine (EVM):
- The EVM, Ethereum's smart contract runtime environment, faces limitations that obstruct AA implementation.
- Despite several AA proposals since Ethereum's inception, they have been delayed due to prioritization of other critical updates and improvements.
However, the rise of Layer 2 (L2) solutions offers a new avenue for AA:
- Layer 2 Solutions:
- Focused on scalability and performance, L2 solutions are more accommodating for AA.
- Platforms like Starknet and ZKSync, inspired by EIP4337, are pioneering native AA implementations.
Due to the ongoing delays and complexities of integrating AA into Ethereum’s L1, many advocates, have shifted their focus:
- Shift to Layer 2 Advocacy:
- Instead of waiting for EOAs to phase out and AA to integrate into Ethereum's core, proponents now support the adoption of AA through L2 solutions.
- This approach aims to deliver AA benefits to users more quickly and maintain Ethereum's competitive edge in the fast-evolving crypto space.
Conclusion
In this subchapter, we've looked at Account Abstraction (AA) in Ethereum. AA makes transactions more flexible and improves how users manage their accounts. It's set to solve problems like key management and user experience in Ethereum, but its integration into Ethereum's main layer has been slow due to technical hurdles and established systems.
Layer 2 solutions, however, are opening doors for AA. Starknet is a key player here.
Next, we'll get practical. You'll learn to code AA contracts in Starknet.
Account Contracts
With a clearer understanding of the AA concept, let's proceed to code it in Starknet.
Account Contract Interface
Account contracts, being a type of smart contracts, are distinguished by specific methods. A smart contract becomes an account contract when it follows the public interface outlined in SNIP-6 (Starknet Improvement Proposal-6: Standard Account Interface). This standard draws inspiration from SRC-6 and SRC-5, similar to Ethereum's ERCs, which establish application conventions and contract standards.
#![allow(unused)] fn main() { /// @title Represents a call to a target contract /// @param to The target contract address /// @param selector The target function selector /// @param calldata The serialized function parameters struct Call { to: ContractAddress, selector: felt252, calldata: Array<felt252> } /// @title SRC-6 Standard Account trait ISRC6 { /// @notice Execute a transaction through the account /// @param calls The list of calls to execute /// @return The list of each call's serialized return value fn __execute__(calls: Array<Call>) -> Array<Span<felt252>>; /// @notice Assert whether the transaction is valid to be executed /// @param calls The list of calls to execute /// @return The string 'VALID' represented as felt when is valid fn __validate__(calls: Array<Call>) -> felt252; /// @notice Assert whether a given signature for a given hash is valid /// @param hash The hash of the data /// @param signature The signature to validate /// @return The string 'VALID' represented as felt when the signature is valid fn is_valid_signature(hash: felt252, signature: Array<felt252>) -> felt252; } /// @title SRC-5 Standard Interface Detection trait ISRC5 { /// @notice Query if a contract implements an interface /// @param interface_id The interface identifier, as specified in SRC-5 /// @return `true` if the contract implements `interface_id`, `false` otherwise fn supports_interface(interface_id: felt252) -> bool; } }
From the proposal, an account contract should have the __execute__
, __validate__
, and is_valid_signature
methods from the ISRC6
trait.
The provided functions serve these purposes:
__validate__
: Validates a list of calls intended for execution based on the contract's rules. Instead of a boolean, it returns a short string like 'VALID' within afelt252
to convey validation results. In Cairo, this short string is the ASCII representation of a single felt. If verification fails, any felt other than 'VALID' can be returned. Often,0
is chosen.is_valid_signature
: Confirms the authenticity of a transaction's signature. It takes a transaction data hash and a signature, and compares it against a public key or another method chosen by the contract's author. The result is a short 'VALID' string within afelt252
.__execute__
: After validation,__execute__
carries out a series of contract calls (asCall
structs). It gives back an array ofSpan<felt252>
structs, showing the return values of those calls.
Moreover, the SNIP-5
(Standard Interface Detection) trait needs to be
defined with a function called supports_interface
. This function
verifies whether a contract supports a specific interface, receiving an
interface ID and returning a boolean.
#![allow(unused)] fn main() { trait ISRC5 { fn supports_interface(interface_id: felt252) -> bool; } }
In essence, when a user dispatches an invoke
transaction, the protocol initiates by invoking the __validate__
method. This verifies the associated signer's authenticity. For security reasons, particularly to safeguard the Sequencer from Denial of Service (DoS) attacks [1], there are constraints on the operations within the __validate__
method. If the signature is verified, the method yields a 'VALID'
felt252
value. If not, it returns 0.
After the protocol verifies the signer, it proceeds to invoke the __execute__
function, passing an array of all desired operations—referred to as "calls"—as an argument. Each of these calls specifies a target smart contract address (to
), the method to be executed (selector
), and the arguments this method requires (calldata
).
#![allow(unused)] fn main() { struct Call { to: ContractAddress, selector: felt252, calldata: Array<felt252> } trait ISRC6 { .... fn __execute__(calls: Array<Call>) -> Array<Span<felt252>>; .... } }
Executing a Call
may yield a return value from the target smart contract. Whether it's a felt252, boolean, or a more intricate data structure like a struct or array, Starknet protocol serializes the return using Span<felt252>
. Since Span
captures a segment of an Array [2], the __execute__
function outputs an array of Span<felt252>
elements. This array signifies the serialized feedback from every operation in the multicall.
The is_valid_signature
method isn't mandated or employed by the Starknet protocol. Instead, it's a convention within the Starknet developer community. Its purpose is to facilitate user authentication in web3 applications. For instance, consider a user attempting to log into an NFT marketplace using their digital wallet. The web application prompts the user to sign a message, then it uses the is_valid_signature
function to confirm the authenticity of the associated wallet address.
To ensure other smart contracts recognize the compliance of an account contract with the SNIP-6 public interface, developers should incorporate the supports_interface
method from the ISRC5
introspection trait. This method requires the Interface ID of SNIP-6 as its argument.
#![allow(unused)] fn main() { struct Call { to: ContractAddress, selector: felt252, calldata: Array<felt252> } trait ISRC6 { // Implementations for __execute__, __validate__, and is_valid_signature go here. } trait ISRC5 { fn supports_interface(interface_id: felt252) -> bool; } }
The interface_id
corresponds to the aggregated hash of the trait's selectors, as detailed in Ethereum's ERC165 [3]. Developers can either compute the ID using the src5-rs
utility [4] or rely on the pre-calculated ID: 1270010605630597976495846281167968799381097569185364931397797212080166453709
.
The fundamental structure for the account contract, aligning with the SNIP-G Interface standard, looks like this:
#![allow(unused)] fn main() { struct Call { to: ContractAddress, selector: felt252, calldata: Array<felt252> } trait ISRC6 { fn __execute__(calls: Array<Call>) -> Array<Span<felt252>>; fn __validate__(calls: Array<Call>) -> felt252; fn is_valid_signature(hash: felt252, signature: Array<felt252>) -> felt252; } trait ISRC5 { fn supports_interface(interface_id: felt252) -> bool; } }
Expanding the Interface
While the components mentioned earlier lay the foundation for an account contract in alignment with the SNIP-6 standard, developers can introduce more features to enhance the contract's capabilities.
For example, integrate the __validate_declare__
function if the contract declares other contracts and handles the corresponding gas fees. This offers a way to authenticate the contract declaration. For those keen on counterfactual smart contract deployment, the __validate_deploy__
function can be included.
Counterfactual deployment lets developers set up an account contract without depending on another account contract for gas fees. This method is valuable when there's no desire to link a new account contract with its deploying address, ensuring a fresh start.
This approach involves:
- Locally determining the potential address of our account contract without actual deployment, feasible with the Starkli [5] tool.
- Transferring sufficient ETH to the predicted address to cover the deployment costs.
- Sending a
deploy_account
transaction to Starknet containing our contract's compiled code. The sequencer then activates the account contract at the estimated address, compensating its gas fees from the transferred ETH. Nodeclare
action is needed beforehand.
For better compatibility with tools like Starkli later on, expose the signer's public_key
through a view function in the public interface. Below is the augmented account contract interface:
#![allow(unused)] fn main() { /// @title IAccountAddon - Extended account contract interface trait IAccountAddon { /// @notice Validates if a declare transaction can proceed /// @param class_hash Hash of the smart contract under declaration /// @return 'VALID' string as felt, if valid fn __validate_declare__(class_hash: felt252) -> felt252; /// @notice Validates if counterfactual deployment can proceed /// @param class_hash Hash of the account contract under deployment /// @param salt Modifier for account address /// @param public_key Account signer's public key /// @return 'VALID' string as felt, if valid fn __validate_deploy__(class_hash: felt252, salt: felt252, public_key: felt252) -> felt252; /// @notice Fetches the signer's public key /// @return Public key fn public_key() -> felt252; } }
In conclusion, a comprehensive account contract incorporates the SNIP-5, SNIP-6, and the Addon interfaces.
#![allow(unused)] fn main() { // Cheat sheet struct Call { to: ContractAddress, selector: felt252, calldata: Array<felt252> } trait ISRC6 { fn __execute__(calls: Array<Call>) -> Array<Span<felt252>>; fn __validate__(calls: Array<Call>) -> felt252; fn is_valid_signature(hash: felt252, signature: Array<felt252>) -> felt252; } trait ISRC5 { fn supports_interface(interface_id: felt252) -> bool; } trait IAccountAddon { fn __validate_declare__(class_hash: felt252) -> felt252; fn __validate_deploy__(class_hash: felt252, salt: felt252, public_key: felt252) -> felt252; fn public_key() -> felt252; } }
Recap
We've broken down the distinctions between account contracts and basic smart contracts, particularly focusing on the methods laid out in SNIP-6.
-
Introduced the
ISRC6
trait, spotlighting essential functions:__validate__
: Validates transactions.is_valid_signature
: Verifies signatures.__execute__
: Executes contract calls.
-
Discussed the
ISRC5
trait and highlighted the importance of thesupports_interface
function in confirming interface support. -
Detailed the
Call
struct to represent a single contract call, explaining its components:to
,selector
, andcalldata
. -
Touched on advanced features for account contracts, such as the
__validate_declare__
and__validate_deploy__
functions.
Coming up, we'll craft a basic account contract and deploy it on Starknet, offering hands-on insight into their functionality and interactions.
Hello World! Account Contract
This section guides you through the creation of the simplest possible account contract, adhering to the SNIP-6 standard. The account contract will be the simplest implementation of an account contract, with the following features:
- Signature validation for transactions will be not enforced. In other words, every transaction will be considered valid no matter who signed it; there will be no pivate key.
- It will make a single call and not multicall in the execution phase.
- It will only implement the SNIP-6 standard which is the minimum to be considered an account contract.
We will deployed using starknet.py
and use it to deploy other contracts.
Setting Up Your Project
For deploying an account contract to Starknet's testnet or mainnet, use Scarb version 2.3.1, which is compatible with the Sierra 1.3.0 target supported by both networks. For the latest information, review the Starknet Release Notes. As of November 2023, Scarb version 2.3.1 is the recommended choice.
To check your current Scarb version, run:
scarb --version
To install or update Scarb, refer to the Basic Installation instructions in Chapter 2, covering macOS and Linux environments:
curl --proto '=https' --tlsv1.2 -sSf https://docs.swmansion.com/scarb/install.sh | sh
Starting a New Scarb Project
Begin by creating a new project (more details in the Scarb subchapter in Chapter 2):
scarb new hello_account
Check the generated project structure:
$ tree .
.
└── hello_account
├── Scarb.toml
└── src
└── lib.cairo
By default, Scarb sets up for vanilla Cairo. Add Starknet capacities by editing Scarb.toml
to include the starknet
dependency:
[package]
name = "hello_account"
version = "0.1.0"
[dependencies]
starknet = ">=2.3.0"
[[target.starknet-contract]]
sierra = true
casm = true
casm-add-pythonic-hints = true
Replace the code in src/lib.cairo
with the Hello World account contract:
#![allow(unused)] fn main() { use starknet::account::Call; // IERC6 obtained from Open Zeppelin's cairo-contracts/src/account/interface.cairo #[starknet::interface] trait ISRC6<TState> { fn __execute__(self: @TState, calls: Array<Call>) -> Array<Span<felt252>>; fn __validate__(self: @TState, calls: Array<Call>) -> felt252; fn is_valid_signature(self: @TState, hash: felt252, signature: Array<felt252>) -> felt252; } #[starknet::contract] mod HelloAccount { use starknet::VALIDATED; use starknet::account::Call; use starknet::get_caller_address; #[storage] struct Storage {} // Empty storage. No public key is stored. #[external(v0)] impl SRC6Impl of super::ISRC6<ContractState> { fn is_valid_signature( self: @ContractState, hash: felt252, signature: Array<felt252> ) -> felt252 { // No signature is required so any signature is valid. VALIDATED } fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { let hash = 0; let mut signature: Array<felt252> = ArrayTrait::new(); signature.append(0); self.is_valid_signature(hash, signature) } fn __execute__(self: @ContractState, calls: Array<Call>) -> Array<Span<felt252>> { let sender = get_caller_address(); assert(sender.is_zero(), 'Account: invalid caller'); let Call{to, selector, calldata } = calls.at(0); let _res = starknet::call_contract_syscall(*to, *selector, calldata.span()).unwrap(); let mut res = ArrayTrait::new(); res.append(_res); res } } } }
Compile your project to ensure the setup is correct:
scarb build
SNIP-6 Standard
To define an account contract, implement the ISRC6
trait:
#![allow(unused)] fn main() { #[starknet::interface] trait ISRC6<TState> { fn __execute__(self: @TState, calls: Array<Call>) -> Array<Span<felt252>>; fn __validate__(self: @TState, calls: Array<Call>) -> felt252; fn is_valid_signature(self: @TState, hash: felt252, signature: Array<felt252>) -> felt252; } }
The __execute__
and __validate__
functions are designed for exclusive use by the Starknet protocol to enhance account security. Despite their public accessibility, only the Starknet protocol can invoke these functions, identified by using the zero address. In this minimal account contract we will not enforce this restriction, but we will do it in the next examples.
Validating Transactions
The is_valid_signature
function is responsible for this validation, returning VALIDATED
if the signature is valid. The VALIDATED
constant is imported from the starknet
module.
#![allow(unused)] fn main() { use starknet::VALIDATED; }
Notice that the is_valid_signature
function accepts all the transactions as valid. We are not storing a public key in the contract, so we cannot validate the signature. We will add this functionality in the next examples.
#![allow(unused)] fn main() { fn is_valid_signature( self: @ContractState, hash: felt252, signature: Array<felt252> ) -> felt252 { // No signature is required so any signature is valid. VALIDATED } }
The __validate__
function calls the is_valid_signature
function with a dummy hash and signature. The __validate__
function is called by the Starknet protocol to validate the transaction. If the transaction is not valid, the execution of the transaction is aborted.
#![allow(unused)] fn main() { fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { let hash = 0; let mut signature: Array<felt252> = ArrayTrait::new(); signature.append(0); self.is_valid_signature(hash, signature) } }
In other words we have implemented a contract that accepts all the transactions as valid. We will add the signature validation in the next examples.
Executing Transactions
The __execute__
function is responsible for executing the transaction. In this minimal account contract we will only execute a single call. We will add the multicall functionality in the next examples.
#![allow(unused)] fn main() { fn __execute__(self: @ContractState, calls: Array<Call>) -> Array<Span<felt252>> { let Call{to, selector, calldata } = calls.at(0); let _res = starknet::call_contract_syscall(*to, *selector, calldata.span()).unwrap(); let mut res = ArrayTrait::new(); res.append(_res); res } }
The __execute__
function calls the call_contract_syscall
function from the starknet
module. This function executes the call and returns the result. The call_contract_syscall
function is a Starknet syscall, which means that it is executed by the Starknet protocol. The Starknet protocol is responsible for executing the call and returning the result. The Starknet protocol will also validate the call, so we do not need to validate the call in the __execute__
function.
Deploying the Contract
[TODO]
Standard Account Contract
This section guides you through the creation of a standard account contract, adhering to the SNIP-6 and SRC-5 standards. Previously, we created a simple account contract that lacked signature validation and multicall execution. This time, we'll implement a more robust account contract that includes these features and adheres to the standards of an account contract.
Setting Up Your Project
For deploying an account contract to Starknet's testnet or mainnet, use Scarb version 2.3.1, which is compatible with the Sierra 1.3.0 target supported by both networks. For the latest information, review the Starknet Release Notes. As of November 2023, Scarb version 2.3.1 is the recommended choice.
To check your current Scarb version, run:
scarb --version
To install or update Scarb, refer to the Basic Installation instructions in Chapter 2, covering macOS and Linux environments:
curl --proto '=https' --tlsv1.2 -sSf https://docs.swmansion.com/scarb/install.sh | sh
Starting a New Scarb Project
Begin by creating a new project (more details in the Scarb subchapter in Chapter 2):
scarb new aa
Check the generated project structure:
$ tree .
.
└── aa
├── Scarb.toml
└── src
└── lib.cairo
By default, Scarb sets up for vanilla Cairo. Add Starknet capacities by editing Scarb.toml
to include the starknet
dependency:
[package]
name = "aa"
version = "0.1.0"
cairo-version = "2.3.0"
[dependencies]
starknet = ">=2.3.0"
[[target.starknet-contract]]
sierra = true
casm = true
Replace the code in src/lib.cairo
with an account contract scaffold:
#![allow(unused)] fn main() { #[starknet::contract] mod Account { #[storage] struct Storage { public_key: felt252 } } }
To validate signatures, store the public key associated with the signer's private key.
#![allow(unused)] fn main() { #[storage] struct Storage { public_key: felt252 } }
Compile your project to ensure the setup is correct:
scarb build
Implementing SNIP-6 Standard
To define an account contract, implement the ISRC6
trait:
#![allow(unused)] fn main() { trait ISRC6 { fn __execute__(calls: Array<Call>) -> Array<Span<felt252>>; fn __validate__(calls: Array<Call>) -> felt252; fn is_valid_signature(hash: felt252, signature: Array<felt252>) -> felt252; } }
The #[external(v0)]
attribute marks functions with unique selectors for external interaction. The Starknet protocol exclusively uses __execute__
and __validate__
, whereas is_valid_signature
is available for web3 applications to validate signatures.
The trait IAccount<T>
** with #[starknet::interface]
attribute groups publicly accessible functions, like is_valid_signature
. Functions __execute__
and __validate__
, though public, are accessible only indirectly.
#![allow(unused)] fn main() { use starknet::account::Call; #[starknet::interface] trait IAccount<T> { fn is_valid_signature(self: @T, hash: felt252, signature: Array<felt252>) -> felt252; } #[starknet::contract] mod Account { use super::Call; #[storage] struct Storage { public_key: felt252 } #[external(v0)] impl AccountImpl for super::IAccount<ContractState> { fn is_valid_signature(self: @ContractState, hash: felt252, signature: Array<felt252>) -> felt252 { ... } } // These functions are protocol-specific and not intended for direct external use. #[external(v0)] #[generate_trait] impl ProtocolImpl for ProtocolTrait { fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { ... } fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { ... } } } }
Restricted Function Access for Security
The __execute__
and __validate__
functions are designed for exclusive use by the Starknet protocol to enhance account security. Despite their public accessibility, only the Starknet protocol can invoke these functions, identified by using the zero address.
#![allow(unused)] fn main() { #[starknet::contract] mod Account { use starknet::get_caller_address; use zeroable::Zeroable; // Enforces Starknet protocol-only access to specific functions #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { // Executes protocol-specific operations fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { self.only_protocol(); // Verifies protocol-level caller // ... (implementation details) } // Validates protocol-specific operations fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { self.only_protocol(); // Verifies protocol-level caller // ... (implementation details) } } // Defines a private function to check for protocol-level access #[generate_trait] impl PrivateImpl of PrivateTrait { fn only_protocol(self: @ContractState) { // ... (access validation logic) } } } }
Enhanced Security Through Protocol-Exclusive Functions
Starknet enhances the security of accounts by restricting the callability of certain functions. The __execute__
and __validate__
functions, though publicly visible, are callable solely by the Starknet protocol. This protocol asserts its unique calling rights by using a designated zero address—a special value that signifies protocol-level operations.
#![allow(unused)] fn main() { #[starknet::contract] mod Account { use starknet::get_caller_address; use zeroable::Zeroable; // Implements function access control for Starknet protocol #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { // The __execute__ function is a protocol-exclusive operation fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { self.only_protocol(); // Validates the caller as the Starknet protocol // ... (execution logic) } // The __validate__ function ensures the integrity of protocol-level calls fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { self.only_protocol(); // Ensures the caller is the Starknet protocol // ... (validation logic) } } // A private function, only_protocol, to enforce protocol-level access #[generate_trait] impl PrivateImpl of PrivateTrait { // only_protocol checks the caller's address against the zero address fn only_protocol(self: @ContractState) { // If the caller is not the zero address, access is denied // This guarantees that only the Starknet protocol can call the function // ... (access control logic) } } } }
The is_valid_signature
function, by contrast, is not bounded by only_protocol
, maintaining its availability for broader use.
Transaction Signature Validation
To verify transaction signatures, the account contract stores the public key of the signer. The constructor
method initializes this public key during the contract's deployment.
#![allow(unused)] fn main() { #[starknet::contract] mod Account { // Persistent storage for account-related data #[storage] struct Storage { public_key: felt252 // Stores the public key for signature validation } // Sets the public key during contract deployment #[constructor] fn constructor(ref self: ContractState, public_key: felt252) { self.public_key.write(public_key); // Records the signer's public key } // ... Additional implementation details } }
The is_valid_signature
function outputs VALID
for an authentic signature and 0
for an invalid one. Additionally, the is_valid_signature_bool
internal function provides a Boolean result for the signature's validity.
#![allow(unused)] fn main() { #[starknet::contract] mod Account { // Import relevant cryptographic and data handling modules use array::ArrayTrait; use ecdsa::check_ecdsa_signature; use array::SpanTrait; // Facilitates the use of the span() method // External function to validate the transaction signature #[external(v0)] impl AccountImpl of super::IAccount<ContractState> { fn is_valid_signature(self: @ContractState, hash: felt252, signature: Array<felt252>) -> felt252 { // Converts the signature array into a span for processing let is_valid = self.is_valid_signature_bool(hash, signature.span()); if is_valid { 'VALID' } else { 0 } // Returns 'VALID' or '0' based on signature validity } } // Private function to check the signature validity and return a Boolean #[generate_trait] impl PrivateImpl of PrivateTrait { // Validates the signature using a span of elements fn is_valid_signature_bool(self: @ContractState, hash: felt252, signature: Span<felt252>) -> bool { // Checks if the signature has the correct length let is_valid_length = signature.len() == 2_u32; // If the signature length is incorrect, returns false if !is_valid_length { return false; } // Verifies the signature using the stored public key check_ecdsa_signature( hash, self.public_key.read(), *signature.at(0_u32), *signature.at(1_u32) ) } } // ... Additional implementation details } }
In the __validate__
function, the is_valid_signature_bool
method is utilized to confirm the integrity of transaction signatures.
#![allow(unused)] fn main() { #[starknet::contract] mod Account { // Import modules for transaction information retrieval use box::BoxTrait; use starknet::get_tx_info; // Protocol implementation for transaction validation #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { // Validates the signature of a transaction fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { self.only_protocol(); // Ensures protocol-only access // Retrieves transaction information and unpacks it let tx_info = get_tx_info().unbox(); let tx_hash = tx_info.transaction_hash; let signature = tx_info.signature; // Validates the signature and asserts its correctness let is_valid = self.is_valid_signature_bool(tx_hash, signature); assert(is_valid, 'Account: Incorrect tx signature'); // Stops execution if the signature is invalid 'VALID' // Indicates a valid signature } } // ... Additional implementation details } }
Unified Signature Validation for Contract Operations
The __validate_declare__
function is responsible for validating the signature
of the declare
function. On the other hand, __validate_deploy__
facilitates
counterfactual deployment,a method to deploy an account contract without
associating it to a specific deployer address.
To streamline the validation process, we'll unify the behavior of the three
validation functions __validate__
,__validate_declare__
and __validate_deploy__
.
The core logic from __validate__
is abstracted to validate_transaction
private
function, which is then invoked by the other two validation functions.
#![allow(unused)] fn main() { #[starknet::contract] mod Account { // Protocol implementation for the account contract #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { // Validates general contract function calls fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { self.only_protocol(); // Ensures only the Starknet protocol can call self.validate_transaction() // Centralized validation logic } // Validates the 'declare' function signature fn __validate_declare__(self: @ContractState, class_hash: felt252) -> felt252 { self.only_protocol(); // Ensures only the Starknet protocol can call self.validate_transaction() // Reuses the validation logic } // Validates counterfactual contract deployment fn __validate_deploy__(self: @ContractState, class_hash: felt252, salt: felt252, public_key: felt252) -> felt252 { self.only_protocol(); // Ensures only the Starknet protocol can call // Even though public_key is provided, it uses the one stored from the constructor self.validate_transaction() // Applies the same validation logic } } // Private trait implementation that contains shared validation logic #[generate_trait] impl PrivateImpl of PrivateTrait { // Abstracted core logic for validating transactions fn validate_transaction(self: @ContractState) -> felt252 { let tx_info = get_tx_info().unbox(); // Extracts transaction information let tx_hash = tx_info.transaction_hash; let signature = tx_info.signature; // Validates the transaction signature using an internal boolean function let is_valid = self.is_valid_signature_bool(tx_hash, signature); assert(is_valid, 'Account: Incorrect tx signature'); // Ensures signature correctness 'VALID' // Returns 'VALID' if the signature checks out } } } }
It's important to note that the __validate_deploy__
function receives the public key
as an argument. While this key is captured during the constructor phase before this function
is invoked, it remains crucial to provide it when initiating the transaction.
Alternatively, the public key can be directly utilized within the __validate_deploy__
function,
bypassing the constructor.
Efficient Multicall Transaction Execution
The __execute__
function within the Account
module of a Starknet contract is designed to process an array of Call
structures. This multicall feature consolidates several user operations into a single transaction, significantly improving the user experience by enabling batched operations.
#![allow(unused)] fn main() { ```rust #[starknet::contract] mod Account { // Protocol implementation to handle execution of calls #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { // The __execute__ function processes an array of calls fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { self.only_protocol(); // Ensures Starknet protocol level access self.execute_multiple_calls(calls) // Invokes batch processing of calls } // ... Additional implementation details } } }
Each Call
represents the details required for executing a single operation by the smart contract:
#![allow(unused)] fn main() { // Data structure encapsulating a contract call #[derive(Drop, Serde)] struct Call { to: ContractAddress, // The target contract address selector: felt252, // The function selector calldata: Array<felt252> // The parameters for the function call } }
The contract defines a private function execute_single_call
to handle individual calls. It utilizes the call_contract_syscall
to directly invoke a function on another contract:
#![allow(unused)] fn main() { #[starknet::contract] mod Account { // Import syscall for contract function invocation use starknet::call_contract_syscall; // Private trait implementation for individual call execution #[generate_trait] impl PrivateImpl of PrivateTrait { // Executes a single call to another contract fn execute_single_call(self: @ContractState, call: Call) -> Span<felt252> { let Call{to, selector, calldata} = call; // Destructures the Call struct call_contract_syscall(to, selector, calldata.span()).unwrap_syscall() // Performs the contract call } } // ... Additional implementation details } }
For the execution of multiple calls, execute_multiple_calls
iterates over the array of Call structures, invoking execute_single_call
for each and collecting the responses:
#![allow(unused)] fn main() { #[starknet::contract] mod Account { // Private trait implementation for batch call execution #[generate_trait] impl PrivateImpl of PrivateTrait { // Handles an array of calls and accumulates the results fn execute_multiple_calls(self: @ContractState, mut calls: Array<Call>) -> Array<Span<felt252>> { let mut res = ArrayTrait::new(); // Initializes the result array loop { match calls.pop_front() { Option::Some(call) => { let response = self.execute_single_call(call); // Executes each call individually res.append(response); // Appends the result of the call to the result array }, Option::None(_) => { break (); // Exits the loop when no more calls are left }, }; }; res // Returns the array of results } } // ... Additional implementation details } }
In summary, the __execute__
function orchestrates the execution of multiple calls within a single transaction. It leverages these internal functions to handle each call efficiently and return the collective results:
#![allow(unused)] fn main() { #[starknet::contract] mod Account { // External function definition within the protocol implementation #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { // The __execute__ function takes an array of Call structures and processes them fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { self.only_protocol(); // Verifies that the function caller is the Starknet protocol self.execute_multiple_calls(calls) // Delegates to a function for processing multiple calls } // ... Additional implementation details may follow } // ... Further module code may be present } }
The __execute__
function first ensures that it is being called by the Starknet protocol itself, a security measure to prevent unauthorized access. It then calls the execute_multiple_calls
function to handle the actual execution of the calls.
Ensuring Compatibility with Transaction Versioning
Starknet incorporates a versioning system for transactions to maintain backward compatibility while introducing new functionalities. The account contract tutorial showcases support for the latest transaction versions through a specific module, ensuring smooth operation of both legacy and updated transaction structures.
To accommodate the evolution of Starknet and its enhanced functionalities, a versioning system was introduced for transactions. This ensures backward compatibility, allowing both old and new transaction structures to operate concurrently.
- Version 1 for
invoke
transactions - Version 1 for
deploy_account
transactions - Version 2 for
declare
transactions
These supported versions are logically grouped in a module called SUPPORTED_TX_VERSION
:
#![allow(unused)] fn main() { // Module defining supported transaction versions mod SUPPORTED_TX_VERSION { // Constants representing the supported versions const DEPLOY_ACCOUNT: felt252 = 1; // Supported version for deploy_account transactions const DECLARE: felt252 = 2; // Supported version for declare transactions const INVOKE: felt252 = 1; // Supported version for invoke transactions } #[starknet::contract] mod Account { // The rest of the account contract module code ... } }
To handle the version checking, the account contract includes a private function only_supported_tx_version
. This function compares the version of an incoming transaction against the specified supported versions, halting execution with an error if a discrepancy is found.
The critical contract functions such as __execute__
, __validate__
, __validate_declare__
, and __validate_deploy__
implement this version check to confirm transaction compatibility.
#![allow(unused)] fn main() { #[starknet::contract] mod Account { // Importing constants from the SUPPORTED_TX_VERSION module use super::SUPPORTED_TX_VERSION; // Protocol implementation for Starknet functions #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { // Function to execute multiple calls with version check fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { self.only_protocol(); // Checks if the function caller is the Starknet protocol self.only_supported_tx_version(SUPPORTED_TX_VERSION::INVOKE); // Ensures the transaction is the supported version self.execute_multiple_calls(calls) // Processes the calls if version check passes } // Each of the following functions also includes the version check to ensure compatibility fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::INVOKE); self.validate_transaction() } fn __validate_declare__(self: @ContractState, class_hash: felt252) -> felt252 { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::DECLARE); self.validate_transaction() } fn __validate_deploy__(self: @ContractState, class_hash: felt252, salt: felt252, public_key: felt252) -> felt252 { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::DEPLOY_ACCOUNT); self.validate_transaction() } } // Private implementation for checking supported transaction versions #[generate_trait] impl PrivateImpl of PrivateTrait { // Function to assert the transaction version is supported fn only_supported_tx_version(self: @ContractState, supported_tx_version: felt252) { let tx_info = get_tx_info().unbox(); // Retrieves transaction details let version = tx_info.version; // Extracts the version from the transaction assert( version == supported_tx_version, 'Account: Unsupported tx version' // Error message for unsupported versions ); } // ... Additional private functions } } }
By integrating transaction version control, the contract ensures it operates consistently with the network's current standards, providing a clear path for upgrading and maintaining compatibility with Starknet's evolving ecosystem.
Handling Simulated Transactions
Starknet's simulation feature allows developers to estimate the gas cost of transactions without actually committing them to the network. This is particularly useful during development and testing phases. The estimate-only
flag available in tools like Starkli triggers the simulation process. To differentiate between actual transaction execution and simulation, Starknet uses a version offset strategy.
Simulated transactions are assigned a version number that is the sum of (2^{128}) and the version number of the actual transaction type. For example, if the latest version of a declare
transaction is 2, then a simulated declare
transaction would have a version number of (2^{128} + 2). The same logic applies to other transaction types like invoke
and deploy_account
.
Here's how the only_supported_tx_version
function is adjusted to accommodate both actual and simulated transaction versions:
#![allow(unused)] fn main() { #[starknet::contract] mod Account { // Constant representing the version offset for simulated transactions const SIMULATE_TX_VERSION_OFFSET: felt252 = 340282366920938463463374607431768211456; // This is 2^128 // Private trait implementation updated to validate transaction versions #[generate_trait] impl PrivateImpl of PrivateTrait { // Function to check for supported transaction versions, accounting for simulations fn only_supported_tx_version(self: @ContractState, supported_tx_version: felt252) { let tx_info = get_tx_info().unbox(); // Retrieves the transaction metadata let version = tx_info.version; // Extracts the version for comparison // Validates whether the transaction version matches either the supported actual version or the simulated version assert( version == supported_tx_version || version == SIMULATE_TX_VERSION_OFFSET + supported_tx_version, 'Account: Unsupported tx version' // Assertion message for version mismatch ); } // Additional private functions may follow } // Remaining contract code may continue here } }
The code snippet showcases the account contract's capability to recognize and process both actual and simulated versions of transactions by incorporating the large numerical offset. This ensures that the system can seamlessly operate with and adjust to the estimation process without affecting the actual transaction processing logic.
SRC-5 Standard and Contract Introspection
Contract introspection is a feature that allows Starknet contracts to self-report the interfaces they support, in compliance with the SRC-5 standard. The supports_interface
function is a fundamental part of this introspection process, enabling contracts to communicate their capabilities to others.
For a contract to be SRC-5 compliant, it must return true
when the supports_interface
function is called with a specific interface_id
. This unique identifier is chosen to represent the SRC-6 standard's interface, which the contract claims to support. The identifier is a large integer specifically chosen to minimize the chance of accidental collisions with other identifiers.
In the account contract, the supports_interface
function is part of the public interface, allowing other contracts to query its support for the SRC-6 standard:
#![allow(unused)] fn main() { // SRC-5 trait defining the introspection method trait ISRC5 { // Function to check interface support fn supports_interface(interface_id: felt252) -> bool; } // Extension of the account contract's interface for SRC-5 compliance #[starknet::interface] trait IAccount<T> { // ... Additional methods // Method to validate interface support fn supports_interface(self: @T, interface_id: felt252) -> bool; } #[starknet::contract] mod Account { // Constant identifier for the SRC-6 trait const SRC6_TRAIT_ID: felt252 = 1270010605630597976495846281167968799381097569185364931397797212080166453709; // Public interface implementation for the account contract #[external(v0)] impl AccountImpl of super::IAccount<ContractState> { // ... Other function implementations // Implementation of the interface support check fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { // Compares the provided interface ID with the SRC-6 trait ID interface_id == SRC6_TRAIT_ID } } // ... Additional account contract code } // SRC-5 trait defining the introspection method trait ISRC5 { // Function to check interface support fn supports_interface(interface_id: felt252) -> bool; } // Extension of the account contract's interface for SRC-5 compliance #[starknet::interface] trait IAccount<T> { // ... Additional methods // Method to validate interface support fn supports_interface(self: @T, interface_id: felt252) -> bool; } #[starknet::contract] mod Account { // Constant identifier for the SRC-6 trait const SRC6_TRAIT_ID: felt252 = 1270010605630597976495846281167968799381097569185364931397797212080166453709; // Public interface implementation for the account contract #[external(v0)] impl AccountImpl of super::IAccount<ContractState> { // ... Other function implementations // Implementation of the interface support check fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { // Compares the provided interface ID with the SRC-6 trait ID interface_id == SRC6_TRAIT_ID } } // ... Additional account contract code } }
By implementing this function, the account contract declares its ability to interact with other contracts expecting SRC-6 features, thus adhering to the standards of the Starknet protocol and enhancing interoperability within the network.
Public Key Accessibility
For enhanced transparency and debugging purposes, it's recommended to make the public key of the account contract's signer accessible. This allows users to verify the correct deployment of the account contract by comparing the stored public key with the signer's public key offline.
#![allow(unused)] fn main() { ... #[starknet::contract] mod Account { ... #[external(v0)] impl AccountImpl of IAccount<ContractState> { ... fn public_key(self: @ContractState) -> felt252 { self.public_key.read() } } } }
Final Implementation
We now have a fully functional account contract. Here's the final implementation;
#![allow(unused)] fn main() { use starknet::account::Call; mod SUPPORTED_TX_VERSION { const DEPLOY_ACCOUNT: felt252 = 1; const DECLARE: felt252 = 2; const INVOKE: felt252 = 1; } #[starknet::interface] trait IAccount<T> { fn is_valid_signature(self: @T, hash: felt252, signature: Array<felt252>) -> felt252; fn supports_interface(self: @T, interface_id: felt252) -> bool; fn public_key(self: @T) -> felt252; } #[starknet::contract] mod Account { use super::{Call, IAccount, SUPPORTED_TX_VERSION}; use starknet::{get_caller_address, call_contract_syscall, get_tx_info, VALIDATED}; use zeroable::Zeroable; use array::{ArrayTrait, SpanTrait}; use ecdsa::check_ecdsa_signature; use box::BoxTrait; const SIMULATE_TX_VERSION_OFFSET: felt252 = 340282366920938463463374607431768211456; // 2**128 const SRC6_TRAIT_ID: felt252 = 1270010605630597976495846281167968799381097569185364931397797212080166453709; // hash of SNIP-6 trait #[storage] struct Storage { public_key: felt252 } #[constructor] fn constructor(ref self: ContractState, public_key: felt252) { self.public_key.write(public_key); } #[external(v0)] impl AccountImpl of IAccount<ContractState> { fn is_valid_signature(self: @ContractState, hash: felt252, signature: Array<felt252>) -> felt252 { let is_valid = self.is_valid_signature_bool(hash, signature.span()); if is_valid { VALIDATED } else { 0 } } fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { interface_id == SRC6_TRAIT_ID } fn public_key(self: @ContractState) -> felt252 { self.public_key.read() } } #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::INVOKE); self.execute_multiple_calls(calls) } fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::INVOKE); self.validate_transaction() } fn __validate_declare__(self: @ContractState, class_hash: felt252) -> felt252 { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::DECLARE); self.validate_transaction() } fn __validate_deploy__(self: @ContractState, class_hash: felt252, salt: felt252, public_key: felt252) -> felt252 { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::DEPLOY_ACCOUNT); self.validate_transaction() } } #[generate_trait] impl PrivateImpl of PrivateTrait { fn only_protocol(self: @ContractState) { let sender = get_caller_address(); assert(sender.is_zero(), 'Account: invalid caller'); } fn is_valid_signature_bool(self: @ContractState, hash: felt252, signature: Span<felt252>) -> bool { let is_valid_length = signature.len() == 2_u32; if !is_valid_length { return false; } check_ecdsa_signature( hash, self.public_key.read(), *signature.at(0_u32), *signature.at(1_u32) ) } fn validate_transaction(self: @ContractState) -> felt252 { let tx_info = get_tx_info().unbox(); let tx_hash = tx_info.transaction_hash; let signature = tx_info.signature; let is_valid = self.is_valid_signature_bool(tx_hash, signature); assert(is_valid, 'Account: Incorrect tx signature'); VALIDATED } fn execute_single_call(self: @ContractState, call: Call) -> Span<felt252> { let Call{to, selector, calldata} = call; call_contract_syscall(to, selector, calldata.span()).unwrap() } fn execute_multiple_calls(self: @ContractState, mut calls: Array<Call>) -> Array<Span<felt252>> { let mut res = ArrayTrait::new(); loop { match calls.pop_front() { Option::Some(call) => { let _res = self.execute_single_call(call); res.append(_res); }, Option::None(_) => { break (); }, }; }; res } fn only_supported_tx_version(self: @ContractState, supported_tx_version: felt252) { let tx_info = get_tx_info().unbox(); let version = tx_info.version; assert( version == supported_tx_version || version == SIMULATE_TX_VERSION_OFFSET + supported_tx_version, 'Account: Unsupported tx version' ); } } } }
Account Contract Creation Summary
-
SNIP-6 Implementation
- Implements the
ISRC6
trait, defining the account contract's structure.
- Implements the
-
Protocol-Only Function Access
- Restricts
__validate__
and__execute__
to StarkNet protocol access. - Makes
is_valid_signature
available for external calls. - Adds a
only_protocol
private function to enforce access rules.
- Restricts
-
Signature Validation Process
- Stores a public key to verify the signer's transactions.
- Initializes with a
constructor
to set the public key. - Validates signatures with
is_valid_signature
, returningVALID
or0
. - Uses
is_valid_signature_bool
to return a true or false validation result.
-
Declare and Deploy Function Validation
- Sets up
__validate_declare__
to check thedeclare
function's signature. - Designs
__validate_deploy__
for counterfactual deployments. - Abstracts core validation to
validate_transaction
.
- Sets up
-
Transaction Execution Logic
- Enables multicall capability with
__execute__
. - Handles calls individually with
execute_single_call
and in batches withexecute_multiple_calls
.
- Enables multicall capability with
-
Transaction Version Compatibility
- Ensures compatibility with StarkNet updates using a versioning system.
- Defines supported transaction types in
SUPPORTED_TX_VERSION
. - Checks transaction versions with
only_supported_tx_version
.
-
Simulated Transaction Handling
- Adapts
only_supported_tx_version
to recognize both actual and simulated versions.
- Adapts
-
Contract Self-Identification
- Allows self-identification with the SRC-5 standard via
supports_interface
.
- Allows self-identification with the SRC-5 standard via
-
Public Key Visibility
- Provides public key access for transparency.
-
Complete Implementation
- Presents the final account contract code.
Next, we will deploy the account using Starkli to the testnet and interact with other smart contracts.
Deploying Account Contracts
After building our account contract, we'll now deploy it using Starkli on the testnet and interact with other contracts.
Ensure you've installed starkli and scarb. Review the Basic Installation subchapter in Chapter 2 if you haven't.
Account Contract Configuration Files
Starkli requires two key configuration files:
keystore.json
: A secure file that holds the private key.account.json
: An open file with the account's public details like public key, class hash, and address.
Optionally, Starkli can use:
envars.sh
: A script to set environment variables for Starkli commands.
For multiple wallets, keep a clean directory structure. Each wallet should have its own folder with the three files inside. Group these under a ~/.starkli-wallets
directory.
Here's a suggested structure:
tree ~/.starkli-wallets
.starkli-wallets
├── wallet-a
│ ├── account.json
│ ├── envars.sh
│ └── keystore.json
└── wallet-b
├── account.json
├── envars.sh
└── keystore.json
This setup promotes better organization.
We'll make a custom folder in .starkli-wallets
for our contract wallet:
mkdir ~/.starkli-wallets/custom
Next, we use Starkli to create keystore.json
and account.json
, then write envars.sh
by hand.
Creating the Keystore File with Starkli
Starkli simplifies creating a keystore.json
file. This encrypted file holds your private key and sits in the custom
directory. You can create this with one command:
starkli signer keystore new ~/.starkli-wallets/custom/keystore.json
When you run this, you'll enter a password to secure the file. The resulting keystore.json
is essential for setting up the envars.sh
file, which stores environment variables.
Create envars.sh
like this:
touch ~/.starkli-wallets/custom/envars.sh
Open the file and insert:
#!/bin/bash
export STARKNET_KEYSTORE=~/.starkli-wallets/custom/keystore.json
Activate the variable by sourcing the file:
source ~/.starkli-wallets/custom/envars.sh
Now, your environment is ready for the next step: creating the account.json
file.
Generating the Account Configuration File
Our account contract's signature validation mirrors that in Open Zeppelin's default account contract, using a single signer and a STARK-compatible elliptic curve. Despite building our contract independently, we'll use Starkli's command for Open Zeppelin accounts to create our configuration:
starkli account oz init ~/.starkli-wallets/custom/account.json
After entering your keystore password, account.json
is created. This file includes a class hash for OpenZeppelin's contract, not ours. Since the class hash influences the deployment address, the shown address won't match our contract.
Here's what account.json looks like:
{
"version": 1,
"variant": {
"type": "open_zeppelin",
"version": 1,
"public_key": "0x1445385497364c73fabf223c55b7b323586b61c42942c99715d842c6f0a781c",
"legacy": false
},
"deployment": {
"status": "undeployed",
"class_hash": "0x4c6d6cf894f8bc96bb9c525e6853e5483177841f7388f74a46cfda6f028c755",
"salt": "0x36cb2427f99a75b7d4c4ceeca1e412cd94b1fc396e09fec8adca14f8dc33374"
}
}
To deploy our unique contract, we must compile our project to obtain the correct class hash and update account.json
accordingly.
Finding the Class Hash
Previously, we set up a aa
directory for our account contract's Cairo code. If you don't have it, clone the repository:
git clone git@github.com:starknet-edu/aa-workshop.git aa
To compile the contract with Scarb, enter the project directory:
cd aa
scarb build
The compiled contract lies in target/dev
. Use Starkli to get the class hash:
starkli class-hash target/dev/aa_Account.sierra.json
Next, edit account.json
to insert the correct class hash:
code ~/.starkli-wallets/custom/account.json
Ensure the class_hash
is updated:
{
"version": 1,
"variant": {
"type": "open_zeppelin",
"version": 1,
"public_key": "0x1445385497364c73fabf223c55b7b323586b61c42942c99715d842c6f0a781c",
"legacy": false
},
"deployment": {
"status": "undeployed",
"class_hash": "0x03480253c19b447b1d7e7a6422acf80b73866522de03126fa55796a712d9f092",
"salt": "0x36cb2427f99a75b7d4c4ceeca1e412cd94b1fc396e09fec8adca14f8dc33374"
}
}
To point to the updated account.json
, modify envars.sh
:
code ~/.starkli-wallets/custom/envars.sh
Add the account path:
#!/bin/bash
export STARKNET_KEYSTORE=~/.starkli-wallets/custom/keystore.json
export STARKNET_ACCOUNT=~/.starkli-wallets/custom/account.json
Source envars.sh
to apply the changes:
source ~/.starkli-wallets/custom/envars.sh
Now, we're ready to declare the contract on the testnet.
Establishing an RPC Provider
For transactions on Starknet, an RPC provider is essential. This guide uses Infura but a personal node is a viable alternative.
Steps for Infura:
- Create an account.
- Start a new project for Starknet Goerli.
- Obtain the RPC URL.
- Add this URL to
envars.sh
:
aa $ code ~/.starkli-wallets/custom/envars.sh
The file should now include:
#!/bin/bash
export STARKNET_KEYSTORE=~/.starkli-wallets/custom/keystore.json
export STARKNET_ACCOUNT=~/.starkli-wallets/custom/account.json
export STARKNET_RPC=https://starknet-goerli.infura.io/v3/your-api-key
Replace your-api-key
with the actual API key provided by Infura.
Declaring the Account Contract
You'll need a funded account to pay gas fees. Configure Starkli with a Braavos or Argent X wallet as the deployer. Instructions are available here.
After setting up, your Starkli wallet structure will be:
tree .
.
├── custom
│ ├── account.json
│ ├── envars.sh
│ └── keystore.json
└── deployer
├── account.json
├── envars.sh
└── keystore.json
Source the deployer's environment file in the aa
directory to use it:
source ~/.starkli-wallets/deployer/envars.sh
Declare the contract with the deployer covering gas:
starkli declare target/dev/aa_Account.sierra.json
After reaching "Accepted on L2," status (less than a minute) switch back to the account's environment:
source ~/.starkli-wallets/custom/envars.sh
Deploy the account with Starkli:
starkli account deploy ~/.starkli-wallets/custom/account.json
Starkli will wait for you to fund the address displayed with at least the estimated fee from Starknet's faucet.
Once funded, press ENTER
to deploy:
...
Deployment transaction confirmed
Your account contract is now live on the Starknet testnet.
Using the Account Contract
To test our account contract, we can send 100 gwei to the wallet 0x070a...52d1
by calling the transfer
function of the WETH smart contract on Starknet's testnet.
Invoke the transfer with Starkli (more details on Starkli's in Chapter 2):
starkli invoke eth transfer 0x070a012... u256:100
A successful invoke confirms that our account contract has authenticated the signature, executed the transfer, and managed the gas fees.
Here's a summary of all the steps from declaration to interaction:
# Quick Guide: Declare, Deploy, and Interact with a Custom Account Contract
# [1] Set up environment variables in envars.sh
export STARKNET_KEYSTORE=~/.starkli-wallets/custom/keystore.json
export STARKNET_ACCOUNT=~/.starkli-wallets/custom/account.json
export STARKNET_RPC=https://starknet-goerli.infura.io/v3/your-api-key
# [2] Generate keystore.json
starkli signer keystore new ~/.starkli-wallets/custom/keystore.json
# [3] Initialize account.json
starkli account oz init ~/.starkli-wallets/custom/account.json
# [4] Build the contract with Scarb
scarb build
# [5] Get the class hash
starkli class-hash target/dev/aa_Account.sierra.json
# [6] Update account.json with the real class hash
code ~/.starkli-wallets/custom/account.json
# [7] Set deployer wallet environment
source ~/.starkli-wallets/deployer/envars.sh
# [8] Declare the contract using the deployer
starkli declare target/dev/aa_Account.sierra.json
# [9] Switch to the custom wallet
source ~/.starkli-wallets/custom/envars.sh
# [10] Deploy the contract
starkli account deploy ~/.starkli-wallets/custom/account.json
# [11] Test the contract by transferring ETH
starkli invoke eth transfer 0x070a012... u256:100
# [bonus] Recommended directory structure
.
├── account.json
├── envars.sh
└── keystore.json
Summary
We've successfully deployed and used our custom account contract on Starknet with Starkli. Here's what we accomplished:
- Set environment variables in
envars.sh
. - Created
keystore.json
to securely store the private key. - Initialized
account.json
as the account descriptor file. - Used Braavos smart wallet to set up the deployer environment.
- Declared and deployed our account contract to the Starknet testnet.
- Conducted a transfer to another wallet.
We matched the Open Zeppelin's contract in terms of signature methods for the constructor
and __declare_deploy__
functions, which allowed us to use Starkli's Open Zeppelin preset. Should there be a need for signature modification, Starknet JS SDK would be the tool of choice.
Examples
Here, we will explore numerous examples, elucidating the principles and techniques to effectively design and implement Account contracts.
Before delving into the examples, we would like to issue a disclaimer: the contracts discussed in this chapter are for illustrative and educational purposes, and they have not undergone formal auditing. This signifies that, while we strive to provide accurate and informative content, the implementation of these contracts in a live environment should be approached with due diligence. We encourage users to thoroughly test and validate these contracts before their deployment on the mainnet.
The goal of this chapter is not only to furnish a library of contract examples but also to foster collaboration and knowledge sharing among the Starknet community. We believe that the exchange of ideas and constructive feedback is instrumental in advancing our collective understanding and expertise.
If you’ve developed or come across an innovative contract that could serve as a valuable learning resource for others, we invite you to contribute. Here are a few guidelines for your contribution:
-
Open a PR: To submit a contract example or suggest changes to existing ones, simply open a Pull Request. Ensure that your PR contains a thorough explanation of the contract, its use cases, and its functionality.
-
Code Standards: Please ensure that the submitted code is well-documented and adheres to the standard code conventions of Starknet. This will facilitate the review process and enhance the readability and comprehensibility of the contract.
-
Detailed Explanation: Accompany your code with a detailed explanation of the contract logic. Wherever possible, use diagrams, flowcharts, or pseudocode to illustrate complex mechanisms or workflows.
As we expand this repertoire of contract examples, we hope to equip the Starknet community with a robust toolset and inspire further exploration and innovation in the realm of custom account contracts.
MultiCaller Account
NOTE: THIS CHAPTER NEEDS TO BE UPDATED TO REFLECT THE NEW SYNTAX FOR ACCOUNT CONTRACTS. PLEASE DO NOT USE THIS CHAPTER AS A REFERENCE UNTIL THIS NOTE IS REMOVED.
CONTRIBUTE: This subchapter is missing an example of declaration, deployment and interaction with the contract. We would love to see your contribution! Please submit a PR.
Multicall is a powerful technique that allows multiple constant smart contract function calls to be aggregated into a single call, resulting in a consolidated output. With Starknet’s account abstraction feature, multicalls can be seamlessly integrated into account contracts.
Why Multicalls?
Multicalls come handy in several scenarios. Here are some examples:
-
Token Swapping on Decentralized Exchanges: In a typical token swap operation on a decentralized exchange (DEX), you first need to approve the spending of the tokens and then initiate the swap. Executing these operations separately could be cumbersome from a user experience perspective. With multicall, these calls can be combined into a single transaction, simplifying the user’s task.
-
Fetching Blockchain Data: When you want to query the prices of two different tokens from the blockchain, it’s beneficial to have them both come from the same block for consistency. Multicall returns the latest block number along with the aggregated results, providing this consistency.
The benefits of multicall transactions can be realized more in the context of account abstraction.
Multicall Functionality in Account Contracts
To facilitate multicalls, we can introduce specific functions in the account contract. Here are two core functions:
_execute_calls
Function
The _execute_calls
function is responsible for executing the
multicalls. It iterates over an array of calls, executes them, and
aggregates the results.
#![allow(unused)] fn main() { fn _execute_calls(mut calls: Array<AccountCall>, mut res:Array::<Array::<felt>>) -> Array::<Array::<felt>> { match calls.pop_front() { Option::Some(call) => { let _res = _call_contract(call); res.append(_res); return _execute_calls(calls, res); }, Option::None(_) => { return res; }, } } }
Apart from the traditional execute
function, adding the
_execute_calls
function to your account contract can ensure that
you can make a multicall using your smart contract account.
The above code is a simple example snippet where the **"return
execute_calls(calls, res);" statement makes recursive calls to the
_execute_calls
function thereby bundling the calls together.
The final result will be aggregated and returned in the ***res***
variable.
_call_contract
Function
The _call_contract
function is a helper function used to make
individual contract calls.
#![allow(unused)] fn main() { fn _call_contract(call: AccountCall) -> Array::<felt> { starknet::call_contract_syscall( call.to, call.selector, call.calldata ).unwrap_syscall() } }
Considerations
While multicall provides significant benefits in terms of UX and data consistency, it’s important to note that it may not significantly reduce gas fees compared to individual calls. However, the primary advantage of using multicall is that it ensures results are derived from the same block, providing a much-improved user experience.
The Book is a community-driven effort created for the community.
-
If you’ve learned something, or not, please take a moment to provide feedback through this 3-question survey.
-
If you discover any errors or have additional suggestions, don’t hesitate to open an issue on our GitHub repository.
Multi-Signature Accounts
NOTE: THIS CHAPTER NEEDS TO BE UPDATED TO REFLECT THE NEW SYNTAX FOR ACCOUNT CONTRACTS. PLEASE DO NOT USE THIS CHAPTER AS A REFERENCE UNTIL THIS NOTE IS REMOVED.
CONTRIBUTE: This subchapter is missing an example of declaration, deployment and interaction with the contract. We would love to see your contribution! Please submit a PR.
Multisignature (multisig) technology is an integral part of the modern blockchain landscape. It enhances security by requiring multiple signatures to confirm a transaction, hence reducing the risk of fraudulent transactions and increasing control over asset management.
In Starknet, the concept of multisig accounts is abstracted at the protocol level, allowing developers to implement custom account contracts that embody this concept. In this chapter, we’ll delve into the workings of a multisig account and see how it’s created in Starknet using an account contract.
What is a Multisig Account?
A multisig account is an account that requires more than one signature to authorize transactions. This significantly enhances security, requiring multiple entities' consent to transact funds or perform critical actions.
Key specifications of a multisig account include:
-
Public keys that form the account
-
Threshold number of signatures required
A transaction signed by a multisig account must be individually signed by the different keys specified for the account. If fewer than the threshold number of signatures needed are present, the resultant multisignature is considered invalid.
In Starknet, accounts are abstractions provided at the protocol level. Therefore, to create a multisig account, one needs to code the logic into an account contract and deploy it.
The contract below serves as an example of a multisig account contract. When deployed, it can create a native multisig account using the concept of account abstraction. Please note that this is a simplified example and lacks comprehensive checks and validations found in a production-grade multisig contract.
Multisig Account Contract
This is the Rust code for a multisig account contract:
#![allow(unused)] fn main() { #[account_contract] mod MultisigAccount { use ecdsa::check_ecdsa_signature; use starknet::ContractAddress; use zeroable::Zeroable; use array::ArrayTrait; use starknet::get_caller_address; use box::BoxTrait; use array::SpanTrait; struct Storage { index_to_owner: LegacyMap::<u32, felt252>, owner_to_index: LegacyMap::<felt252, u32>, num_owners: usize, threshold: usize, curr_tx_index: felt252, //Mapping between tx_index and num of confirmations tx_confirms: LegacyMap<felt252, usize>, //Mapping between tx_index and its execution state tx_is_executed: LegacyMap<felt252, bool>, //Mapping between a transaction index and its hash transactions: LegacyMap<felt252, felt252>, has_confirmed: LegacyMap::<(ContractAddress, felt252), bool>, } #[constructor] fn constructor(public_keys: Array::<felt252>, _threshold: usize) { assert(public_keys.len() <= 3_usize, 'public_keys.len <= 3'); num_owners::write(public_keys.len()); threshold::write(_threshold); _set_owners(public_keys.len(), public_keys); } //GETTERS //Get number of confirmations for a given transaction index #[view] fn get_confirmations(tx_index : felt252) -> usize { tx_confirms::read(tx_index) } //Get the number of owners of this account #[view] fn get_num_owners() -> usize { num_owners::read() } //Get the public key of the owners //TODO - Recursively add the owners into an array and return, maybe wait for loops to be enabled //EXTERNAL FUNCTIONS #[external] fn submit_tx(public_key: felt252) { //Need to check if caller is one of the owners. let tx_info = starknet::get_tx_info().unbox(); let signature: Span<felt252> = tx_info.signature; let caller = get_caller_address(); assert(signature.len() == 2_u32, 'INVALID_SIGNATURE_LENGTH'); //Updating the transaction index let tx_index = curr_tx_index::read(); //`true` if a signature is valid and `false` otherwise. assert( check_ecdsa_signature( message_hash: tx_info.transaction_hash, public_key: public_key, signature_r: *signature.at(0_u32), signature_s: *signature.at(1_u32), ), 'INVALID_SIGNATURE', ); transactions::write(tx_index, tx_info.transaction_hash); curr_tx_index::write(tx_index + 1); } #[external] fn confirm_tx(tx_index: felt252, public_key: felt252) { let transaction_hash = transactions::read(tx_index); //TBD: Assert that tx_hash is not null let num_confirmations = tx_confirms::read(tx_index); let executed = tx_is_executed::read(tx_index); assert(executed == false, 'TX_ALREADY_EXECUTED'); let caller = get_caller_address(); let tx_info = starknet::get_tx_info().unbox(); let signature: Span<felt252> = tx_info.signature; assert( check_ecdsa_signature( message_hash: tx_info.transaction_hash, public_key: public_key, signature_r: *signature.at(0_u32), signature_s: *signature.at(1_u32), ), 'INVALID_SIGNATURE', ); let confirmed = has_confirmed::read((caller, tx_index)); assert (confirmed == false, 'CALLER_ALREADY_CONFIRMED'); tx_confirms::write(tx_index, num_confirmations+1_usize); has_confirmed::write((caller, tx_index), true); } //An example function to validate that there are at least two signatures fn validate_transaction(public_key: felt252) -> felt252 { let tx_info = starknet::get_tx_info().unbox(); let signature: Span<felt252> = tx_info.signature; let caller = get_caller_address(); assert(signature.len() == 2_u32, 'INVALID_SIGNATURE_LENGTH'); //`true` if a signature is valid and `false` otherwise. assert( check_ecdsa_signature( message_hash: tx_info.transaction_hash, public_key: public_key, signature_r: *signature.at(0_u32), signature_s: *signature.at(1_u32), ), 'INVALID_SIGNATURE', ); starknet::VALIDATED } //INTERNAL FUNCTION //Function to add the public keys of the multisig in permanent storage fn _set_owners(owners_len: usize, public_keys: Array::<felt252>) { if owners_len == 0_usize { } index_to_owner::write(owners_len, *public_keys.at(owners_len - 1_usize)); owner_to_index::write(*public_keys.at(owners_len - 1_usize), owners_len); _set_owners(owners_len - 1_u32, public_keys); } #[external] fn __validate_deploy__( class_hash: felt252, contract_address_salt: felt252, public_key_: felt252 ) -> felt252 { validate_transaction(public_key_) } #[external] fn __validate_declare__(class_hash: felt252, public_key_: felt252) -> felt252 { validate_transaction(public_key_) } #[external] fn __validate__( contract_address: ContractAddress, entry_point_selector: felt252, calldata: Array::<felt252>, public_key_: felt252 ) -> felt252 { validate_transaction(public_key_) } #[external] #[raw_output] fn __execute__( contract_address: ContractAddress, entry_point_selector: felt252, calldata: Array::<felt252>, tx_index: felt252 ) -> Span::<felt252> { // Validate caller. assert(starknet::get_caller_address().is_zero(), 'INVALID_CALLER'); // Check the tx version here, since version 0 transaction skip the __validate__ function. let tx_info = starknet::get_tx_info().unbox(); assert(tx_info.version != 0, 'INVALID_TX_VERSION'); //Multisig check here let num_confirmations = tx_confirms::read(tx_index); let owners_len = num_owners::read(); //Subtracting one for the submitter let required_confirmations = threshold::read() - 1_usize; assert(num_confirmations >= required_confirmations, 'MINIMUM_50%_CONFIRMATIONS'); tx_is_executed::write(tx_index, true); starknet::call_contract_syscall( contract_address, entry_point_selector, calldata.span() ).unwrap_syscall() } } }
Multisig Transaction Flow
The flow of a multisig transaction includes the following steps:
-
Submitting a transaction: Any of the owners can submit a transaction from the account.
-
Confirming the transaction: The owner who hasn’t submitted a transaction can confirm the transaction.
The transaction will be successfully executed if the number of confirmations (including the submitter’s signature) is greater than or equal to the threshold number of signatures, else it fails. This mechanism of confirmation ensures that no single party can unilaterally perform critical actions, thereby enhancing the security of the account.
Exploring Multisig Functions
Let’s take a closer look at the various functions associated with multisig functionality in the provided contract.
_set_owners
Function
This is an internal function designed to add the public keys of the account owners to a permanent storage. Ideally, a multisig account structure should permit adding and deleting owners as per the agreement of the account owners. However, each change should be a transaction requiring the threshold number of signatures.
#![allow(unused)] fn main() { //INTERNAL FUNCTION //Function to add the public keys of the multisig in permanent storage fn _set_owners(owners_len: usize, public_keys: Array::<felt252>) { if owners_len == 0_usize { } index_to_owner::write(owners_len, *public_keys.at(owners_len - 1_usize)); owner_to_index::write(*public_keys.at(owners_len - 1_usize), owners_len); _set_owners(owners_len - 1_u32, public_keys); } }
submit_tx
Function
This external function allows the owners of the account to submit transactions. Upon submission, the function checks the validity of the transaction, ensures the caller is one of the account owners, and adds the transaction to the transactions map. It also increments the current transaction index.
#![allow(unused)] fn main() { #[external] fn submit_tx(public_key: felt252) { //Need to check if caller is one of the owners. let tx_info = starknet::get_tx_info().unbox(); let signature: Span<felt252> = tx_info.signature; let caller = get_caller_address(); assert(signature.len() == 2_u32, 'INVALID_SIGNATURE_LENGTH'); //Updating the transaction index let tx_index = curr_tx_index::read(); //`true` if a signature is valid and `false` otherwise. assert( check_ecdsa_signature( message_hash: tx_info.transaction_hash, public_key: public_key, signature_r: *signature.at(0_u32), signature_s: *signature.at(1_u32), ), 'INVALID_SIGNATURE', ); transactions::write(tx_index, tx_info.transaction_hash); curr_tx_index::write(tx_index + 1); } }
confirm_tx
Function
Similarly, the confirm_tx
function provides a way to record
confirmations for each transaction. An account owner, who did not submit
the transaction, can confirm it, increasing its confirmation count.
#![allow(unused)] fn main() { #[external] fn confirm_tx(tx_index: felt252, public_key: felt252) { let transaction_hash = transactions::read(tx_index); //TBD: Assert that tx_hash is not null let num_confirmations = tx_confirms::read(tx_index); let executed = tx_is_executed::read(tx_index); assert(executed == false, 'TX_ALREADY_EXECUTED'); let caller = get_caller_address(); let tx_info = starknet::get_tx_info().unbox(); let signature: Span<felt252> = tx_info.signature; assert( check_ecdsa_signature( message_hash: tx_info.transaction_hash, public_key: public_key, signature_r: *signature.at(0_u32), signature_s: *signature.at(1_u32), ), 'INVALID_SIGNATURE', ); let confirmed = has_confirmed::read((caller, tx_index)); assert (confirmed == false, 'CALLER_ALREADY_CONFIRMED'); tx_confirms::write(tx_index, num_confirmations+1_usize); has_confirmed::write((caller, tx_index), true); } }
execute
Function
The execute function serves as the final step in the transaction process. It checks the validity of the transaction, whether it has been previously executed, and if the threshold number of signatures has been reached. The transaction is executed if all the checks pass.
#![allow(unused)] fn main() { #[external] #[raw_output] fn __execute__( contract_address: ContractAddress, entry_point_selector: felt252, calldata: Array::<felt252>, tx_index: felt252 ) -> Span::<felt252> { // Validate caller. assert(starknet::get_caller_address().is_zero(), 'INVALID_CALLER'); // Check the tx version here, since version 0 transaction skip the __validate__ function. let tx_info = starknet::get_tx_info().unbox(); assert(tx_info.version != 0, 'INVALID_TX_VERSION'); //Multisig check here let num_confirmations = tx_confirms::read(tx_index); let owners_len = num_owners::read(); //Subtracting one for the submitter let required_confirmations = threshold::read() - 1_usize; assert(num_confirmations >= required_confirmations, 'MINIMUM_50%_CONFIRMATIONS'); tx_is_executed::write(tx_index, true); starknet::call_contract_syscall( contract_address, entry_point_selector, calldata.span() ).unwrap_syscall() } }
Closing Thoughts
This chapter has introduced you to the concept of multisig accounts in Starknet and illustrated how they can be implemented using an account contract. However, it’s important to note that this is a simplified example, and a production-grade multisig contract should contain additional checks and validations for robustness and security.
The Book is a community-driven effort created for the community.
-
If you’ve learned something, or not, please take a moment to provide feedback through this 3-question survey.
-
If you discover any errors or have additional suggestions, don’t hesitate to open an issue on our GitHub repository.