Building Upgradable Smart Contracts on StarkNet

Smart contracts on StarkNet provide a solid foundation for building decentralized applications. Using OpenZeppelin’s libraries and the StarkNet Foundry (snforge), you can create upgradable contracts that allow updates without losing existing data or functionality. This guide will take you through the steps to set up your environment, create an upgradable contract, and handle deployment and upgrades effectively.

Prerequisites

Before starting, ensure you have:

A basic understanding of Cairo programming language.The latest version of Scarb installed. If not, install it here.

Setting Up the Environment

Step 1: Initialize the Project

Run the following commands to initialize a project:

npm init -y
snforge new

Step 2: Configure Scarb.toml

Open your Scarb.toml file and add the following dependencies and configurations:

[dependencies]
openzeppelin = { git = “https://github.com/OpenZeppelin/cairo-contracts.git”, tag = “v0.20.0” }
[dependencies]
openzeppelin = { git = “https://github.com/OpenZeppelin/cairo-contracts.git”, tag = “v0.20.0” }

[[target.starknet-contract]]
allowed-libfuncs-list.name = “experimental”
sierra = true
casm = true

[tool.fmt]
sort-module-level-items = true
max-line-length = 120

Step 3: Install Node.js Dependencies

Install the required Node.js libraries:

npm install dotenv starknet

Step 4: Add .env File

Create a .env file in your project root and populate it with the following variables:

DEPLOYER_PRIVATE_KEY=
DEPLOYER_ADDRESS=
RPC_ENDPOINT=https://starknet-sepolia.public.blastapi.io/

Step 5: Creating the Contract

Navigate to the src folder and open the lib.cairo file. Add the following contract code:

#[starknet::interface]
trait ITry<TContractState> {
fn get_value(self: @TContractState) -> u128;
fn increase_value(ref self: TContractState) -> u128;
}

#[starknet::contract]
mod gammer {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::upgrades::interface::IUpgradeable;
use openzeppelin::upgrades::UpgradeableComponent;
use starknet::{ClassHash, ContractAddress};
component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl<ContractState>;

#[storage]
struct Storage {
#[substorage(v0)]
ownable: OwnableComponent::Storage,
#[substorage(v0)]
upgradeable: UpgradeableComponent::Storage,
value: u128,
name: felt252
}

#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
OwnableEvent: OwnableComponent::Event,
#[flat]
UpgradeableEvent: UpgradeableComponent::Event,
}

#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress, initial_value: u128) {
self.ownable.initializer(owner);
self.value.write(initial_value);
}

#[abi(embed_v0)]
impl TryImpl of super::ITry<ContractState> {
fn get_value(self: @ContractState) -> u128 {
self.value.read()
}
fn increase_value(ref self: ContractState) -> u128 {
self.value.write(self.value.read() + 1);
self.value.read()
}
}

#[abi(embed_v0)]
impl UpgradeableImpl of IUpgradeable<ContractState> {
fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {
self.ownable.assert_only_owner();
self.upgradeable.upgrade(new_class_hash);
}
}
}

Short Explanation of the gammer Contract

This Cairo smart contract demonstrates how to build an upgradable smart contract on StarkNet using OpenZeppelin’s libraries. The primary features include:

Ownership and Upgradeability:The contract uses the OwnableComponent to enforce that only the owner can perform specific actions, such as upgrades.The UpgradeableComponent provides functionality to update the contract’s implementation (ClassHash) without losing the existing state.

2. State Management:

Stores a value (a u128 integer) that can be retrieved or incremented using exposed functions.Implements an upgradeable structure that ensures the contract’s state variables remain consistent across upgrades.

3. Key Functions:

get_value: Reads the current value stored in the contract.increase_value: Increments the stored value by 1.upgrade: Allows the contract owner to update the implementation using a new ClassHash.

4. Upgradability:

The upgrade function ensures future-proofing by allowing the contract’s functionality to evolve while retaining its state.This is achieved using the OpenZeppelin IUpgradeable interface and UpgradeableComponent.

