Governance-based contract upgrades on Aptos

Wormhole
11 min readJan 20, 2023

--

Introduction

Smart contract upgradeability is a double edged sword. On the one hand, it allows for feature evolution, and more importantly, fixing critical bugs in-place without incurring migration overhead and associated risks. On the other hand, upgradeability vastly changes the trust assumptions of the contract, because now users must trust the maintainer’s intentions and operational security practices.

When designing a protocol, it’s important to consider how upgradeability fits into the trust model, and ideally develop a solution that doesn’t weaken that model. For example, Wormhole and its associated contracts support upgradeability, but upgrades must be authorised by a decentralised governance body (called the guardians). This is the same set of guardians that are responsible for attesting to the validity of cross-chain messages in the Wormhole ecosystem, so the upgradeability governance does not change the fundamental trust model of the protocol. Contract upgrades are proposed by contributors (Wormhole is open source), and the guardians vote by generating a cryptographic signature approving the upgrade. The contract then checks that a sufficient number of guardians voted before proceeding with the upgrade.

This procedure ensures that no single entity has authority to change the protocol’s behaviour. Ensuring the consistency of this process between all contract implementations on the chains supported by Wormhole is challenging. On some chains, like Ethereum, implementing custom upgrade logic is straightforward (even though upgradeability itself is rather complex). On the other hand, some chains like Aptos require a bit of creativity. In this post, we’ll discuss how contract upgrades work on Aptos, why implementing Wormhole’s upgradeability protocol is challenging, and the solution we developed.

Contract deployment and upgrades on Aptos

Aptos blockchain is based on the Move programming language. In Move, smart contracts packages are made up of several modules, and these modules are owned by the account that deployed them. Unlike Ethereum, where upgrades are implemented via convoluted proxy setups, Aptos supports contract upgradeability natively.

The aptos framework defines the following function for both deployment and upgrades:

public entry fun publish_package_txn(owner: &signer, metadata_serialized: vector<u8>, code: vector<vector<u8>>)

/aptos-move/framework/aptos-framework/sources/code.move#L188

publish_package_txn requires that the owner of the package be a signer, and it takes the package metadata and the bytecode for each module. If the package is deployed for the first time, then it simply publishes the modules under the owner's account. If the package was deployed before, i.e. it already exists under owner, then it performs an upgrade (after checking certain conditions, such as that the new package bytecode is compatible with the old one). While convenient for most use cases, the notion that the deployer owns the contract (and thus controls its upgradeability) is problematic for Wormhole's trust model. The deployer should have no special permissions beyond deploying and initialising the contract for the first time.

To solve this issue, we turn to a construct provided by the Aptos framework: resource accounts.

Resource accounts on Aptos

Resource accounts are similar to smart contract accounts on Ethereum or PDAs on Solana in that access to them is provided not by means of cryptographic signatures, but runtime support in the virtual machine. What this means is that they make it possible to implement programmatic access control without giving special authority to any single given entity. In other words, resource accounts behave like smart contract accounts on Ethereum, as opposed to externally owned accounts (EOAs). Unlike smart contract accounts however, resource accounts are created independently from smart contract creation.

A resource account can be created by calling the following function:

public fun create_resource_account(source: &signer, seed: vector<u8>): (signer, SignerCapability)

/aptos-move/framework/aptos-framework/sources/account.move#L421

The account’s address is derived by taking the sha3–256 hash of the signer’s address and some additional seed bytes, which are provided as input. This means that a particular address can deterministically create resource accounts. The create_resource_account function returns the signer and the SignerCapability for the resource account. Values of type signer in Move are similar to addresses, but can be used for additional access control, as creating values of this type is a priviliged operation by the runtime. In the case of resource accounts, whoever owns the account's signer can authorise actions on behalf of the account. Since values of type signer can not be stored on-chain (signer does not have the store or key abilities), the signer gets dropped at the end of the transaction. In order to be able recover the signer object beyond the transaction that created the resource account, create_resource_account returns a SignerCapability.

struct SignerCapability has drop, store { account: address }

/aptos-move/framework/aptos-framework/sources/account.move#L47

This value can be stored, and importantly, used to recover the signer by whoever owns it:

public fun create_signer_with_capability(capability: &SignerCapability): signer

/aptos-move/framework/aptos-framework/sources/account.move#L505

Now, the module deployment strategy is starting to take shape: we first create a resource account, then deploy the contract into the account. This means that the modules are owned not by the deployer, but an autonomous resource account. Finally, we transfer the SignerCapability object to the resource account itself, which it can then access programmatically through smart contract code. It will thus allow the contract to perform the required checks and authorise its own upgrades.

