Creating Upgradable Smart Contracts on StarkNet

David Barreto
Starknet Edu
Published in
8 min readDec 17, 2022

TL;DR; To make a smart contract upgradable in preparation for Regenesis you’ll need to turn your constructor function into an initializer and add an upgrade function that changes the class hash of the implementation.

@external
func initializer{...}(proxy_admin: felt) {
// your constructor logic here
Proxy.initializer(proxy_admin);
return ();
}

@external
func upgrade{...}(new_implementation: felt) -> () {
Proxy.assert_only_admin();
Proxy._set_implementation_hash(new_implementation);
return ();
}

Besides your smart contract, you’ll also need OZ’s Proxy smart contract that’s already declared on Mainnet and Testnet. The deployment process happens in stages following the diagram below.

Once deployed, the Proxy uses the syscall library_call to delegate calls to the Implementation while preserving the Proxy as the context for storage.

To upgrade the implementation, simply call the function upgrade and pass the class hash of the new implementation contract.

The Problem We Want to Solve

I’ve always been wary of upgradable smart contracts. If a smart contract is analogous to a legal contract but governed by code instead of a legal framework, why would it be acceptable for one party (an admin) to change the clauses of a contract (the code) without explicit consent of the other party (the community)?

There are however a few scenarios where I think creating an upgradable smart contract is acceptable:

  1. When the upgrade process is controlled by the community using decentralized governance
  2. When a smart contract is being developed on a testnet and feedback is collected to improve its implementation prior to mainnet launch
  3. When you want to future proof your smart contract for Cairo 1.0 and Regenesis.

The last point is the most important right now. If you are planning to deploy a smart contract today on mainnet StarkNet and you want to prevent having to migrate users and assets (hard fork) when Regenesis happens, you’ll need to make your smart contract upgradable. Keep in mind that when upgrading your contract to Cairo 1.0 you can completely remove its ability to be upgraded from that point forward.

To make a smart contract upgradable you have to use the Proxy pattern.

The Proxy Pattern

The Proxy pattern is a well known mechanism for changing the code of a smart contract while preserving its internal state. At the heart of it is the syscall library_call that allows you to call any smart contract class while keeping the caller as the context for storage.

The term class might be confusing because there’s no “class” keyword in Cairo or smart contract inheritance for that matter. When we declare a smart contract on StarkNet we don’t provide the arguments of the constructor function simply because this function is not executed. Declaring is the act of uploading a compiled smart contract to StarkNet and deriving a unique identifier from its source code known as the class hash. This declared code is what we refer to as a smart contract class.

To create an instance of a class (a declared smart contract) we use the deploy syscall while providing the arguments for its constructor function. The relationship between smart contract class and instance on StarkNet is shown below.

Figure 1. A smart contract class vs an instance.

Another key difference between a smart contract class and an instance is that the latter has internal state or storage while the former doesn’t. A smart contract instance is what we usually interact with via the invoke or call transaction. A smart contract class is only ever used for deploying new instances or for creating reusable libraries.

When you combine the concepts of smart contract classes and instances with the way the library_call syscall works, you get the Proxy pattern.

Figure 2. The Proxy pattern applied to an upgradable ERC20 token contract.

Let’s review step by step how to create an upgradable ERC20 token using the Proxy pattern.

Creating an Upgradable ERC20 Token

The Proxy pattern requires two smart contracts, the Proxy and the Implementation. For this example, we are going to be using an ERC20 token as the Implementation but the concept is the same for any type of smart contract.

To create an upgradable ERC20 token we can use OpenZeppelin Contract Wizard for Cairo checking the box for “Upgradeability”.

Figure 3. Making an ERC20 token upgradable.

The Wizard gives us back a regular ERC20 smart contract with two key differences. Instead of a constructor it defines an initializer function that performs a similar job. It also adds a new upgrade function that changes the underlying implementation of the Proxy. More on that later.

Token_v1.cairo

...

@external
func initializer{...}(proxy_admin: felt) {
ERC20.initializer('MyToken', 'MTK', 18);
Proxy.initializer(proxy_admin);
return ();
}

...

@external
func upgrade{...}(new_implementation: felt) -> () {
Proxy.assert_only_admin();
Proxy._set_implementation_hash(new_implementation);
return ();
}

For the Proxy smart contract we can once again make use of OpenZeppelin’s library.

Proxy.cairo

...

@constructor
func constructor{...}(
implementation_hash: felt,
selector: felt,
calldata_len: felt,
calldata: felt*
) {...}

@external
@raw_input
@raw_output
func __default__{...}(
selector: felt,
calldata_size: felt,
calldata: felt*
) -> (...) {...}

...

Besides the constructor function, OZ’s implementation defines a __default__ function that is invoked anytime a provided selector doesn’t match any of the smart contract functions. This is a catch all technique that allows the Proxy to redirect all calls to the Implementation contract without knowing its public interface.

Now that we have our two smart contracts, let’s see step by step how to deploy them.

Step 1: Declare the Proxy and the Implementation

The process starts by uploading to StarkNet the code for both the Proxy and the Implementation smart contracts by using the declare transaction. This step will give us back the class hash of both classes that we will need for later steps.

