This document explains how to safely upgrade the Checkmate-Escrow smart contract, what the automated tests cover, and what to check manually before and after each upgrade.
- Overview
- Upgrade Lifecycle
- Automated Upgrade Tests
- Running Upgrade Tests Locally
- Upgrade Compatibility Checklist
- How to Perform an Upgrade
- Rollback Procedure
- Versioning Convention
- CI Integration
- Common Pitfalls
Soroban contracts are upgraded by uploading a new WASM blob and calling
execute_upgrade. The contract enforces a 7-day review period between
scheduling and executing an upgrade, during which anyone can audit the new
WASM. A separate migrate_state function handles any storage-format changes
between versions.
Automated upgrade simulation tests guard against:
- Breaking storage keys — a rename or removal of a
DataKeyvariant silently makes old data unreadable. - Breaking function signatures — changing argument types or return types breaks existing integrations without a compile-time error in the contract itself.
- Fee regression — a bug in fee calculation causes winners to receive the wrong payout after an upgrade.
- Data corruption — migration code that overwrites existing values instead of back-filling absent ones.
- Version downgrade — a
migrate_statecall that lowers the version counter, making it impossible to detect which migrations have run.
schedule_upgrade(wasm_hash)
│
│ 7-day review period (UPGRADE_REVIEW_PERIOD_LEDGERS = 120,960)
▼
pause() the contract ← admin must pause before executing
│
▼
execute_upgrade() ← uploads new WASM, contract is live
│
▼
migrate_state(target_version) ← runs data migrations, updates version
│
▼
validate_state() ← confirms instance store is healthy
│
▼
unpause() ← re-opens the contract to users
The test file contracts/escrow/tests/upgrade_simulation_tests.rs contains
the following categories of tests:
| Test | What it verifies |
|---|---|
test_admin_preserved_across_migration |
Admin address unchanged after migrate_state |
test_oracle_preserved_across_migration |
Oracle address unchanged after migrate_state |
test_pause_state_preserved_across_migration |
Paused flag unchanged |
test_match_timeout_preserved_across_migration |
Custom timeout unchanged |
test_protocol_config_preserved_across_migration |
Vesting, fees, treasury unchanged |
| Test | What it verifies |
|---|---|
test_pending_match_readable_after_migration |
Pending match state and fields intact |
test_active_match_readable_after_migration |
Active match state and deposit flags intact |
test_escrow_balance_preserved_after_migration |
get_escrow_balance returns same value |
test_is_funded_flag_preserved_after_migration |
is_funded stays true for funded matches |
| Test | What it verifies |
|---|---|
test_oracle_can_submit_result_after_migration |
Oracle can finalize an active match post-migration |
test_draw_payout_correct_after_migration |
Draw refunds both players the correct amount |
| Test | What it verifies |
|---|---|
test_create_match_api_compatible_after_migration |
create_match accepts same args |
test_deposit_api_compatible_after_migration |
deposit works on post-migration matches |
test_cancel_match_api_compatible_after_migration |
cancel_match still transitions to Cancelled |
test_get_player_matches_api_compatible_after_migration |
get_player_matches returns consistent data |
| Test | What it verifies |
|---|---|
test_fee_tiers_preserved_and_applied_after_migration |
Fee tiers set before migration produce the correct treasury transfer and winner payout after migration |
| Test | What it verifies |
|---|---|
test_validate_state_passes_before_and_after_migration |
validate_state finds all required keys |
test_version_storage_key_increments_correctly |
ContractVersion key is readable and updates |
| Test | What it verifies |
|---|---|
test_execute_upgrade_rejected_when_not_paused |
InvalidPauseState if not paused |
test_execute_upgrade_rejected_before_review_period |
UpgradeReviewPeriodNotElapsed if too early |
test_execute_upgrade_rejected_with_no_scheduled_upgrade |
UpgradeNotScheduled if none pending |
| Test | What it verifies |
|---|---|
test_cancel_upgrade_allows_reschedule |
cancel_upgrade → schedule_upgrade succeeds |
test_contract_operational_after_cancelled_upgrade |
Matches work after a cancelled upgrade |
| Test | What it verifies |
|---|---|
test_migrate_state_rejects_same_version |
InvalidVersion for same version |
test_migrate_state_rejects_downgrade |
InvalidVersion for lower version |
test_migrate_state_double_migration_rejected |
Re-running a migration is blocked |
| Test | What it verifies |
|---|---|
test_multiple_matches_survive_migration |
Pending + Active matches all intact post-migration |
test_new_match_completes_normally_after_migration |
New matches created after migration complete correctly |
# Run only the upgrade simulation tests
cargo test -p escrow --test upgrade_simulation_tests
# Run with output for debugging
cargo test -p escrow --test upgrade_simulation_tests -- --nocapture
# Run a single test
cargo test -p escrow --test upgrade_simulation_tests test_fee_tiers_preserved -- --nocaptureThe tests use only in-memory Soroban environments (Env::default()); no
network connection or deployed contract is needed.
Before merging any contract change, verify all items below. The automated tests cover most of these; items marked ✋ require manual review.
- No existing
DataKeyvariant was renamed or removed. - If a new
DataKeyvariant was added,validate_statewas updated to check for it when required. - ✋ Any new persistent key has a sensible default that
migrate_stateback-fills for contracts that pre-date the key.
- No existing public function had argument types changed.
- No existing public function had its return type changed.
- ✋ Any new public function is additive (not a replacement of an existing one without a deprecation period).
-
CONTRACT_VERSIONinsrc/lib.rswas incremented. - A new arm in
migrate_statehandles the version step from the previous version to the new one. - The
matchinmigrate_stateincludes a_ => {}or explicit catch-all that does not panic on unknown future versions.
- Fee calculation logic is unchanged, or any change is intentional and the fee test was updated to reflect the new expected amounts.
- All events listed in the README Events Reference table still use the same topic strings.
- Take a backup:
./scripts/backup_state.sh mainnet - Review the new WASM diff with a second engineer.
- Publish the WASM hash publicly (GitHub release or on-chain memo) for the 7-day review period.
- Announce the upcoming upgrade to users.
# 1. Build the new WASM
./scripts/build.sh
# 2. Upload the WASM and get its hash
WASM_HASH=$(stellar contract upload \
--wasm target/wasm32-unknown-unknown/release/escrow.wasm \
--source $DEPLOYER_KEYPAIR \
--network mainnet)
echo "WASM hash: $WASM_HASH"
# 3. Schedule the upgrade (starts the 7-day review clock)
stellar contract invoke \
--id $CONTRACT_ESCROW \
--source $DEPLOYER_KEYPAIR \
--network mainnet \
-- schedule_upgrade --wasm_hash "$WASM_HASH"
# 4. Wait for the review period to elapse (7 days = 120,960 ledgers)
# 5. Pause the contract
stellar contract invoke \
--id $CONTRACT_ESCROW \
--source $DEPLOYER_KEYPAIR \
--network mainnet \
-- pause
# 6. Execute the upgrade
stellar contract invoke \
--id $CONTRACT_ESCROW \
--source $DEPLOYER_KEYPAIR \
--network mainnet \
-- execute_upgrade
# 7. Run state migration
stellar contract invoke \
--id $CONTRACT_ESCROW \
--source $DEPLOYER_KEYPAIR \
--network mainnet \
-- migrate_state --target_version <new_version_number>
# 8. Validate state
stellar contract invoke \
--id $CONTRACT_ESCROW \
--source $DEPLOYER_KEYPAIR \
--network mainnet \
-- validate_state
# 9. Unpause the contract
stellar contract invoke \
--id $CONTRACT_ESCROW \
--source $DEPLOYER_KEYPAIR \
--network mainnet \
-- unpause
echo "✅ Upgrade complete."If the upgrade is scheduled but has NOT yet been executed, cancel it:
stellar contract invoke \
--id $CONTRACT_ESCROW \
--source $DEPLOYER_KEYPAIR \
--network mainnet \
-- cancel_upgrade
stellar contract invoke \
--id $CONTRACT_ESCROW \
--source $DEPLOYER_KEYPAIR \
--network mainnet \
-- unpauseIf the upgrade HAS been executed and is broken, there is no on-chain rollback — Soroban does not support downgrading WASM. Options:
- Upload a hot-fix WASM and schedule a new upgrade (requires another 7-day review period unless the contract is already broken in a way that warrants emergency governance).
- Deploy a new contract and migrate users — follow the Disaster Recovery runbook.
The contract version is encoded as major * 1_000_000 + minor * 1_000 + patch:
| Version string | Encoded integer |
|---|---|
| 0.1.0 | 1,000 |
| 0.1.1 | 1,001 |
| 0.2.0 | 2,000 |
| 1.0.0 | 1,000,000 |
The current version is defined in src/lib.rs:
pub const CONTRACT_VERSION: u32 = 1_000; // 0.1.0Increment the appropriate component and update the constant before opening a PR that changes on-chain behavior or storage layout.
The upgrade simulation tests are part of the standard CI pipeline defined in
.github/workflows/ci.yml. They run on every push and pull request to
main/master as part of the test job:
- name: Run upgrade simulation tests
run: cargo test -p escrow --test upgrade_simulation_testsThis ensures no merge to main can introduce a breaking upgrade path without
first failing CI.
Renaming a DataKey variant
Rust enum variants are not stable identifiers in XDR encoding; renaming
DataKey::Foo to DataKey::Bar writes data under a different storage key,
making all existing Foo data invisible. Never rename a variant — add a new
one and write a migration.
Removing a DataKey variant
Same effect as renaming. Keep removed variants as dead code or use a
#[deprecated] annotation until all data under that key has been migrated.
Changing a contracttype field
Adding a field to a #[contracttype] struct changes its XDR encoding.
Existing stored values will fail to deserialize. Always add fields as
Option<T> and back-fill them in migrate_state.
Skipping validate_state after migration
validate_state is the only automated check that the migration completed
cleanly. Always call it before unpausing the contract.
Forgetting to increment CONTRACT_VERSION
Without a version bump, migrate_state cannot distinguish a fresh contract
from one that ran the migration. Always bump the version with every
storage-layout change.