4. Events:

Tracks important actions, such as upgrades and ownership changes, via emitted events.

This contract is a foundational example of how to use OpenZeppelin’s tools to manage ownership and upgrades, ensuring secure and scalable smart contract deployments on StarkNet.

Declaring and Deploying the Contract

Step 6: Create Scripts Folder

Create a scripts folder and add a deploymentsOrDeclare.ts file. Paste the following code:

import { Account, CallData, Contract, RpcProvider, stark } from “starknet”;
import * as dotenv from “dotenv”;
import fs from “fs”;
import path from “path”;
import { getCompiledCode } from “./utils”;
dotenv.config({ path: __dirname + “/../.env” });

async function main() {
const provider = new RpcProvider({
nodeUrl: process.env.RPC_ENDPOINT,
});

// initialize existing predeployed account 0
console.log(“ACCOUNT_ADDRESS=”, process.env.DEPLOYER_ADDRESS);
console.log(“ACCOUNT_PRIVATE_KEY=”, process.env.DEPLOYER_PRIVATE_KEY);
const privateKey0 = process.env.DEPLOYER_PRIVATE_KEY ?? “”;
const accountAddress0: string = process.env.DEPLOYER_ADDRESS ?? “”;
const account0 = new Account(provider, accountAddress0, privateKey0);
console.log(“Account connected.n”);

let name = “new_gammer”;

// Define the path to the .env file
const envFilePath = path.resolve(__dirname, “../.env”);

// Declare
let sierraCode, casmCode;

try {
({ sierraCode, casmCode } = await getCompiledCode(
“gammer_gammer”
));
} catch (error: any) {
console.log(“Failed to read contract files”);
process.exit(1);
}

const declareResponse = await account0.declare({
contract: sierraCode,
casm: casmCode,
});

console.log(“Contract classHash: “, declareResponse.class_hash);

fs.appendFileSync(
envFilePath,
`n${name.toUpperCase()}_CLASS_HASH=${declareResponse.class_hash}`
);

// =============================================================================
// Deployment
const myCallData = new CallData(sierraCode.abi);
const constructor = myCallData.compile(“constructor”, {
owner: “0x0118093daad51ef5c39b07201ee09e48edeffaa12e3fbb33d6d0c65eb00dfdfa”,
intial_value: 10,
});

const { transaction_hash, contract_address } = await account0.deploy({
classHash: process.env.GAMMER_CLASS_HASH as string,
constructorCalldata: constructor,
salt: stark.randomAddress(),
});

const contractAddress: any = contract_address[0];
await provider.waitForTransaction(transaction_hash);

// Connect the new contract instance :
const try_contract = new Contract(sierraCode.abi, contractAddress, provider);

console.log(`✅ Contract has been connected at: ${try_contract.address}`);

fs.appendFileSync(
envFilePath,
`n${name.toUpperCase()}_ADDRESS=${contractAddress}`
);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

Step 7: Add Scripts to package.json

Update your package.json file:

“scripts”: {
“deploy-gammer”: “ts-node scripts/deploymentsOrDeclare.ts”,
“upgrade”: “ts-node scripts/upgradeContracts.ts”
}

Step 8: Declare and Deploy

Run the following commands to build and declare:

scarb build &
npm run deploy-gammer

A new environment variable is automatically appended to your .env file: GAMMER_CLASS_HASH & GAMMER_ADDRESS

Step 9: Upgrading the Contract

To make the contract upgradable, add a new function to decrease the value. Update the ITry and TryImpl implementations.

Updated Contract:

#[starknet::interface]
trait ITry<TContractState> {
fn get_value(self: @TContractState) -> u128;
fn increase_value(ref self: TContractState) -> u128;
//new function
fn decrease_value(ref self: TContractState) -> u128;
}

#[starknet::contract]
mod gammer {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::upgrades::interface::IUpgradeable;
use openzeppelin::upgrades::UpgradeableComponent;
use starknet::{ClassHash, ContractAddress};

component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);

#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
// Upgradeable
impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl<ContractState>;

#[storage]
struct Storage {
#[substorage(v0)]
ownable: OwnableComponent::Storage,
#[substorage(v0)]
upgradeable: UpgradeableComponent::Storage,
value: u128,
name: felt252
}

#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
OwnableEvent: OwnableComponent::Event,
#[flat]
UpgradeableEvent: UpgradeableComponent::Event,
}

