A step-by-step guide for safely upgrading the BlueCollar Registry and Market contracts deployed on Stellar (Soroban), without losing contract IDs or storage state.
For complete contract function signatures, storage maps, events, and authorization rules, see CONTRACTS.md. For the architectural decision record, see ADR 0002: Soroban Smart Contract Upgrade Strategy.
Soroban supports in-place WASM upgrades via env.deployer().update_current_contract_wasm(new_wasm_hash). This replaces the contract's executable code while preserving:
- The contract ID (address stays the same)
- All instance, persistent, and temporary storage entries
- All existing escrows, worker registrations, and config
The upgrade does not automatically migrate storage schemas. If the new WASM reads storage keys or types differently from the old WASM, you must handle migration explicitly (see Storage Migration).
Make your changes in packages/contracts/contracts/market/src/lib.rs or registry/src/lib.rs. Run tests before building:
cd packages/contracts
cargo testmake build
# or directly:
cargo build --release --target wasm32v1-noneOutput files:
target/wasm32v1-none/release/bluecollar_market.wasmtarget/wasm32v1-none/release/bluecollar_registry.wasm
stellar contract install uploads the WASM bytecode to the network and returns a 32-byte hash. The contract is not yet upgraded at this point — the hash is just registered.
# Install Market WASM
stellar contract install \
--wasm target/wasm32v1-none/release/bluecollar_market.wasm \
--source <ADMIN_IDENTITY> \
--network testnet
# Install Registry WASM
stellar contract install \
--wasm target/wasm32v1-none/release/bluecollar_registry.wasm \
--source <ADMIN_IDENTITY> \
--network testnetBoth commands print a WASM hash. Save them:
MARKET_WASM_HASH=<32-byte hex from install output>
REGISTRY_WASM_HASH=<32-byte hex from install output>
Call the upgrade function on each deployed contract, passing the new WASM hash:
# Upgrade Market contract
stellar contract invoke \
--id <MARKET_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- upgrade \
--admin <ADMIN_ADDRESS> \
--new_wasm_hash $MARKET_WASM_HASH
# Upgrade Registry contract
stellar contract invoke \
--id <REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- upgrade \
--admin <ADMIN_ADDRESS> \
--new_wasm_hash $REGISTRY_WASM_HASHConfirm the new WASM is active by fetching the contract's current WASM hash from the network and comparing it to the installed hash:
stellar contract info \
--id <MARKET_CONTRACT_ID> \
--network testnetThe reported WASM hash should match $MARKET_WASM_HASH.
Both contracts enforce the following authorization chain on upgrade:
admin.require_auth()— Soroban VM verifies the transaction is signed byadmin.config.admin == admin— The contract asserts the passed address matches the stored admin.
If either check fails, the transaction is rejected and no state changes occur.
- The
--sourceidentity in the CLI command must be the keypair corresponding to<ADMIN_ADDRESS>. - If the admin key is a multisig account, all required signers must co-sign the transaction before submission.
- Never share the admin private key. Use a hardware wallet (Ledger) or a Stellar multisig account for mainnet admin keys.
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- get_adminConfirm the returned address matches the identity you intend to use as --source.
Run through this checklist on testnet before upgrading mainnet.
- All changes are reviewed and approved via PR
- No breaking changes to existing storage key names or types (or migration is planned)
- New functions do not conflict with existing event topic names
-
MAX_FEE_BPSand other constants are unchanged (or intentionally changed)
-
cargo testpasses with zero failures -
cargo clippy -- -D warningspasses with zero warnings - All existing test cases still pass (no regressions)
- New functionality has corresponding test coverage
- WASM built successfully (
make build) - WASM installed on testnet (
stellar contract install) -
upgradeinvoked successfully on testnet contract - Post-upgrade smoke tests pass (see below)
- Existing escrows and worker registrations are still readable after upgrade
- New functionality works as expected on testnet
# Market: verify config is intact
stellar contract invoke --id <MARKET_CONTRACT_ID> --network testnet -- get_admin
stellar contract invoke --id <MARKET_CONTRACT_ID> --network testnet -- get_fee_bps
# Market: verify existing escrow is still readable
stellar contract invoke --id <MARKET_CONTRACT_ID> --network testnet -- get_escrow --id <KNOWN_ESCROW_ID>
# Registry: verify worker count is unchanged
stellar contract invoke --id <REGISTRY_CONTRACT_ID> --network testnet -- worker_count
# Registry: verify a known worker is still readable
stellar contract invoke --id <REGISTRY_CONTRACT_ID> --network testnet -- get_worker --id <KNOWN_WORKER_ID>Soroban does not support automatic rollback. However, because stellar contract install registers WASM hashes permanently on-chain, you can re-upgrade to the previous version at any time.
stellar contract info --id <CONTRACT_ID> --network mainnet
# Save the current wasm_hash value as PREVIOUS_WASM_HASHThe previous WASM hash is already installed on-chain (it was used before). Simply call upgrade again with the old hash:
stellar contract invoke \
--id <MARKET_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network mainnet \
-- upgrade \
--admin <ADMIN_ADDRESS> \
--new_wasm_hash $PREVIOUS_WASM_HASH- The new WASM wrote storage entries in a format incompatible with the old WASM (type mismatch on read will panic).
- The new WASM deleted or migrated storage keys that the old WASM expects.
This is why storage-breaking changes require a migration plan (see below) rather than a simple rollback path.
Neither contract currently has a pause mechanism. If a critical bug is found post-upgrade and rollback is not safe, the recommended mitigation is:
- Immediately upgrade to a patched WASM that rejects all state-changing calls.
- Communicate the issue to users.
- Deploy a fixed version once ready.
Migration is needed when the new WASM changes:
- A
#[contracttype]struct by adding, removing, or reordering fields - A storage key name or type
- The encoding of an existing value
If you only add new functions or change logic without touching stored types, no migration is needed.
Add new fields to structs with Option<T> so old entries remain readable:
// Old
#[contracttype]
pub struct Worker {
pub owner: Address,
pub name: String,
}
// New — additive, backward compatible
#[contracttype]
pub struct Worker {
pub owner: Address,
pub name: String,
pub verified: Option<bool>, // new field, defaults to None on old entries
}Old storage entries deserialize successfully — verified will be None for existing workers.
Migrate entries on first access rather than all at once. This avoids a single large migration transaction.
pub fn get_worker(env: Env, id: Symbol) -> Option<WorkerV2> {
// Try reading as new type first
if let Some(w) = env.storage().persistent().get::<_, WorkerV2>(&DataKey::Worker(id.clone())) {
return Some(w);
}
// Fall back to old type and migrate
if let Some(old) = env.storage().persistent().get::<_, WorkerV1>(&DataKey::Worker(id.clone())) {
let migrated = WorkerV2 { owner: old.owner, name: old.name, verified: Some(false) };
env.storage().persistent().set(&DataKey::Worker(id), &migrated);
return Some(migrated);
}
None
}Add a one-time migrate function to the new WASM that the admin calls after upgrading. Gate it so it can only run once.
pub fn migrate(env: Env, admin: Address, worker_ids: Vec<Symbol>) {
admin.require_auth();
let config: Config = env.storage().instance().get(&DataKey::Config).expect("Not initialized");
assert!(config.admin == admin, "Unauthorized");
assert!(!env.storage().instance().has(&DataKey::Migrated), "Already migrated");
for id in worker_ids.iter() {
if let Some(old) = env.storage().persistent().get::<_, WorkerV1>(&DataKey::Worker(id.clone())) {
let new = WorkerV2 { owner: old.owner, name: old.name, verified: Some(false) };
env.storage().persistent().set(&DataKey::Worker(id), &new);
}
}
env.storage().instance().set(&DataKey::Migrated, &true);
}Call it after upgrading:
stellar contract invoke \
--id <REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- migrate \
--admin <ADMIN_ADDRESS> \
--worker_ids '["worker1","worker2","worker3"]'For major breaking changes where backward compatibility is not feasible:
- Deploy a new contract instance with the new WASM.
- Migrate state off-chain by reading from the old contract and writing to the new one.
- Update
REGISTRY_CONTRACT_ID/MARKET_CONTRACT_IDin the API.env. - Keep the old contract read-only for a transition period.
- Decommission the old contract once all clients have migrated.
1. [ ] Merge upgrade PR to main after review
2. [ ] Run full test suite: cargo test
3. [ ] Build release WASM: make build
4. [ ] Record current mainnet WASM hash (rollback reference)
5. [ ] Install new WASM on testnet: stellar contract install --network testnet
6. [ ] Upgrade testnet contract: stellar contract invoke ... upgrade
7. [ ] Run smoke tests on testnet (see checklist above)
8. [ ] Install new WASM on mainnet: stellar contract install --network mainnet
9. [ ] Upgrade mainnet contract: stellar contract invoke ... upgrade
10.[ ] Run smoke tests on mainnet
11.[ ] Update CHANGELOG.md with upgrade details and new WASM hash
12.[ ] Update contract addresses table in packages/contracts/README.md
Every upgrade is gated by an automated test framework whose job is to give a contract administrator confidence that an upgrade preserves all existing data and functionality before it ever reaches testnet or mainnet. It is organized into four pillars.
| Layer | Location | Runs under |
|---|---|---|
| Upgrade framework (registry) | contracts/registry/src/test.rs |
cargo test |
| Upgrade framework (market) | contracts/market/src/test.rs (mod upgrade_framework) |
cargo test |
| Migration property tests | contracts/fuzz/tests/upgrade_fuzz.rs |
cargo test |
| Coverage-guided migration fuzzer | contracts/fuzz/fuzz_targets/fuzz_migrate.rs |
cargo +nightly fuzz |
The framework files are wired into their crates via #[cfg(test)] mod test;,
so they compile and run as part of the normal test suite.
-
State-migration testing —
migrateis run over populated state and every stored field (workers, escrows, reputation, role membership) is asserted unchanged; the schema version must advance by exactly one. Replay and out-of-order migrations are rejected ("Wrong schema version"). -
Backward-compatibility verification — records and role memberships written by the old code are read back after a migration to prove storage-key/layout compatibility, a fresh deploy reports schema version
1, and themigrate/upgrade/propose_upgradeentry points are pinned to their exact argument shapes (a signature change fails the build). -
Performance-regression testing — core operations (
register,migrate,create_escrow) are run withenv.budget().reset_default()and their CPU and memory cost is asserted to stay under a ceiling with a few-x headroom over the observed baseline, so an upgrade cannot silently regress gas usage. -
Security-regression testing —
upgraderequires the stored admin to holdROLE_UPGRADER,migraterequiresROLE_ADMIN, the 48-hour upgrade timelock cannot be executed early, and only one upgrade may be pending at a time.
- Property-based (
proptest, deterministic, no nightly):upgrade_fuzz.rsdrivesmigrateover randomized worker/escrow state and checks the preservation + version-bump invariant. The other property suites (registry_fuzz.rs,market_fuzz.rs) fuzz the non-upgrade entry points. - Coverage-guided (
libFuzzer, nightly): thefuzz_register,fuzz_tip, andfuzz_migratetargets are gated behind thefuzzingCargo feature so they never break the normal build.
cd packages/contracts
# Full suite: unit tests + upgrade framework + property tests
make test # == cargo test --workspace
# Just the upgrade framework
cargo test -p bluecollar-registry test::
cargo test -p bluecollar-market test::upgrade_framework
cargo test -p bluecollar-fuzz --test upgrade_fuzz
# Property fuzzing (more cases = deeper coverage)
PROPTEST_CASES=512 make fuzz
# Coverage-guided fuzzing (needs nightly + cargo-fuzz)
make fuzz-migrate
# Coverage report (fails under 80% line coverage)
make coverage.github/workflows/contract-tests.yml runs on every push/PR that touches
packages/contracts/**:
| Job | Gate | What it does |
|---|---|---|
test |
blocking | cargo test --workspace |
upgrade-safety |
blocking | runs only the upgrade framework + migration property tests |
fuzz |
blocking | property fuzzing with PROPTEST_CASES=512 |
wasm-build |
blocking | builds + uploads the release WASM (make build) |
lint |
non-blocking | cargo fmt --check, cargo clippy |
coverage |
non-blocking | cargo llvm-cov report artifact |
The in-process Soroban test host cannot install a WASM blob from a dummy hash, so
the unit-level tests verify the data-integrity path of an upgrade via migrate.
A full WASM swap (env.deployer().upload_contract_wasm(...) followed by
upgrade) is exercised against the actual compiled bytecode by the
wasm-build job's artifact and on testnet during the dry run in the
Pre-Upgrade Testing Checklist.