Code Upgradeability Made Easy with the Algorand VM
Overview
As we all know, Algorand Layer-1 contracts can opt-in to upgradeability with simple or complex logic that handles authentication and authorization of the upgrade (or elect not to enable it).
However, this isn’t always the case. Many blockchains can only implement immutable contracts which can make patching vulnerabilities or adding features impossible.
Developers are no strangers to immutability though and quickly figured out that it’s always possible to deploy new versions of the same application and have strategies to redirect traffic.
Table of content - Upgradeability
- The Upgrade Problem
- Algorand In-place Upgrades
- Authorization Strategy
- Full Contract Migration
- Proxy Pattern
- Real Use Cases
- Conclusion
The Upgrade Problem
But why would we even want to upgrade the same contract and not use one that’s brand new?
Well, the answer is that applications on-chain can own stored data, assets and (sometimes) even whole accounts! Migrating this way would mean losing all customer’s data. Not a great idea.
The most basic tactic is the Contract Migration strategy. It consists of just deploying a new version of the application and having measures in place to migrate data to the new app. This is often avoided and very costly because each transaction can only move so much data and so the number of transactions could be huge.
On the other hand, the most common strategies always boil down to adding a level of indirection (from now on Proxy Pattern) either on the stored data or on the application logic. This means that the first application either redirects the call to use data stored elsewhere, or redirects it to execute logic deployed in another application reducing what could be a very resource-intensive process into a change of a few bytes.
This mechanism hinges on the fact that, although code is immutable, the data owned by the application can be changed. The Proxy contract owns references to the actual business logic or customer data that can be changed to point to new versions.
References:
Algorand In-place Upgrades
This is how it looks on Algorand. (Seriously, no indirection on this one…)
updateApplication() {
assert(this.txn.sender === this.app.creator);
}
This code will approve or reject a proposed upgrade following simple or complex logic. In this case, anything that the creator of the contract says goes. The code will be upgraded for the same App ID and therefore the application will keep storage, assets, global configurations, user data, and accounts owned.
In the rest of this article, we’ll be observing what kinds of problems Algorand native upgradeability solves by exploring upgrade strategies on Algorand that do not use this upgrade primitive. All the examples are in TEALScript.
Full Contract Migration
This strategy technically retains nothing of the original and immutable application deployment but rather re-deploys a brand new contract which then is bootstrapped with a copy of the old data. Since it’s just a copy of the old data, no authorization strategy is required. It’s up to the users to trust the new creator, the new deployment, and the copied data.
Let’s talk about the peculiarities in Algorand of migrating (copying) data to a new contract. Algorand offers three types of storage: Global, Local, and Boxes.
While there are many differences between these storage types, for the sake of this experiment it’s enough to say that Global and Boxes are owned by the application and can be written to/read freely by the application logic. This is because the balance required to use storage on-chain falls directly on the creator of the app or the app itself.
Local state is paid for by the user of the app and this means that nobody can force users to opt-in another app and unlock that storage. This makes a contract migration that targets a local storage contract complex. The state can be migrated only if the user voluntarily opts into the new app.
For this reason, we’ll focus on an ASA migration and a storage migration writing only to Global storage and Boxes. We’ll just keep the fact that migrating to Local storage is possible but has an opt-in as a prerequisite in the back of our minds.
The following code is a contract that allows its creator to initialize Global and Box storage. As highlighted in the reference article, there is a very important data collection process that needs to take place off-the-chain before migration that we won’t be covering.
It’s also important that the same state schema is applied to the new application and that the contract code is tuned to the same state schema
// eslint-disable-next-line no-unused-vars
class Migration extends Contract {
// There are at most 64 global storage slots so the sum of
// the bytes and ints should be less than that.
globalInts = GlobalStateMap<bytes, number>({ maxKeys: 32, prefix: 'i-' });
globalBytes = GlobalStateMap<bytes, bytes>({ maxKeys: 32, prefix: 'b-' });
boxBytes = BoxMap<bytes, bytes>();
migrateASA(asa: Asset): void {
sendAssetTransfer({
assetSender: this.txn.sender,
assetReceiver: this.txn.sender,
xferAsset: asa,
assetAmount: 0,
fee: 0,
});
}
migrateGlobalInt(key: bytes, value: number): void {
this.globalInts(key).value = value;
}
migrateGlobalBytes(key: bytes, value: bytes): void {
this.globalBytes(key).value = value;
}
migrateBoxBytes(key: bytes, value: bytes): void {
this.boxBytes(key).value = value;
}
}
Easy stuff. Although, as noted earlier, anyone with an account can perform this process so it is empty of any security guarantees on the new creator and/or the new data. For upgrades that keep components of the previous deployment, we want to make sure that the smart contract can guarantee at least some security.
Authorization Strategy
Now that we covered contract migration and why it’s not ideal, let us take a detour and discuss who should be in charge of authorizing a non-migration upgrade.
For all TEALScript code in this article, we’ll assume that the contract was created by a Multisignature 3-out-of-5 account. The contract will verify that the creator is that particular kind of Multisignature by verifying its address against the set of Singlesig accounts that participate in it and the threshold parameter. Any upgrade proposed by the creator is automatically accepted. The enhanced security of this approach comes from the fact that it’s harder to lose 3 keys at the same time to the same bad actor without noticing rather than losing a single key.
import { Contract } from '@algorandfoundation/tealscript';
// eslint-disable-next-line no-unused-vars
class Migration extends Contract {
createApplication(
version: number,
participants: StaticArray<Address, 5>,
): void {
assert(participants.length === 5);
assert(sha512_256(
'MultisigAddr'
+ extract3(itob(version), 7, 1)
+ extract3(itob(3), 7, 1)
+ rawBytes(participants[0])
+ rawBytes(participants[1])
+ rawBytes(participants[2])
+ rawBytes(participants[3])
+ rawBytes(participants[4]),
) === castBytes<byte[32]>(this.txn.sender));
}
}
On Algorand, the address of a Msig account is determined by its participants and some other public parameters. Although a contract cannot directly see or verify the signature of a transaction, it can determine if the public set of participants corresponds to the caller address as providing a fake set of addresses that incidentally map to a Multisig address is computationally infeasible. It would be equivalent to finding the inverse mapping of a hash function.
Proxy Pattern
This code example will make it clear how it might be possible to do a code upgrade through Proxy on Algorand. If you are interested in making a code upgrade that also preserves storage and assets, please make sure to check out the GitHub project associated with this article (link at the top).
Before we investigate what the Proxy Pattern might look like on Algorand, it’s important to understand how it’s often implemented in EVM.
The EVM, just like the AVM, has standards in place that delineate how to call a specific method on a contract (in both cases called Application Binary Interface or ABI). (EVM ABI / AVM ABI)
So what happens when a call fails to match a method on the contract? Well, on the EVM there’s a special function called the fallback function. OpenZeppelin (a popular library for contracts on EVM) actually uses this function such that any call to the contract actually is then delegated transparently to the proxied contract.
On the other hand, Algorand contracts compliant with this standard cannot (nor do they need to) implement a fallback function. In our case, this means that both contracts will implement the same interface instead of the proxy being fully transparent.
Proxy
// eslint-disable-next-line no-unused-vars
class CalculatorProxy extends Contract {
targetApplication = GlobalStateKey<Application>();
setTargetApplication(app: Application): void {
assert(this.txn.sender === this.app.creator);
this.targetApplication.value = app;
}
// eslint-disable-next-line no-unused-vars
sum(a: number, b: number, targetApp: Application): number {
return sendMethodCall<[number, number, Application], number>({
applicationID: this.targetApplication.value,
name: 'sum',
methodArgs: [a, b, this.app],
});
}
}
Implementation
// eslint-disable-next-line no-unused-vars
class ImmutableOld extends Contract {
sourceApplication = GlobalStateKey<Application>();
setSourceApplication(app: Application): void {
assert(this.txn.sender === this.app.creator);
this.sourceApplication.value = app;
}
// eslint-disable-next-line no-unused-vars
sum(a: number, b: number, sourceApp: Application): number {
assert(this.txn.sender === this.sourceApplication.value.address);
return a + a;
}
}
Oops, we made a mistake in our implementation but we already deployed! Please take the time to look at this project on GitHub that can handle a new deployment and the proxied upgrade to the new version of the contract.
Real Use Cases
Now we will present some of the applications that leverage the techniques discussed in this article:
- USDC (an ERC20 Smart Contract) on EVM chains is a Proxy contract.
- AAVE uses a slightly customized Proxy Pattern. Their design allows the administrator to upgrade components in the smart contract and bootstrap with initial data.
- Wormhole uses a p2p network to handle notarization and attestation of events. These guardians (in Wormhole’s terms) can also sign meta-events related to the protocol itself such as upgrade events. Upgrade events consist of a signed hash of the new program which is saved on storage for future use. This is just the prelude that validates the information and sets up the application. The real code update happens in a subsequent call that just needs to check the hash of the new program against the approved one.
- Tinyman v2 contracts opted out of upgradeability.
Conclusion
Strictly immutable smart contracts need to jump through quite a few hoops in order to get upgradeability. This is unavoidable when immutability is not an opt-in feature but rather the only possibility.
Contract migration is an old technique still worth mentioning because it serves as a benchmark for newer strategies. Its costs are unreasonable even for a small application with not much data on it. Not to mention the security concerns, the operational burden, and the huge time and skills required.
The Proxy Pattern is a huge step forward because the smart contract can still govern itself and does not require migrating data but just implies a few re-deploys of code which is honestly not too bad. It is thanks to years of development and experience through failures that libraries can offer this kind of admin functionality and process orchestration at a level where most developers would be comfortable deploying upgradable applications.
However, it’s very rare that there is a technology that just makes the old problems disappear without compromise. Algorand upgrades are an example of this and can make upgrades optional, easy, safe, and potentially self-governing.