Deployer contract

To facilitate the deployment process, we implement an auxiliary deployer contract. The contract has the following entry point:

public entry fun deploy_derived(
deployer: &signer,
metadata_serialized: vector<u8>,
code: vector<vector<u8>>,
seed: vector<u8>
) acquires DeployingSignerCapability {
let deployer_address = signer::address_of(deployer);
let (resource_signer, signer_cap) = account::create_resource_account(deployer, seed);
move_to(&resource_signer, DeployingSignerCapability { signer_cap, deployer: deployer_address });
code::publish_package_txn(&resource_signer, metadata_serialized, code);
}

/aptos/deployer/sources/deployer.move#L84-L120

It creates a resource account by calling account::create_resource_account, and transfers the signer capability to it. It does so by storing the following struct in the freshly created account (using the move_to primitive):

struct DeployingSignerCapability has key {
signer_cap: account::SignerCapability,
deployer: address,
}

/aptos/deployer/sources/deployer.move#L79-L82

This struct contains the signer capability and the address of the deployer. Finally, the package is published under the resource account by calling code::publish_package_txn.

To recap, we now have our package deployed into a freshly created resource account, such that account holds its own signer capability in the DeployingSignerCapability object. This object is controlled by the deployer module, so the first thing the newly deployed contract needs to do is actually claim the signer capability object and store it under a module controlled by the contract itself. To do this, the deployer module provides the following function:

public fun claim_signer_capability(
caller: &signer,
resource: address
): account::SignerCapability acquires DeployingSignerCapability {
assert!(exists<DeployingSignerCapability>(resource), E_NO_DEPLOYING_SIGNER_CAPABILITY);
let DeployingSignerCapability { signer_cap, deployer } = move_from<DeployingSignerCapability>(resource);
let caller_addr = signer::address_of(caller);
assert!(caller_addr == deployer, E_INVALID_DEPLOYER);
signer_cap
}

/aptos/deployer/sources/deployer.move#L122-L134

It allows the caller to unlock the signer capability for the resource account, where the caller has to be the deployer of the package. This function removes the DeployingSignerCapability from the account by using the move_from primitive, which means it can only be called once (move_from simply reverts if the object is not available).

The claim_signer_capability is called by the contract itself. The Wormhole contract has an init function which claims the signer capability and initialises the contract state.

