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

ChapterDescription
1: Starknet IntroductionDelve into the fundamental concepts of Starknet and acquaint yourself with the deployment of smart contracts.
2: Starknet ToolingFamiliarize 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 ArchitectureUncover Starknet’s core structure, gaining insights into the transaction lifecycle and the interplay between the Sequencer, Prover, and Nodes.
4: Account AbstractionDelve deep into Starknet’s unique approach to user accounts, and master the art of crafting custom accounts.
5: STARKsDive 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:

  1. Starklings: A resource specifically designed to guide you through learning Cairo programming, ensuring that you reach a proficient level. You can access it here.

  2. Starknet Community Forum: An online platform where you can engage in discussions about the latest developments in Starknet. Join the conversation here.

  3. Starknet Documentation: You can browse through the documentation here.

  4. Cairo Documentation: Explore it here.

  5. 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:

  1. Is openly described by a public protocol.
  2. Operates over a wide, inclusive, peer-to-peer network.
  3. 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):

  1. 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.
  2. 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.
  3. 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:

  1. They are the main avenue for proposing new features or changes.

  2. They act as a platform for technical discussions within the community.

  3. 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.

  1. Visit The Remix Project.
  2. Navigate to the ‘Plugins’ section in the bottom left corner.
  3. Enable the “Starknet” plugin.
Activate the Starknet Plugin

Activate the Starknet Plugin

  1. 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.

  1. Dependencies and Interface:
    • starknet::ContractAddress: Represents a Starknet contract address.
    • OwnableTrait: Specifies functions for transferring and getting ownership.
  2. Events:
    • OwnershipTransferred1: Indicates ownership change with previous and new owner details.
  3. Storage:
    • Storage: Holds the contract's state with the current owner's address.
  4. Constructor:
    • Initializes the contract with a starting owner.
  5. External Functions:
    • Functions for transferring ownership and retrieving the current owner's details.
  6. Private Methods:
    • only_owner: Validates if the caller is the current owner.

Compilation Process

To compile using Remix:

  1. File Creation

    • Navigate to the "File Explorer" tab in Remix.
    • Create a new file named Ownable.cairo and input the previous code.
  2. 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.
Artifacts folder after compilation

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:

  1. Select the Appropriate Network

    • Go to the Environment selection tab.
    • Choose "Remote Devnet" for deploying your inaugural contract on a development network.
  2. 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.
  3. 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 the init_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:

  1. Initiating Interaction

    • Navigate to the "Starknet" tab.
    • Select the "Interact" option.
  2. 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:
{
  "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.

  1. 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).

  1. Install the recommended chrome/brave extension for your chosen wallet.
  2. Follow your wallet provider's instructions to deploy your account.
  3. Use the Starknet Faucet to fund your account.
  4. 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

  1. Follow the previous deployment steps.
  2. In the 'Environment selection' tab, choose 'Wallet Selection'.
  3. 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:

  1. 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.

  2. 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:

  1. Scarb: A package manager that compiles your contracts.

  2. Starkli: A CLI tool for interacting with the Starknet network.

  3. Starknet Foundry: For contract testing.

  4. Katana: Creates a local test node.

  5. SDKs: starknet.js, Starknet.py, and starknet.rs interface with Starknet using common programming languages.

  6. Starknet-react: Builds front-end apps using React.

Installation

This chapter walks you through setting up your Starknet development tools.

Essential tools to install:

  1. Starkli - A CLI tool for interacting with Starknet. More tools are discussed in Chapter 2.

  2. Scarb - Cairo’s package manager that compiles code to Sierra, a mid-level language between Cairo and CASM.

  3. 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:

  1. Your contract's class hash.
  2. 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 the name 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:

  1. Initialize: Use scarb new to set up a new project, generating a Scarb.toml file and initial src/lib.cairo.

  2. Code: Add your Cairo code in the src directory.

  3. Dependencies: Add external libraries using scarb add.

  4. 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 your Scarb.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.

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:

  1. Private Key: A 256-bit/32-byte/64-character (ignoring the 0x prefix) hexadecimal key that is the cornerstone of your wallet’s security.

  2. Public Key: Derived from the private key, it’s also a 256-bit/32-byte/64-character hexadecimal key.

  3. 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 and kdfparams: 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:

  1. 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.

  2. 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.

  3. 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:

  1. The class hash of your smart contract.

  2. 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:

