This document describes the safe contract upgrade pattern used across Mux Protocol's Soroban contracts.
Soroban contracts are immutable once deployed — their WASM bytecode cannot be changed. Upgrades
work by uploading new WASM to the ledger and then calling upgrade() on the deployed contract
instance, which atomically replaces the running code with the new WASM hash.
-
Build new contract WASM
cargo build --target wasm32-unknown-unknown --release
-
Upload new WASM to the network (returns
new_wasm_hash)stellar contract upload \ --wasm target/wasm32-unknown-unknown/release/<contract>.wasm \ --source $DEPLOYER_ACCOUNT \ --network $NETWORK
-
Call
upgrade()on the live contract instancestellar contract invoke \ --id $CONTRACT_ID \ --source $ADMIN_ACCOUNT \ --network $NETWORK \ -- upgrade \ --new_wasm_hash $NEW_WASM_HASH
-
Verify the upgrade The contract instance now runs the new WASM at the same address. Run post-upgrade smoke tests.
Every upgradeable Mux contract must implement the upgrade entry point:
use soroban_sdk::{contract, contractimpl, Address, BytesN, Env};
#[contract]
pub struct MuxContract;
#[contractimpl]
impl MuxContract {
/// Upgrade the contract WASM.
/// Only the admin stored in contract storage may call this.
pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) {
// 1. Authorise — only admin may upgrade
let admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("admin not initialised");
admin.require_auth();
// 2. Atomically update the running WASM
env.deployer().update_current_contract_wasm(new_wasm_hash);
}
}| Invariant | Why it matters |
|---|---|
require_auth() on admin before update_current_contract_wasm() |
Prevents unauthorised upgrades |
Admin set during initialize(), never overwritten without auth |
Ensures admin key rotation is itself protected |
No storage migration in upgrade() itself |
Storage layout must be backward-compatible, or migrated in a separate migrate() call |
When changing storage layout between versions:
- Add fields only — never remove or rename existing
DataKeyvariants. Existing ledger entries remain valid after the upgrade. - Use
Option<T>for new fields — allows old entries to deserialise without the new field. - If removal is required — implement a
migrate(env: Env)function that rewrites entries before the new code path reads them.
pub fn migrate(env: Env) {
let admin: Address = env.storage().instance()
.get(&DataKey::Admin).expect("not initialised");
admin.require_auth();
// Example: rename OldKey → NewKey
if let Some(val) = env.storage().persistent().get::<_, OldType>(&DataKey::OldKey) {
env.storage().persistent().set(&DataKey::NewKey, &val);
env.storage().persistent().remove(&DataKey::OldKey);
}
}Before every production upgrade:
- New WASM hash verified with
scripts/verify-wasm-hash.sh(see #113) - All existing tests pass against the new WASM
- Storage layout changes are backward-compatible or a
migrate()function is ready - Testnet deploy completed and smoke-tested (see #110)
- Admin key is available and hardware-secured
- Upgrade transaction simulated (
--fee-bumpif needed) - Rollback plan documented (prior WASM hash retained)
Soroban does not natively support rolling back an upgrade. Mitigation:
- Keep the previous WASM hash — it is always reuploaded if needed (WASM is content-addressed; the hash is permanent).
- Call
upgrade()again with the prior hash to revert. - If storage was migrated — run a reverse
migrate()that was prepared before the upgrade.
Add an integration test that:
- Deploys contract v1 to a local Soroban sandbox.
- Registers state (stores entries, calls functions).
- Uploads contract v2 WASM and calls
upgrade(). - Asserts all v1 state is readable from v2.
- Asserts new v2 behaviour is correct.
#[cfg(test)]
mod upgrade_tests {
use soroban_sdk::{testutils::Address as _, Address, Env};
use crate::MuxContractClient;
#[test]
fn upgrade_preserves_state() {
let env = Env::default();
env.mock_all_auths();
// Deploy v1
let admin = Address::generate(&env);
let contract_id = env.register_contract(None, super::MuxContract);
let client = MuxContractClient::new(&env, &contract_id);
client.initialize(&admin);
// Record state before upgrade
let state_before = client.get_state();
// Upload v2 WASM (in tests, this is the same binary — replace with v2 in CI)
let new_wasm_hash = env.deployer().upload_contract_wasm(super::WASM);
client.upgrade(&new_wasm_hash);
// Assert state preserved
assert_eq!(client.get_state(), state_before);
}
}Mux TypeScript clients in bindings/ talk to contract IDs (addresses), not WASM hashes. After an on-chain upgrade:
- Contract IDs stay the same — update application config only if you deploy to a new address (greenfield deploy, not upgrade).
- Regenerate or verify bindings when the Soroban interface changes (
cargo build+ export scripts inbindings/). Error enums and method signatures must match the upgraded WASM ABI. - Run smoke tests against the upgraded network: read paths first (e.g.
get_version,owner,list_contracts), then write paths in a staging environment. - Handle new error variants in
bindings/src/errors.tsand HTTP mappings if the upgraded contract addscontracterrorcodes. - Coordinate downtime — upgrades are atomic on-chain, but relayers and indexers should tolerate a short window where old simulation assumptions may fail until they pick up the new WASM behavior.
Per-contract storage migration notes:
| Contract | Migration doc |
|---|---|
mux-account |
account-upgrade-migration.md |
mux-batcher |
batcher-upgrade.md |
mux-delegation |
delegation-upgrade.md |
mux-permissions |
permissions-upgrade-migration.md |
mux-registry |
See v0.1.0 migration notes below |
- Soroban Contract Upgrade Docs
- Stellar CLI:
contract upload - Mux deployment scripts:
scripts/deploy-testnet.sh(see #110) - Mux WASM hash verification:
scripts/verify-wasm-hash.sh(see #113)
No breaking storage changes in this cycle. The DataKey enum gained two new
variants (Metadata(Symbol) for rich metadata and Names for the contract-name
index); both are additive — existing ledger entries remain valid.
If you are upgrading a live mux-registry instance:
- Upload the new WASM and call
upgrade(new_wasm_hash)as the admin. - No
migrate()call is required — theNamesvec is bootstrapped lazily if absent, andMetadataentries are optional (reads fall back toContractNotFoundrather than panicking). - Verify with
get_versionandlist_contractsthat pre-upgrade registrations are still readable.
No storage layout changes in v0.1.0. The DataKey::Wallet(Symbol) key is
unchanged. No migration step is needed when upgrading to any patch release in
this series.
General rule for both registries: upgrade → smoke-test → keep prior WASM hash for rollback. See the Rollback section above.
No breaking storage changes in this cycle. The DataKey enum gained one new
variant (Metadata for optional RegistryMeta); it is additive — existing
ledger entries remain valid.
If you are upgrading a live mux-account instance:
- Upload the new WASM and call
upgrade(new_wasm_hash)as the owner (once upgrade support is enabled on the contract). - No
migrate()call is required —Metadatais optional and reads returnNonewhen unset. - Verify with
owner,delegates, andget_metadatathat pre-upgrade state is still readable.
See docs/account-upgrade-migration.md for the full storage layout and breaking-change checklist.