#[constructor]
fn constructor(ref self: ContractState,owner: ContractAddress, intial_value: u128) {
self.ownable.initializer(owner);
self.value.write(intial_value );
}

#[abi(embed_v0)]
impl TryImpl of super:: ITry<ContractState> {
fn get_value(self: @ContractState)-> u128 {
self.value.read()
}

fn increase_value(ref self: ContractState) -> u128{
self.value.write(self.value.read() + 1);
self.value.read()
}
//new function
fn decrease_value(ref self: ContractState) -> u128{
assert(self.value.read() > 0, ‘number < 0’);
self.value.write(self.value.read() – 1);
self.value.read()
}
}

//
// Upgradeable
//

#[abi(embed_v0)]
impl UpgradeableImpl of IUpgradeable<ContractState> {
fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {
self.ownable.assert_only_owner();
self.upgradeable.upgrade(new_class_hash);
}
}
}

Step 10: Update deploymentsOrDeclare.ts and Declare Upgrade

Update deploymentsOrDeclare.ts to declare the updated contract:

let name = “gammer_v1”;

Step 11: Declare

Declare the new contract version, make sure the deployment code is commented before running the command below:scarb build &
npm run deploy-gammer

A new environment variable is automatically added to your .env file:
GAMMER_V1_CLASS_HASH

Step 12: Create upgradeContracts.ts

Create an upgradeContracts.ts file in the scripts folder with the following content:

import { Account, CallData, Contract, RpcProvider, stark } from “starknet”;
import { getCompiledCode } from “./utils”;

import fs from “fs”;
import dotenv from “dotenv”;

dotenv.config({ path: __dirname + “/../.env” });

const provider = new RpcProvider({
nodeUrl: process.env.RPC_ENDPOINT,
});

// initialize existing predeployed account 0
console.log(“ACCOUNT_ADDRESS=”, process.env.DEPLOYER_ADDRESS);
console.log(“ACCOUNT_PRIVATE_KEY=”, process.env.DEPLOYER_PRIVATE_KEY);
const privateKey0 = process.env.DEPLOYER_PRIVATE_KEY ?? “”;
const accountAddress0: string = process.env.DEPLOYER_ADDRESS ?? “”;
const owner = new Account(provider, accountAddress0, privateKey0);
console.log(“Account connected.n”);

async function upgrade() {
const new_class_hash = process.env.GAMMER_V1_CLASS_HASH as string;
const contract_address = process.env.GAMMER_ADDRESS as string;

// Declare
let sierraCode, casmCode;

try {
({ sierraCode, casmCode } = await getCompiledCode(“gammer_gammer”));
} catch (error: any) {
console.log(“Failed to read contract files”);
process.exit(1);
}

const contract = new Contract(sierraCode.abi, contract_address, owner);
let result = await contract.upgrade(new_class_hash);
console.log(“✅ contract upgraded approved, amount:”, new_class_hash);
console.log(result, result);
}

async function main() {
await upgrade();
}

main();

Step 13: Upgrade the Contract

Run the upgrade command:

npm run upgrade

Conclusion

By leveraging OpenZeppelin’s upgradeable library and snforge, you’ve successfully built, deployed, and upgraded an upgradable contract on StarkNet. Always respect the order of state variables to ensure smooth upgrades. Explore more possibilities with StarkNet’s advanced features!

Access GitHub repo here : https://github.com/Muhindo-Galien/starknet-upgredeable-contracts

Building Upgradable Smart Contracts on StarkNet was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

By

Leave a Reply

Your email address will not be published. Required fields are marked *