This document describes how to upgrade stellar-router contracts on Soroban, what to consider for state migration, and the recommended process for each contract in the suite.
Soroban supports in-place WASM replacement via the host function
update_current_contract_wasm. When called, the contract's WASM bytecode is
replaced atomically. The contract's storage (all DataKey entries) is
preserved — the new WASM reads the same storage the old WASM wrote.
This means:
- Adding new storage keys is safe (old entries are simply absent until written).
- Removing storage keys is safe (old entries remain but are ignored).
- Changing the type of an existing storage key is dangerous — the new WASM will try to deserialize old data with the new type and will panic.
- Changing a
contracterrordiscriminant value is a breaking change for callers that pattern-match on error codes.
Never upgrade a contract directly. Always queue the upgrade as a timelock operation so there is a delay window for review and cancellation.
stellar contract invoke --id <TIMELOCK_ID> --network testnet --source admin \
-- queue \
--proposer <ADMIN_ADDRESS> \
--description "upgrade router-core to v2" \
--target <CORE_CONTRACT_ID> \
--delay 86400 \
--depends_on "[]"cargo build --target wasm32-unknown-unknown --releaseThe new WASM will be at:
target/wasm32-unknown-unknown/release/router_core.wasm
stellar contract upload \
--wasm target/wasm32-unknown-unknown/release/router_core.wasm \
--network testnet \
--source adminThis returns a WASM hash. Note it — you will need it in step 4.
After the timelock delay has elapsed, execute the operation. The actual
update_current_contract_wasm call must be made from within the contract
itself (or via an authorized upgrade function). Add an upgrade function
to each contract:
pub fn upgrade(env: Env, caller: Address, new_wasm_hash: soroban_sdk::BytesN<32>) -> Result<(), RouterError> {
caller.require_auth();
Self::require_admin(&env, &caller)?;
env.deployer().update_current_contract_wasm(new_wasm_hash);
Ok(())
}Then invoke it:
stellar contract invoke --id <CORE_ID> --network testnet --source admin \
-- upgrade \
--caller <ADMIN_ADDRESS> \
--new_wasm_hash <WASM_HASH_FROM_STEP_3>| Change | Safe? | Notes |
|---|---|---|
Add a new pub fn |
✅ | New function, no storage impact |
Add a new DataKey variant |
✅ | Old storage unaffected |
Add a new contracterror variant |
✅ | New discriminant, old callers unaffected |
| Add a field to a struct (with default) | Only safe if old data can be deserialized — Soroban uses XDR, which is not forward-compatible by default | |
Remove an unused pub fn |
✅ | No storage impact |
| Change | Risk | Mitigation |
|---|---|---|
Change the type of an existing DataKey value |
🔴 Panic on read | Migrate data before upgrading (see below) |
Change a contracterror discriminant number |
🔴 Breaking for callers | Never reuse discriminant numbers; only add new ones |
Remove a DataKey variant that is still in storage |
🟡 Orphaned data | Acceptable if the data is no longer needed; document it |
Rename a contracttype struct field |
🔴 XDR deserialization failure | Add a new struct, migrate data, remove old struct in a follow-up upgrade |
If you need to change a storage type, use a two-phase upgrade:
Phase 1 — migration upgrade:
- Add the new
DataKeyvariant (e.g.,RouteEntryV2). - Add a
migrate()function that reads allRouteEntryvalues, converts them toRouteEntryV2, writes them under the new key, and removes the old key. - Deploy this upgrade.
- Call
migrate()once.
Phase 2 — cleanup upgrade:
- Remove the old
DataKey::RouteEntryvariant and all code that references it. - Deploy this upgrade.
RouteEntrystruct has anOption<RouteMetadata>field. Adding fields toRouteMetadatarequires a migration if existing entries are stored.DataKey::RouteNamesandDataKey::AliasesareVec<String>— safe to extend but not to change the element type.- The
admin()function panics if the contract is not initialized. Ensureinitialize()has been called before upgrading.
ContractEntrystoresregistered_by: Address. Adding aregistered_at: u64timestamp field requires a migration for existing entries.- Version lists (
DataKey::Versions) areVec<u32>— safe to extend.
DataKey::RoleParentis new in the hierarchy feature. Old deployments without it will simply have no parent relationships — safe to add without migration.DataKey::HasRolestoresbool. Do not change this to a struct without a migration.
RouteConfighas grown over time (addedfailure_threshold,recovery_window_seconds,log_retention). If upgrading from an older deployment, existingRouteConfigentries will fail to deserialize with the new struct. Run a migration that re-writes allRouteConfigentries with default values for the new fields.
TimelockOphasis_critical: bool. Old entries without this field will fail to deserialize. If upgrading from a pre-hierarchy deployment, migrate all existing operations to setis_critical = false.DataKey::FastTrackEnabledis new — safe to add without migration.
CallDescriptorhasinstruction_budget: Option<u64>. Old entries without this field will fail to deserialize if stored. Sinceexecute_batchdoes not persistCallDescriptorvalues, this is safe.
Soroban does not support automatic rollback of a WASM upgrade. If an upgrade introduces a bug:
- Build the previous WASM version.
- Upload it to the network (step 3 above).
- Call
upgrade()with the old WASM hash.
This is why all upgrades should be queued through router-timelock — the delay window gives time to test the new WASM on testnet and cancel the upgrade if issues are found before it executes on mainnet.
Before upgrading any contract on mainnet:
- New WASM tested on testnet with production-like data
- Storage compatibility verified (no type changes without migration)
-
contracterrordiscriminants unchanged - Upgrade queued via router-timelock with at least 24h delay
- Migration function (if needed) tested on testnet
- Rollback WASM uploaded and hash noted
- On-chain monitoring active during upgrade window