public entry fun init(
deployer: &signer,
chain_id: u64,
governance_chain_id: u64,
governance_contract: vector<u8>,
initial_guardians: vector<vector<u8>>
) {
let signer_cap = deployer::claim_signer_capability(deployer, @wormhole);
...

/aptos/wormhole/sources/wormhole.move#L56-L66

Since deployer::claim_signer_capability can only be called with the original deployer as the signer, that will ensure that init can also be called by the original deployer. The @wormhole address is a named address in the contract's Move.toml file, which we specify to be the resource account's address when compiling the contract by using the --named-addresses flag when invoking aptos compile. The rest of the init function performs routine initialisation steps, and stores the SignerCapability in the contract's state struct.

struct WormholeState has key {
...
/// The signer capability for wormhole itself
signer_cap: account::SignerCapability,
...
}

/aptos/wormhole/sources/state.move#L40-L69

The following function then provides access to this signer:

public(friend) fun wormhole_signer(): signer acquires WormholeState {
account::create_signer_with_capability(&borrow_global<WormholeState>(@wormhole).signer_cap)
}

/aptos/wormhole/sources/state.move#L218-L222

It’s critical that this function has a friend visibility. What that means is that it can only be called from modules that are explicitly marked as friends of the state module. One such friend module of Wormhole's state module is the contract_upgrade module

friend wormhole::contract_upgrade;

/aptos/wormhole/sources/state.move#L14

which we’ll be looking at in the next section.

Our contract is now deployed under a resource account and it stores its own signer capability. The final piece of the puzzle is implementing the upgradeability logic itself.

Contract upgrades

In the case of Wormhole, contract upgrades must be authorised by 2/3+ of the guardians. The way a guardian “votes” on a contract upgrade is by signing the hash of the contract’s bytecode with their private key. Once a sufficient number of valid signatures exist, the guardians produce a signed message (called a VAA) that can be submitted to the contract. This VAA includes the signatures, the contract hash, and some other metadata, such as the target chain (Aptos in this case). The contract will then check the signatures and allow upgrading to a new bytecode whose hash matches the one the guardians signed.

The wormhole contract perfroms the upgrade procedure in two transactions. In the first, we submit the signed VAA, which the contract will verify. In the second, we submit the actual bytecode. These two steps could be done in one go, but doing them separately is more flexible, as it allows for other use cases, such as incremental voting where governance participants individually submit their votes on-chain. Once a sufficient number of votes have been submitted, the upgrade can be performed. In the case of Wormhole, the votes are all submitted in a single transaction as part of the governance VAA.

Upgrade authorisation

First, we have an entry point that takes the VAA payload:

public entry fun submit_vaa_entry(vaa: vector<u8>) acquires UpgradeAuthorized {
let vaa = vaa::parse_and_verify(vaa);
vaa::assert_governance(&vaa);
vaa::replay_protect(&vaa);
let hash = parse_payload(vaa::destroy(vaa));
    authorize_upgrade(hash);
}

/aptos/wormhole/sources/contract_upgrade.move#L88-L90

submit_vaa_entry parses and verifies the VAA, then once those checks passed, passes the bytecode hash from the VAA on to authorize_upgrade. The precise details of the VAA format, parsing, and verification are outside the scope of this post, but take a look at “Enforcing resource invariants in Move” to read about ways to safely implement parsers in Move.

The authorize_upgrade function is implemented as follows:

fun authorize_upgrade(hash: vector<u8>) acquires UpgradeAuthorized {
let wormhole = state::wormhole_signer();
if (exists<UpgradeAuthorized>(@wormhole)) {
let UpgradeAuthorized { hash: _ } = move_from<UpgradeAuthorized>(@wormhole);
};
move_to(&wormhole, UpgradeAuthorized { hash });
}

/aptos/wormhole/sources/contract_upgrade.move#L92-L100

First, it acquires the contract’s signer from the state (it can do this, because wormhole::contract_upgrade a friend module of wormhole::state). Then it stores the UpgradeAuthorized struct in the contract's account:

struct UpgradeAuthorized has key {
hash: vector<u8>
}

/aptos/wormhole/sources/contract_upgrade.move#L42-L44

The UpgradeAuthorized struct represents the fact that a given bytecode hash has been authorised for upgrade by governance. Notice that authorize_upgrade will first remove this struct from the contract's account if it exists. This allows a newer upgrade to override an older upgrade authorisation that hasn't been performed. This is important, because an upgrade may fail, if the approved bytecode is not compatible with the previous contract's bytecode. In such cases, Aptos will reject the upgrade, so we need a mechanism to override such failed upgrades, otherwise the contract would accidentally become non-upgradeable.

Performing the upgrade

Finally, the upgrade function performs the upgrade.

public entry fun upgrade(
metadata_serialized: vector<u8>,
code: vector<vector<u8>>
) acquires UpgradeAuthorized {
assert!(exists<UpgradeAuthorized>(@wormhole), E_UPGRADE_UNAUTHORIZED);
let UpgradeAuthorized { hash } = move_from<UpgradeAuthorized>(@wormhole);
    // reconstruct the bytecode hash and check that it matches the
// authorized hash
let c = copy code;
vector::reverse(&mut c);
let a = keccak256(metadata_serialized);
while (!vector::is_empty(&c)) vector::append(&mut a, keccak256(vector::pop_back(&mut c)));
assert!(keccak256(a) == hash, E_UNEXPECTED_HASH);
let wormhole = state::wormhole_signer();
code::publish_package_txn(&wormhole, metadata_serialized, code);
}

/aptos/wormhole/sources/contract_upgrade.move#L111-L134

Notice that this function does not take a signer, which means it can be called by anyone in a permissionless way. This is desired, since it means that the only access control is the application logic that checks that the hash has been approved by a quorum of Wormhole guardians.

First we assert that the UpgradeAuthorized object exists in the wormhole contract's state, and revert otherwise with an error message. This is purely cosmetic, as the move_from expression on the next line would revert anyway if the resource didn't exist, but this provides a better error message in the failure case.

Next, the bytecode hash is constructed. More on the construction in the next section. Finally, we recover the wormhole resource account’s signer and invoke code::publish_package_txn to perform the upgrade.

The off-chain part

To help with computing the hash and submitting the upgrade transaction, we built a simple client using the Aptos typescript SDK to submit the upgrade transactions.

The second argument of the upgrade function in the contract_upgrade.move module, code, is an array of the module bytecodes generated by move compiler from our move source files. The order in which they are submitted is important for two reasons. Firstly, the hash is recomputed on-chain (by upgrade above), thus the order must match the off-chain order in which the hash was computed for governance voting. Secondly, the bytecode verifier that runs upon upgrading fails when the bytecode of a module appears before other modules that it depends on. The order in which the move compiler outputs the modules to the standard output is in the correct order, so we use that. For example, when compiling the wormhole contracts, it prints the following:

$ aptos move compile --save-metadata --included-artifacts none --named-addresses wormhole=0x5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625,deployer=0x0108bc32f7de18a5f6e1e7d6ee7aff9f5fc858d0d87ac0da94dd8d2a5d267d6b 2&>/dev/null
{
"Result": [
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::u32",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::u16",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::keccak256",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::guardian_pubkey",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::structs",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::set",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::u256",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::serialize",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::cursor",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::deserialize",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::external_address",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::emitter",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::state",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::vaa",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::contract_upgrade",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::guardian_set_upgrade",
"5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625::wormhole"
]
}

Note that we pass the --save-metadata flag in order to produce a metadata file, which will be needed as the first argument of the upgrade function. The --included-artifacts none flag makes the size of the metadata file as small as possible, which helps ensure that the transaction doesn't exceed the max transaction size.

In our typescript client, we invoke the move compiler, then take its output to recover the right module order:

function buildPackage(dir: string, addrs?: string): Package {
const named_addresses =
addrs
? ["--named-addresses", addrs]
: [];
const aptos = spawnSync("aptos",
["move", "compile", "--save-metadata", "--included-artifacts", "none", "--package-dir", dir, ...named_addresses])
// error handling elided
  const result: any = JSON.parse(aptos.stdout.toString('utf8'))
const buildDirs =
fs.readdirSync(`${dir}/build`, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
// error handling elided
const buildDir = `${dir}/build/${buildDirs[0]}`
return {
meta_file: `${buildDir}/package-metadata.bcs`,
mv_files: result["Result"].map((mod: string) => `${buildDir}/bytecode_modules/${mod.split("::")[1]}.mv`)
}
}

/clients/js/cmds/aptos.ts#L374-L401

The buildPackage function invokes the move compiler as above, and returns the name of the metadata file (in the wormhole case it will be in build/Wormhole/package-metadata.bcs), and the .mv move object files, for example build/Wormhole/bytecode_modules/contract_ugprade.mv.

Finally, we implement a function that takes those file paths and constructs the appropriate upgrade hash (for the guardians to vote on), and the serialised metadata and bytecodes.

function serializePackage(meta_file: string, mv_files: string[]): PackageBCS {
const metaBytes = fs.readFileSync(p.meta_file);
const packageMetadataSerializer = new BCS.Serializer();
packageMetadataSerializer.serializeBytes(metaBytes)
const serializedPackageMetadata = packageMetadataSerializer.getBytes();
  const modules = p.mv_files.map(file => fs.readFileSync(file))
const serializer = new BCS.Serializer();
serializer.serializeU32AsUleb128(modules.length);
modules.forEach(module => serializer.serializeBytes(module));
const serializedModules = serializer.getBytes();
const hashes = [metaBytes].concat(modules).map((x) => Buffer.from(sha3.keccak256(x), "hex"));
const codeHash = Buffer.from(sha3.keccak256(Buffer.concat(hashes)), "hex")
return {
meta: serializedPackageMetadata,
bytecodes: serializedModules,
codeHash
}
}

/clients/js/cmds/aptos.ts#L403-L423

Aptos transaction data is encoded in the BCS binary serialisation format. Once the guardians have approved codeHash, the wormhole::contract_upgrade::upgrade entrypoint can now be called by passing in serialisedPackageMetadata and serializedModules as the arguments.

And with that, we have a contract that can be upgraded via custom governance rules.

Closing thoughts

In this post we looked at how to implement governance-based contract upgrades on Aptos. Doing so allows performing custom checks when approving contract upgrades, something that Aptos does not natively support currently.

Many applications don’t need complex custom logic for upgradeability, a simple ownership check suffices. The native method where the deployer owns the account is still suboptimal, because this kind of ownership is not transferrable. In those cases, the deployer could simply create a resource account but retain the SignerCapability, which can then be transferred later on as needed.

As the ecosystem matures, we expect more official support for these patterns to be provided by the Aptos Foundation. For example, recently they introduced the aptos move create-resource-account-and-publish-package command that performs the resource account creation, but it was intended for immutable contracts.

--

--

Wormhole
Wormhole

Written by Wormhole

Wormhole is the leading interoperability platform powering multichain applications and bridges at scale. Build Multichain: http://wormhole.com

No responses yet