Upgrades and Migrations
Soroban contracts are mutable by default. Mutability in the context of Stellar Soroban refers to the ability of a smart contract to modify its WASM bytecode, thereby altering its function interface, execution logic, or metadata.
Soroban provides a built-in, protocol-level defined mechanism for contract upgrades, allowing contracts to upgrade themselves if they are explicitly designed to do so. One of the advantages of it is the flexibility it offers to contract developers who can choose to make the contract immutable by simply not provisioning upgradability mechanics. On the other hand, providing upgradability on a protocol level significantly reduces the risk surface, compared to other smart contract platforms, which lack native support for upgradability.
While Soroban's built-in upgradability eliminates many of the challenges, related to managing smart contract upgrades and migrations, certain caveats must still be considered.
Overview
The upgradeable module provides a lightweight upgradeability framework with additional guidance for structured and safe migrations.
It consists of two components:
- The
Upgradeabletrait — a standardized entry point for contract upgrades, generating a client (UpgradeableClient) for calling upgrades from other contracts. - Migration pattern guidelines — three documented patterns for handling storage changes during upgrades.
While the framework structures the upgrade flow, it does NOT perform deeper checks and verifications such as:
- Ensuring that the new contract does not include a constructor, as it will not be invoked.
- Verifying that the new contract includes an upgradability mechanism, preventing an unintended loss of further upgradability capacity.
- Checking for storage consistency, ensuring that the new contract does not inadvertently introduce storage mismatches.
The Upgradeable Trait
The Upgradeable trait defines a standardized upgrade entry point:
#[contractclient(name = "UpgradeableClient")]
pub trait Upgradeable {
fn upgrade(e: &Env, new_wasm_hash: BytesN<32>, operator: Address);
}All access control and authorization checks are the implementor's responsibility. Implement this trait directly using #[contractimpl] and call the upgradeable::upgrade() free function inside:
use soroban_sdk::{contract, contractimpl, Address, BytesN, Env};
use stellar_contract_utils::upgradeable::{self as upgradeable, Upgradeable};
use stellar_macros::only_role;
#[contract]
pub struct ExampleContract;
#[contractimpl]
impl Upgradeable for ExampleContract {
#[only_role(operator, "admin")]
fn upgrade(e: &Env, new_wasm_hash: BytesN<32>, operator: Address) {
upgradeable::upgrade(e, &new_wasm_hash);
}
}Storage Migration
When upgrading contracts, data structures may change (e.g., adding new fields, removing old ones, or restructuring data). This section explains how to handle those changes safely.
Why There Is No Migratable Trait
Migration is deliberately not standardized into a trait:
- Migration rarely has a single entrypoint: a contract may need to migrate several independent storage structures at different times.
- A fixed trait signature would force all migration arguments into a single
#[contracttype]struct, removing the flexibility to choose argument types, authorization roles, or split migration across multiple functions. - Lazy migration (Pattern 2) has no discrete migration call at all.
The patterns below are therefore guidelines rather than enforced interfaces.
The Problem: Host-Level Type Validation
Soroban validates types at the host level when reading from storage. If a data structure's shape changes between versions, the host traps before the SDK can handle the mismatch:
// V1 stored this type:
#[contracttype]
pub struct Config { pub rate: u32 }
// V2 adds a field. Reading old storage with the new type traps, because
// the host validates field count before the SDK sees the value.
#[contracttype]
pub struct Config { pub rate: u32, pub active: bool }
// Traps with Error(Object, UnexpectedSize)
let config: Config = e.storage().instance().get(&key).unwrap();Pattern 1: Eager Migration (Bounded Data)
For bounded data in instance storage (config, metadata, settings), add a migrate function to the upgraded contract that reads old-format data and converts it. Use set_schema_version / get_schema_version to guard against double invocation.
The old type must be defined in the new contract code so the host can deserialize it correctly.
// Old type (matches what v1 stored, field names and types must match)
#[contracttype]
pub struct ConfigV1 {
pub rate: u32,
}
// New type
#[contracttype]
pub struct Config {
pub rate: u32,
pub active: bool,
}
const CONFIG_KEY: Symbol = symbol_short!("CONFIG");
pub fn migrate(e: &Env, operator: Address) {
assert!(upgradeable::get_schema_version(e) < 2, "already migrated");
let old: ConfigV1 = e.storage().instance().get(&CONFIG_KEY).unwrap();
let new = Config { rate: old.rate, active: true };
e.storage().instance().set(&CONFIG_KEY, &new);
upgradeable::set_schema_version(e, 2);
}Migration must happen in a separate transaction after the upgrade completes, or atomically via a third-party upgrader contract (see Atomic Upgrade and Migration below).
Pattern 2: Lazy Migration (Unbounded Data)
For unbounded persistent storage (user balances, approvals, etc.), eager migration is impractical as it's impossible to iterate all entries in one transaction without hitting resource limits.
Instead, use version markers alongside each entry and convert lazily on read:
// Old type must match what v1 stored exactly.
#[contracttype]
pub struct BalanceV1 { pub amount: i128 }
// New type with an added field.
#[contracttype]
pub struct Balance { pub amount: i128, pub frozen: bool }
#[contracttype]
pub enum StorageKey {
Balance(Address),
BalanceVersion(Address),
}
fn get_balance(e: &Env, account: &Address) -> Balance {
let version: u32 = e.storage().persistent()
.get(&StorageKey::BalanceVersion(account.clone()))
.unwrap_or(1);
match version {
1 => {
let v1: BalanceV1 = e.storage().persistent()
.get(&StorageKey::Balance(account.clone())).unwrap();
let migrated = Balance { amount: v1.amount, frozen: false };
set_balance(e, account, &migrated);
migrated
}
_ => e.storage().persistent()
.get(&StorageKey::Balance(account.clone())).unwrap(),
}
}
fn set_balance(e: &Env, account: &Address, balance: &Balance) {
e.storage().persistent()
.set(&StorageKey::BalanceVersion(account.clone()), &2u32);
e.storage().persistent()
.set(&StorageKey::Balance(account.clone()), balance);
}Pattern 3: Enum Wrapper (Plan-Ahead)
For contracts that anticipate future migrations from the start, wrap stored data in a versioned enum. Soroban serializes enum variants as (tag, data), so the host can distinguish between versions without trapping.
#[contracttype]
pub enum ConfigEntry {
V1(ConfigV1),
}
// Store wrapped from day one:
e.storage().instance().set(&key, &ConfigEntry::V1(config));When v2 comes, add a variant and a converter:
#[contracttype]
pub enum ConfigEntry {
V1(ConfigV1),
V2(ConfigV2),
}
impl ConfigEntry {
pub fn into_latest(self) -> ConfigV2 {
match self {
ConfigEntry::V1(v1) => ConfigV2 { rate: v1.rate, active: true },
ConfigEntry::V2(v2) => v2,
}
}
}This pattern cannot work retroactively — reading old bare-struct data as an enum would trap.
If a rollback is required, the contract can be upgraded to a newer version where the rollback-specific logic is defined and performed as a migration.
Atomic Upgrade and Migration
When performing an upgrade, the new implementation only becomes effective after the current invocation completes.
This means that if migration logic is included in the new implementation, it cannot be executed within the same
call. To address this, an auxiliary contract called Upgrader can be used to wrap both invocations, enabling an
atomic upgrade-and-migrate process. This approach ensures that the migration logic is executed immediately after the
upgrade without requiring a separate transaction.
use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Val};
use stellar_contract_utils::upgradeable::UpgradeableClient;
#[contract]
pub struct Upgrader;
#[contractimpl]
impl Upgrader {
pub fn upgrade_and_migrate(
env: Env,
contract_address: Address,
operator: Address,
wasm_hash: BytesN<32>,
migration_data: soroban_sdk::Vec<Val>,
) {
operator.require_auth();
let contract_client = UpgradeableClient::new(&env, &contract_address);
contract_client.upgrade(&wasm_hash, &operator);
// The types of the arguments to the migrate function are unknown to this
// contract, so we need to call it with invoke_contract.
env.invoke_contract::<()>(&contract_address, &symbol_short!("migrate"), migration_data);
}
}