Procedure:

  1. Create a new folder for the project.
  2. 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 a transaction.
  • 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 or cargo 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 and starkli 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:

  1. Ensure snforge is listed as a dependency in your Scarb.toml file, positioned beneath the starknet dependency. Your dependencies section should appear as (make sure to use the latest version of snforge and starknet):
starknet = "2.1.0-rc2"
snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "v0.7.1" }
  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:

  1. -name - Mandatory field.
  2. -address - Necessary account address.
  3. -private-key - Private key of the account.
  4. -add-profile - Though optional, it's pivotal. By enabling sncast to include the account in your Scarb.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:

  1. 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
  1. 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"}
  1. 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.

  1. 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.

  1. 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.

  1. 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 make new_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

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

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, and selectedAddress.

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 with await.
  • Post-disconnection, the state of the React component is updated:
    • setConnection is set to undefined.
    • setAccount is set to undefined.
    • 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, the connectToStarknet function is defined, aiming to establish an asynchronous connection using the connect function. Parameters like modalMode and webWalletUrl 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 using setConnection, setAccount, and setAddress.
  • 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:

  1. 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.
  2. Executes the contract's increment method. The await keyword ensures the program pauses until this action completes.
  3. On successful execution, the user receives a confirmation alert indicating the counter's increment.
  4. 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:

  1. 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.
  2. Initiates the contract's decrement method. With the use of the await keyword, the program ensures it waits for the decrement action to finalize.
  3. Upon successful execution, the user is notified with an alert indicating the counter's decrement.
  4. 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:

  1. Establishes a provider instance, specifying the sequencer network – in this instance, it's set to the mainnet through constants.NetworkName.SN_MAIN.
  2. With this provider, it then initiates a contract instance using the provided contract's ABI, its address, and the aforementioned provider.
  3. 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 the await keyword.
  4. Once successfully retrieved, the count, which is presumably a number, is converted to a string and stored using the setRetrievedValue function.
  5. 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:

  1. Establishing Connection: With the connectWallet function, we made seamless connections to the blockchain, paving the way for interactions with our smart contract.
  2. Terminating Connection: The disconnectWallet function ensures that users can safely terminate their active connections to the blockchain, maintaining security and control.
  3. Interacting with the Smart Contract: Using the increaseCounter, decreaseCounter, and getCounter 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.

Basic Dapp ERC20

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)
        }
    }
}
}
Basic Dapp ERC20

Now edit src/lib.cairo and replace the content with:

#![allow(unused)]
fn main() {
mod erc20;
}
Basic Dapp 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:

Localhost

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

  1. Register for an account at Vercel Signup.
  2. Install Vercel in your web application folder (erc20_web):
cd erc20_web/
npm i -g vercel
  1. 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.

Vercel login Vercel verify

On successful verification, you'll receive a confirmation in the console.

  1. 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)
  1. Upload it:
vercel
  1. 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.

Vercel publication

Engage with your app by:

  • Connecting your wallet:
Vercel publication 2
  • Checking your balance:
Vercel publication 3
  • Transferring tokens:
Vercel publication 4

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:

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"

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.

Basic Dapp ERC20

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)
        }
    }
}
}
Basic Dapp ERC20

Now edit src/lib.cairo and replace the content with:

#![allow(unused)]
fn main() {
mod erc20;
}
Basic Dapp 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&nbsp;
        </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:

Basic Dapp ERC20 React Files

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:

  1. At the root of your project, create a new directory named assets/.
  2. Inside the assets/ directory, create an empty JSON file named erc20.json.
  3. Go back to your ERC20 Cairo project folder and locate the erc20/target/erc20_erc20_sierra.json file.
ABI Original
  1. Extract the ABI definition (ensuring you include the square brackets) and integrate it into the previously created assets/erc20.json file.
ABI Updated

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.

Localhost

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

  1. Register for an account at Vercel Signup.
  2. Install Vercel in your web application folder (erc20_web):
cd erc20_web/
npm i -g vercel
vercel init
  1. Authenticate your Vercel account:
vercel login

After entering your email, check your inbox and click on the "Verify" button.

Vercel login Vercel verify

On successful verification, you'll receive a confirmation in the console.

  1. Link your project to Vercel:
vercel link
  1. Upload it:
vercel
  1. Publish your project:
vercel --prod

Congratulations! Your MKT token web3 application is now accessible to everyone.

Vercel publication

Engage with your app by:

  • Connecting your wallet:
Vercel publication 2
  • Checking your balance:
Vercel publication 3
  • Transferring tokens:
Vercel publication 4

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.

homepage

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 felt252s. 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 felt252s 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.

Wallets

Modals

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

Wallets

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>&nbsp;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 felt252s, 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.

homepage

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 felt252s. 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 and snforge 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:

felt252

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:

u256 u256

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:

u128 u128

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);
    }
}
}
deploy

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):

get_storage

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

block explorer

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:

  1. Cairo-fuzzer: A tool for smart contract developers to test security. It functions both as a standalone tool and as a library.
  2. Caracal: A static analysis tool for Starknet smart contracts, utilizing the SIERRA representation.
  3. 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

cairo-fuzzer
  • 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.

    thoth

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

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:

  1. Sequencing: They collect transactions from users and order (sequence) them.

  2. Executing: Sequencers then process these transactions.

  3. Batching: Transactions are grouped together in batches or blocks for efficiency.

  4. 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:

  1. Receiving Blocks: Provers obtain blocks of processed transactions from Sequencers.

  2. Processing: Provers process these blocks a second time, ensuring that all transactions within the block have been correctly handled.

  3. Proof Generation: After processing, Provers generate a proof of correct transaction processing.

  4. 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:

  1. 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.

  2. 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.

  3. 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:

Transaction Overview

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:

  1. Queries their account nonce, which acts as a unique identifier for the transaction.
  2. Signs the transaction.
  3. 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:

  1. Receive the transaction.
  2. Validate it.
  3. Execute it.
  4. 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:

  1. 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

  1. Received -> Reverted

Transaction Lifecycle Summary

The following outlines the various steps in a transaction's lifecycle:

Transaction flow

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:

ComponentLimit
Cairo Steps200,000,000
Pedersen Hashes5,000,000
Signature Verifications1,000,000
Range Checks2,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:

  1. The count of Cairo steps.
  2. 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:

ComponentGas CostRange
Cairo Step0.01 gwei/gasper step
Pedersen0.32 gwei/gasper application
Poseidon0.32 gwei/gasper application
Range Check0.16 gwei/gasper application
ECDSA20.48 gwei/gasper application
Keccak20.48 gwei/gasper application
Bitwise0.64 gwei/gasper application
EC_OP10.24 gwei/gasper 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

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

Cairo VM

Cairo Native or Cairo VM

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

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:

  1. Parallelization: Recursive proofs allow for work parallelization, reducing user latency and improving SHARP efficiency.

  2. 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.

  3. Lower cloud costs: Since each job is shorter, the required memory for processing is reduced, resulting in lower cloud costs.

  4. Optimization: Recursive proofs enable SHARP to optimize for various factors, including latency, on-chain costs, and time to proof.

  5. 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:

  1. Gateway: Cairo jobs enter SHARP through the gateway.

  2. Job Creator: It prevents job duplication and ensures that the system operates consistently, regardless of multiple identical requests.

  3. 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.

  4. 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.

  5. 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.

  6. Prover: The Prover computes the proofs for each train (that contains a few jobs).

  7. Dispatcher: The Dispatcher serves two functions in the SHARP system.

    1. 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.

    2. 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.

  8. 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.

  9. 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

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:

  • CPU — $193

  • Board (can attempt w/ Raspberry Pi) — $110

  • Disk — $100

  • RAM — $60

  • PSU — $40

  • Case — $50

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:

  1. Navigate to the contract on StarkScan and connect to your wallet.

  2. Enter a new value and write the transaction:

