This guide covers the full lifecycle of upgrading Brain-Storm's Soroban smart contracts: from understanding the upgrade mechanism, through testing and governance approval, to executing the upgrade and rolling back if needed.
- How Soroban Upgrades Work
- Which Contracts Can Be Upgraded
- Storage Preservation & Data Migration
- Testing Requirements Before Upgrade
- Governance Approval Process
- Upgrade Checklist
- Step-by-Step Upgrade Procedure
- Rollback Plan
- Security Considerations
Soroban contracts are upgraded in-place: the contract ID and all stored state remain unchanged. Only the executable WASM bytecode is replaced.
The SharedContract exposes the single upgrade entry point:
// contracts/shared/src/lib.rs
pub fn upgrade(env: Env, admin: Address, new_wasm_hash: BytesN<32>) {
admin.require_auth();
let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
assert!(admin == stored_admin, "Only admin can upgrade");
env.events().publish(
(symbol_short!("shared"), symbol_short!("upgraded")),
new_wasm_hash.clone(),
);
env.deployer().update_current_contract_wasm(new_wasm_hash);
}Key properties:
- Admin-gated —
admin.require_auth()ensures the transaction must be signed by the address stored asAdminduringinitialize. No other address can call this function. - Atomic — the WASM replacement happens in a single transaction. There is no window where the contract is partially upgraded.
- Auditable — a
("shared", "upgraded")event is emitted containing the new WASM hash, creating an on-chain audit trail. - Storage-preserving — all instance and persistent storage entries survive the upgrade unchanged (see Storage Preservation).
| Contract | Has upgrade fn |
Upgrade path |
|---|---|---|
shared |
✅ Yes | Call SharedContract::upgrade directly |
analytics |
❌ No | Redeploy and re-initialize; migrate data if needed |
token |
❌ No | Redeploy and re-initialize; migrate data if needed |
governance |
❌ No | Redeploy and re-initialize; migrate data if needed |
certificate |
❌ No | Redeploy and re-initialize; migrate data if needed |
Note: Only
SharedContractsupports in-place WASM upgrade. For the other contracts, an upgrade means deploying a new contract instance and updating all references (contract IDs in.env,deployed-contracts.json, and any cross-contract calls).
Because update_current_contract_wasm only replaces the WASM, all storage is preserved automatically:
| Storage type | Keys | Survives upgrade? |
|---|---|---|
| Instance | Admin, Role(Address) |
✅ Yes |
| Persistent | — (SharedContract has none) | ✅ Yes |
No data migration is needed for a SharedContract upgrade as long as the new WASM uses the same DataKey enum variants and value types.
When migration IS needed: If the new contract version adds, removes, or renames a DataKey variant, or changes the type of a stored value, you must write a one-time migration function in the new WASM that reads old keys and writes new ones. Call this migration function in the same transaction as the upgrade, or in the first transaction immediately after.
Example migration pattern (add to new contract before deploying):
// Run once after upgrade to rename a storage key
pub fn migrate_v1_to_v2(env: Env, admin: Address) {
admin.require_auth();
let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
assert!(admin == stored_admin, "Only admin");
// Read old value under old key, write under new key, delete old
if let Some(old_val) = env.storage().instance().get::<_, OldType>(&OldDataKey::SomeKey) {
env.storage().instance().set(&DataKey::NewKey, &NewType::from(old_val));
env.storage().instance().remove(&OldDataKey::SomeKey);
}
}A redeploy creates a brand-new contract with a new contract ID. Persistent storage from the old contract is not accessible from the new one. Options:
- Accept data loss — acceptable for testnet or non-critical data.
- Off-chain snapshot + replay — read all persistent entries from the old contract via Horizon/RPC before decommissioning, then replay them into the new contract via
initialize+ setter calls. - On-chain migration contract — deploy a temporary migration contract that reads from the old contract and writes to the new one in a single transaction batch.
All of the following must pass before an upgrade is executed on mainnet.
cargo testThe upgrade auth guard is covered in contracts/shared/src/tests.rs:
#[test]
#[should_panic(expected = "Only admin can upgrade")]
fn test_non_admin_cannot_upgrade() { ... }Ensure your new WASM passes all existing tests and any new tests covering changed behaviour.
Test the full upgrade flow against a local Soroban sandbox before touching testnet:
# Start a local sandbox
stellar network start local
# Deploy the current (old) contract
stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/brain_storm_shared.wasm \
--source alice \
--network local
# Initialize it and record some state
stellar contract invoke --id $CONTRACT_ID --source alice --network local \
-- initialize --admin $ADMIN_ADDRESS
# Build the new WASM
./scripts/build.sh
# Upload new WASM and capture hash
NEW_HASH=$(stellar contract upload \
--wasm target/wasm32-unknown-unknown/release/brain_storm_shared.wasm \
--source alice \
--network local)
# Execute upgrade
stellar contract invoke --id $CONTRACT_ID --source alice --network local \
-- upgrade --admin $ADMIN_ADDRESS --new_wasm_hash $NEW_HASH
# Verify state is preserved and new functions work
stellar contract invoke --id $CONTRACT_ID --source alice --network local \
-- has_role --addr $ADMIN_ADDRESS --role AdminRepeat the full procedure on testnet using the contract IDs in scripts/deployed-contracts.json before touching mainnet.
- All
cargo testpass on the new WASM -
cargo clippy -- -D warningsproduces no warnings -
cargo fmt --checkpasses -
cargo auditshows no vulnerabilities - New WASM hash matches the audited build (verify with
sha256sum) - Storage keys and value types are backward-compatible (or migration function is included)
- New entry points are tested end-to-end on local sandbox
- Upgrade tested on testnet and state verified post-upgrade
For mainnet upgrades, changes must go through the on-chain governance process using the GovernanceContract. BST token holders vote on proposals; a proposal passes when votes_for > votes_against after the voting period ends.
Any BST holder can create a proposal. The voting_end_ledger must be in the future (roughly 1 ledger ≈ 5 seconds; a 7-day vote ≈ 120,960 ledgers).
# Calculate voting_end_ledger: current_ledger + 120960 (≈ 7 days)
CURRENT_LEDGER=$(stellar ledger current --network mainnet | jq '.sequence')
VOTING_END=$((CURRENT_LEDGER + 120960))
./scripts/invoke.sh mainnet governance create_proposal \
--proposer $PROPOSER_ADDRESS \
--title "Upgrade SharedContract to v1.2.0" \
--description "Adds MFA role support. New WASM hash: $NEW_HASH. Audit report: https://..." \
--voting_end_ledger $VOTING_ENDThe proposal description should include:
- What changed and why
- The new WASM hash (so voters can verify the build)
- Link to audit report or PR
- Rollback plan
BST holders vote during the voting window. Voting power equals the voter's BST balance at the time of voting.
# Vote in favour
./scripts/invoke.sh mainnet governance vote \
--voter $VOTER_ADDRESS \
--proposal_id $PROPOSAL_ID \
--support true
# Vote against
./scripts/invoke.sh mainnet governance vote \
--voter $VOTER_ADDRESS \
--proposal_id $PROPOSAL_ID \
--support falseCheck current vote tallies:
./scripts/invoke.sh mainnet governance get_proposal \
--proposal_id $PROPOSAL_ID
# Returns: { votes_for, votes_against, voting_end_ledger, executed, ... }After voting_end_ledger is reached, anyone can call execute_proposal. This marks the proposal as executed on-chain. The actual contract upgrade is then performed by the admin in a separate transaction.
./scripts/invoke.sh mainnet governance execute_proposal \
--proposal_id $PROPOSAL_IDOnly after the proposal is executed on-chain should the admin perform the WASM upgrade:
./scripts/invoke.sh mainnet shared upgrade \
--admin $ADMIN_ADDRESS \
--new_wasm_hash $NEW_HASHTestnet upgrades do not require governance approval. They can be executed directly by the admin for development and testing purposes.
Use this checklist for every mainnet upgrade.
- New contract code reviewed and approved via PR
-
cargo test,cargo clippy,cargo fmt --check,cargo auditall pass - WASM built with
./scripts/build.shand hash recorded - WASM hash verified against audited build (
sha256sum) - Storage compatibility confirmed (no breaking key/type changes, or migration function included)
- Full upgrade flow tested on local sandbox
- Full upgrade flow tested on testnet
- Governance proposal created with WASM hash and audit link
- Voting period completed and
votes_for > votes_against -
execute_proposalcalled on-chain - Admin key available and confirmed (hardware wallet / multisig ready)
- Old WASM hash recorded for rollback
- Upload new WASM:
stellar contract upload ...→ captureNEW_HASH - Verify
NEW_HASHmatches expected hash - Call
upgrade(admin, NEW_HASH)on the contract - Confirm
("shared", "upgraded")event emitted with correct hash
- Call
has_role/has_permissionto confirm existing state is intact - Call any new functions introduced in the upgrade
- Check Horizon event stream for the
upgradedevent - Update
scripts/deployed-contracts.jsonif contract ID changed (redeploy only) - Update
.env/ CI secrets if contract IDs changed - Announce upgrade completion to community
# 1. Build the new WASM
./scripts/build.sh
# 2. Upload the new WASM to the network (does NOT replace the contract yet)
NEW_HASH=$(stellar contract upload \
--wasm target/wasm32-unknown-unknown/release/brain_storm_shared.wasm \
--source "$STELLAR_SECRET_KEY" \
--network mainnet)
echo "New WASM hash: $NEW_HASH"
# 3. Verify the hash matches your audited build
echo "$NEW_HASH target/wasm32-unknown-unknown/release/brain_storm_shared.wasm" | sha256sum --check
# 4. Read the current contract ID
CONTRACT_ID=$(jq -r '.mainnet.shared' scripts/deployed-contracts.json)
# 5. Execute the upgrade (admin must sign)
stellar contract invoke \
--id "$CONTRACT_ID" \
--source "$STELLAR_SECRET_KEY" \
--network mainnet \
-- upgrade \
--admin "$ADMIN_ADDRESS" \
--new_wasm_hash "$NEW_HASH"
# 6. Verify state is intact
stellar contract invoke \
--id "$CONTRACT_ID" \
--source "$STELLAR_SECRET_KEY" \
--network mainnet \
-- has_role \
--addr "$ADMIN_ADDRESS" \
--role Admin# 1. Build
./scripts/build.sh
# 2. Deploy new instance
./scripts/deploy.sh mainnet analytics
# → new CONTRACT_ID written to scripts/deployed-contracts.json
# 3. Initialize the new contract
NEW_CONTRACT_ID=$(jq -r '.mainnet.analytics' scripts/deployed-contracts.json)
stellar contract invoke \
--id "$NEW_CONTRACT_ID" \
--source "$STELLAR_SECRET_KEY" \
--network mainnet \
-- initialize \
--admin "$ADMIN_ADDRESS"
# 4. Replay any persistent data from the old contract (if required)
# See "Storage Preservation & Data Migration" section above.
# 5. Update ANALYTICS_CONTRACT_ID in .env and CI secrets
# 6. Redeploy the backend to pick up the new contract IDBecause the old WASM is still stored on-chain (Stellar never deletes uploaded WASMs), rollback is identical to an upgrade — just use the old WASM hash.
# Record the current hash BEFORE upgrading (do this as part of pre-upgrade checklist)
OLD_HASH=$(stellar contract invoke \
--id "$CONTRACT_ID" \
--source "$STELLAR_SECRET_KEY" \
--network mainnet \
-- get_wasm_hash 2>/dev/null || echo "record manually from Horizon")
# If the upgrade causes issues, roll back by calling upgrade with the old hash
stellar contract invoke \
--id "$CONTRACT_ID" \
--source "$STELLAR_SECRET_KEY" \
--network mainnet \
-- upgrade \
--admin "$ADMIN_ADDRESS" \
--new_wasm_hash "$OLD_HASH"Always record the current WASM hash before upgrading. You can retrieve it from the Horizon API or Stellar Laboratory by inspecting the contract's ledger entry.
If a redeployed contract (analytics, token, etc.) has issues:
- The old contract instance is still live on-chain — it was never destroyed.
- Revert
scripts/deployed-contracts.jsonto the old contract ID. - Revert the
*_CONTRACT_IDenvironment variables and redeploy the backend. - The old contract resumes serving requests immediately.
-
Protect the admin key. The admin key is the sole authority for upgrades. Use a hardware wallet (Ledger) or a multisig account in production. Never store the admin secret key in
.envfiles committed to version control. -
Verify WASM hashes. Before calling
upgrade, independently verify thatNEW_HASHmatches the hash of the audited WASM binary. A compromised build pipeline could produce a different binary than the reviewed source. -
Audit before mainnet. Every mainnet upgrade should have an external or internal security review. Include the audit report link in the governance proposal.
-
Test on testnet first. Always run the complete upgrade procedure on testnet before mainnet. Testnet contract IDs are in
scripts/deployed-contracts.jsonunder thetestnetkey. -
Time-lock for high-impact upgrades. Consider adding a time-lock to the admin role for production: submit the upgrade transaction but delay execution by N ledgers, giving the community time to detect and respond to a malicious upgrade.
-
Monitor post-upgrade. Watch the Horizon event stream for unexpected events after an upgrade. The
("shared", "upgraded")event should be the only upgrade-related event. Set up alerts via the monitoring stack (infra/monitoring/) for anomalous contract activity.
The file contracts/shared/src/upgrade_tests.rs contains an end-to-end test harness for the upgrade path. Run it with:
cargo test -p shared -- upgrade_tests| Test | Scenario |
|---|---|
test_schedule_upgrade_stores_pending |
Deploy v1, schedule upgrade, assert pending upgrade stored |
test_non_admin_cannot_schedule_upgrade |
Unauthorised schedule rejected |
test_cancel_upgrade_removes_pending |
Cancel clears the pending upgrade |
test_non_admin_cannot_cancel_upgrade |
Unauthorised cancel rejected |
test_execute_before_timelock_panics |
Timelock enforced — execute fails before delay |
test_non_admin_cannot_execute_upgrade |
Unauthorised execute rejected |
test_state_preserved_after_schedule |
Write state (roles) → schedule upgrade → state intact |
test_state_preserved_after_cancel |
Write state → cancel upgrade → state intact |
test_migration_pattern_reads_existing_data |
Simulates v1→v2 storage-layout compatibility check |
test_upgrade_history_initially_empty |
Upgrade history count starts at 0 |
test_no_pending_upgrade_initially |
No pending upgrade on fresh contract |
test_direct_upgrade_non_admin_rejected |
Direct upgrade() by non-admin panics |
initialize(admin)
└─ write v1 state (roles, permissions)
└─ schedule_upgrade(admin, new_hash, timelock)
├─ [before timelock] execute_upgrade → panics "Timelock not expired"
├─ cancel_upgrade(admin) → removes pending, state intact
└─ [after timelock] execute_upgrade(admin)
└─ WASM replaced, storage preserved, history recorded
All DataKey variants and their value types are XDR-encoded on-chain. Adding new variants is always backward-compatible. Changing or removing existing variants requires a migration function — see the Storage Preservation & Data Migration section.
# All contracts
cargo test
# Upgrade harness only
cargo test -p shared -- upgrade_tests
# Property/fuzz tests (market + registry)
cargo test -p market -- fuzz_tests
cargo test -p registry -- fuzz_tests