If you forget to take note of the class hash of either smart contract, there’s no need to re-declare them again and spam the network. You can derive the class hash of any smart contract offline by using the command starknet-class-hash of the StarkNet CLI (cairo-lang).

Figure 4. Declaring the proxy and the implementation.

You can skip the declaration of the Proxy smart contract and save money on gas by using the Proxy class already declared on both Mainnet and Testnet and with the class hash:

0x601407cf04ab1fbab155f913db64891dc749f4343bc9e535bd012234a46dc61

Step 2: Deploy and Instance of the Proxy Class

Once the Proxy and the Implementation are declared, it’s time for deployment. The diagram below shows the deployment process at a high level.

Figure 5. Deploying the Proxy

As with every deployment, the process is orchestrated by the Universal Deployer Contract (UDC) that replaced the deploy transaction. If this is the first time you hear about the UDC, we have another article explaining how to use it.

Note that only the Proxy is deployed, the Implementation remains as a class. When deploying a contract, its constructor function is executed so we need to pass the expected parameters.

Proxy.cairo

@constructor
func constructor{...}(
implementation_hash: felt,
selector: felt,
calldata_len: felt,
calldata: felt*
) {
alloc_locals;
Proxy._set_implementation_hash(implementation_hash);

if (selector != 0) {
library_call(
class_hash=implementation_hash,
function_selector=selector,
calldata_size=calldata_len,
calldata=calldata,
);
}
return ();
}

In our case, we need to provide the class hash of our Implementation (the Token) as the value for implementation_hash. That’s why we needed to declare our Token contract before we deployed the Proxy.

The constructor uses the provided selector and calldata to redirect the call to the Implementation. As we mentioned before, when activating the option to make a Token upgradable in OZ’s contract wizard, we were given an initializer function instead of a constructor because the Token is never deployed when using the Proxy pattern but still needs to initialize some storage variables.

Token_v1.cairo

...
@external
func initializer{...}(proxy_admin: felt) {
ERC20.initializer('MyToken', 'MTK', 18);
Proxy.initializer(proxy_admin);
return ();
}
...

The initializer function is the one that has to be called from the Proxy’s constructor so we need to pass its selector as an argument, along with any expected argument for the initializer function. Because the initializer function expects to be provided with a proxy_admin address to control who can trigger an upgrade, we need to provide that value as part of the calldata passed to the Proxy’s constructor.

Step 3. Invoke an Implementation Function on the Proxy

Once the Proxy is deployed, we can invoke any function from our Token using the address of the Proxy instance. In the image below, we are trying to invoke the transfer function from the Token contract through the Proxy.

Figure 6. Interacting with the Implementation through the Proxy.

Using the __default__ catch all function, the Proxy redirects all calls to the Token class while preserving the Proxy storage as the context for any variable that needs to be accessed or modified.

Proxy.cairo

...
@external
@raw_input
@raw_output
func __default__{...}(
selector: felt, calldata_size: felt, calldata: felt*
) -> (retdata_size: felt, retdata: felt*) {
let (class_hash) = Proxy.get_implementation_hash();

let (retdata_size: felt, retdata: felt*) = library_call(
class_hash=class_hash,
function_selector=selector,
calldata_size=calldata_size,
calldata=calldata,
);
return (retdata_size, retdata);
}
...

Step 4. Upgrade the Implementation on the Proxy

There might come a time when you want to change the implementation behind the Proxy, either to add new features to your Token liken adding access control, or simply to use the new Cairo 1.0 syntax once it’s available. The process is the same and it is depicted below.

Figure 7. Upgrading the implementation behind the Proxy.

The first step is to declare the new Implementation, the new Token contract, and take note of the returned class hash. Then, we can invoke the upgrade function that OZ added to our upgradable Token v1 smart contract and pass the class hash of the new Implementation, Token v2.

Token_v1.cairo

...
@external
func upgrade{...}(new_implementation: felt) -> () {
Proxy.assert_only_admin();
Proxy._set_implementation_hash(new_implementation);
return ();
}
...

Keep in mind that Token v2 will need to also implement an upgrade function if you want to one day upgrade to Token v3.

Conclusion

Creating an upgradable smart contract on StarkNet requires two smart contracts, the Implementation and the Proxy. The deployment process requires multiple steps that, although it’s possible to do them all using the StarkNet CLI, it’s recommended to automate them using an SDK like starknet.py or starknet.js.

The syscall library_call allows the Proxy to use the Implementation code while using the Proxy storage for any variable involved. This means that the Implementation can be changed without affecting the internal state of the smart contract. In other words, without changing the value of any of the storage variables.

Even if you are not a fan of upgradable smart contracts, you will still need to use this design pattern to allow for a smooth transition of your dapp during Regenesis. Upgradability can be permanently removed at any time, either during the Cairo 1.0 transition or by setting the 0 address as the proxy admin so no one is able to call the upgrade function ever again.

References

--

--

David Barreto
Starknet Edu

Starknet Developer Advocate. Find me on Twitter as @barretodavid