Starkscan block explorer

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

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.

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

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

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

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 using verifyProofAndRegister. It acts as an umbrella for various layouts, each named CpuFrilessVerifier. Every layout has a unique combination of built-in resources.
Verifier Layouts

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

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 the delegate_call method. Notably, the state resides in the GpsStatementVerifier contract, not in the proxy.
  • CallProxy: Positioned between the Proxy and the GpsStatementVerifier contracts, it functions like a typical proxy. However, it avoids the delegate_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:

Constructor Parameters Constructor Parameters

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, and EcdsaPointsYColumn.
  • Poseidon-Related Contracts: Several PoseidonPoseidonFullRoundKey and PoseidonPoseidonPartialRoundKey contracts.
  • Sampling and Memory: The contract uses CpuOods for out-of-domain sampling and MemoryPageFactRegistry for memory-related tasks.
  • Verification: It integrates with MerkleStatementContract for merkle verification and FriStatementContract for Fri-related tasks.
  • Security: The num_security_bits and min_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 through CpuFrilessVerifier7) to decentralize tasks.
  • Verification: The hashes, hashed_supported_cairo_verifiers and simple_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

Sharp Verification Flow

  1. 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).

  2. The Sharp dispatcher then forwards the proof using verifyProofAndRegister.

  3. 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:

  1. 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.
  2. 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:

  1. 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.
  2. 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.
  3. Key Rotation:

    • If a key is compromised, it can be easily replaced without needing to transfer assets.
  4. Session Keys:

    • AA facilitates a sign in once feature for web3 applications, allowing transactions on your behalf and minimizing constant approvals.
  5. 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:

  1. Improved Key Management:

    • Multiple devices can be linked to your wallet, ensuring account access even if one device is lost.
  2. Diverse Signature and Validation Schemes:

    • AA accommodates additional security measures like two-factor authentication for substantial transactions, catering to individual security needs.
  3. 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:

  1. 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.
  2. 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:

  1. 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.
  2. 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.
  3. 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 a felt252 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 a felt252.
  • __execute__: After validation, __execute__ carries out a series of contract calls (as Call structs). It gives back an array of Span<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:

  1. Locally determining the potential address of our account contract without actual deployment, feasible with the Starkli [5] tool.
  2. Transferring sufficient ETH to the predicted address to cover the deployment costs.
  3. 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. No declare 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 the supports_interface function in confirming interface support.

  • Detailed the Call struct to represent a single contract call, explaining its components: to, selector, and calldata.

  • 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.
  • 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.
  • 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, returning VALID or 0.
    • Uses is_valid_signature_bool to return a true or false validation result.
  • Declare and Deploy Function Validation

    • Sets up __validate_declare__ to check the declare function's signature.
    • Designs __validate_deploy__ for counterfactual deployments.
    • Abstracts core validation to validate_transaction.
  • Transaction Execution Logic

    • Enables multicall capability with __execute__.
    • Handles calls individually with execute_single_call and in batches with execute_multiple_calls.
  • 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.
  • Contract Self-Identification

    • Allows self-identification with the SRC-5 standard via supports_interface.
  • 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:

  1. 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.

  2. 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.

  3. 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:

  1. 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.

  2. 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.

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:

  1. Submitting a transaction: Any of the owners can submit a transaction from the account.

  2. 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.

Auto-Payments 🚧

Alternative Signature Schemes 🚧