As of Phase A, contract upgrades are no longer a single upgrade(admin, new_wasm_hash) call. The admin must run a two-step timelock flow:
propose_upgrade(admin, new_wasm_hash)— records the proposed hash underDataKey::PendingUpgradeand the earliest executable ledger underDataKey::UpgradeEffectiveAt. Emits anupg_propevent.- Wait for at least
UPGRADE_TIMELOCK_LEDGERS = 34_560ledgers (48h × 3600s / 5s/ledger) to elapse. execute_upgrade()— permissionless; anyone can call it after the timelock. The contract WASM is swapped viaenv.deployer().update_current_contract_wasm, the executed hash is recorded underDataKey::LastExecutedUpgrade, and anupg_execevent is emitted.- Cancel — admin may call
cancel_upgrade(admin)at any time before execution to drop a pending upgrade. Emits anupg_cnclevent.
This 48h delay is the sole safety mechanism against a compromised admin key proposing a malicious WASM. See SECURITY.md for the full threat model and the recovery procedure.
If you are upgrading this contract:
- The proposed WASM MUST be a drop-in replacement that preserves every storage key and value layout listed below.
- During the 48h window, run a dry-run deployment to a testnet address with the same storage, and verify the regression test (
test_upgrade_preserves_donation_state_and_storage_keys) passes against the new WASM. - New: Call
simulate_upgrade()(permissionless, no auth required) to validate storage key integrity against the pending upgrade. See the Dry-Run Simulation section below. - If the proposed WASM is discovered to be malicious or buggy during the window, the admin MUST call
cancel_upgradebefore the timelock elapses.
simulate_upgrade() is a read-only safety check anyone can call during the 48-hour timelock window. It validates that a pending upgrade is compatible with the current contract storage without actually executing the WASM migration.
- Pending upgrade exists — panics if no upgrade was proposed.
- WASM hash is non-zero — rejects corrupted all-zeros proposals.
- Scalar storage keys — enumerates and verifies accessibility of all non-parameterized
DataKeyvariants (AdminSet, AdminThreshold, ProjectCount, DonationCount, GlobalTotalRaised, GlobalCO2OffsetGrams, ContractPaused, DonationRateLimitMax, DonationRateLimitWindow, ProjectIdsAll, plus the upgrade lifecycle keys). - Project-specific keys — iterates
ProjectIdsAlland verifies eachProject(pid)entry deserialises correctly, plus any associatedProposal(pid),VoterList(pid), orEmergencyWithdrawal(pid).
- No actual WASM execution: Soroban host does not support storage snapshots and rollbacks, so the simulation cannot run the proposed
migrate()function. Instead it validates storage key integrity and reports that all existing keys would be preserved (keys_after == keys_before). - Donor-address-keyed keys (
DonorStats,ImpactNFT,HasDonated, etc.) cannot be enumerated without an on-chain donor index. The total key count is considered representative for validation.
pub struct SimulationResult {
pub success: bool, // true if no errors found
pub storage_keys_before: u32, // count of accessible storage keys
pub storage_keys_after: u32, // projected count after migration (== before)
pub errors: Vec<String>, // descriptive errors (empty on success)
}# Via Stellar CLI
stellar contract invoke \
--id CONTRACT_ID \
--network testnet \
-- simulate_upgrade// Via JS SDK
const result = await contract.simulate_upgrade();
console.log(result.success, result.storage_keys_before);Run the simulation tests:
cargo test -p indigopay-contract --lib simulate_upgradeIndigoPay uses Soroban instance storage. Upgrade code must keep existing storage keys and stored value layouts backward-compatible because old ledger entries are decoded by the new contract executable after upgrade.
The current persisted keys are:
DataKey::AdminDataKey::Project(String)DataKey::ProjectCountDataKey::DonorStats(Address)DataKey::ImpactNFT(Address, BadgeTier)DataKey::DonationCountDataKey::GlobalTotalRaisedDataKey::GlobalCO2OffsetGramsDataKey::HasDonated(String, Address)DataKey::Proposal(String)VoteProposalnow includesresolved_at: u32(appended for backward compatibility). Legacy proposals stored before this field existed will decode withresolved_at == 0. Resolution functions (resolve_proposal,veto_proposal) always set this field; older resolved proposals that lack it are treated as havingresolved_at == 0and become eligible for cleanup immediately after upgrade.
DataKey::HasVoted(String, Address)DataKey::DonorProjectTotal(String, Address)(v1.1 milestone-NFT support)DataKey::ProjectMilestoneNFT(String, Address)(v1.1 milestone-NFT support)DataKey::VoterList(String)(v1.2 governance UI support)DataKey::ProjectIdsAll(v1.2 bulk admin support)DataKey::USDCTokenAddress(v1.2 multi-currency)DataKey::OracleAddress(v1.2 price oracle)DataKey::PendingAdmin(Phase A two-step admin)DataKey::ContractPaused(Phase A contract-level pause)DataKey::PendingUpgrade(Phase A 48h timelock)DataKey::UpgradeEffectiveAt(Phase A 48h timelock)DataKey::LastExecutedUpgrade(Phase A 48h timelock)DataKey::RefundRequest(u32)(#290 donation refund)DataKey::RefundCount(#290 donation refund)DataKey::RefundForDonation(u32)(#290 donation refund)DataKey::DonationCO2Offset(u32)(#290 donation refund — CO₂ snapshot per donation)DataKey::ForceRefund(u32)(#429 M-of-N refund escalation timelock; appended to preserve existing discriminants)DataKey::SubProjectIds(String)(#391 cross-contract project registry — sub-project index per parent)DataKey::StealthDonationContract(#458 stealth address donation integration)DataKey::TokenConfig(Address)(#421 dynamic token registry configuration per asset)DataKey::TokenList(#421 dynamic token registry enumeration list)DataKey::DonorRateLimit(Address, String, Address)(canonical per-token donation rate limit window)DataKey::DonorRateLimitPerToken(Address, String, Address)(#421 transitional per-token key; retained for migration)DataKey::TokenRateLimitMax(Address)(per-token maximum donations override)DataKey::TokenRateLimitWindow(Address)(per-token window override in ledgers)DataKey::VestingSchedule(Address, u32)(#386 time-locked donation vesting)VestingSchedulenow includescompleted_at: u32(appended for backward compatibility). Legacy schedules stored before this field existed will decode withcompleted_at == 0. Cancellation (cancel_vesting) and full claim (claim_vested_installmentwhen all installments are released) set this field. Active schedules withcompleted_at == 0are not eligible for cleanup.
DataKey::DonorVestingCount(Address)(#386 per-donor vesting count)- Storage version tracking (#379 — Symbol-keyed, not a DataKey variant)
Do not rename or remove these variants, change their argument order, or reorder/remove fields from stored structs such as Project, DonorStats, ImpactNFT, ProjectMilestoneNFT, VoteProposal, or GlobalStats without adding an explicit migration path. New fields should be handled through a new storage version or a new key namespace so existing v1 values remain decodable.
The legacy rate-limit window was encoded as
DonorRateLimit(Address, String), so it has no token discriminator. The
contract retains a private LegacyDataKey representation that produces the
same raw storage key. The initial #421 implementation also used
DataKey::DonorRateLimitPerToken(Address, String, Address). New windows use
the canonical DataKey::DonorRateLimit(Address, String, Address).
Migration is lazy and atomic in the donation path:
- Read the canonical three-field per-token key first.
- If it is absent, read and move the transitional #421 key.
- If both are absent, read the legacy two-field key.
- When a legacy window exists, remove it and continue with that window under the canonical token-specific key.
- If no representation exists, start a new window.
The legacy key is moved only once. This prevents its count from being copied into every token window while retaining the donor's active rate-limit state for the first post-upgrade token. If the donation fails, Soroban transaction rollback also rolls back the key removal.
Token-specific policy values are stored independently. When either per-token
configuration key is absent, the corresponding global
DonationRateLimitMax or DonationRateLimitWindow value is used; if the
global value is also absent, the compiled default is used.
test_upgrade_preserves_donation_state_and_storage_keys covers the v1 to v2 same-code path:
- Deploys IndigoPay v1 in the Soroban test host.
- Registers a project and records a real token-backed donation.
- Replaces the executable at the same contract ID with the same IndigoPay code to model a v2 upgrade.
- Reads the donation-derived project totals, donor stats, badge/NFT state, global counters, and
HasDonatedmarker through both public getters and directDataKeylookups.
This confirms the storage keys and value layouts used by existing donation state remain backward-compatible across the upgrade.
test_propose_upgrade_* and test_execute_upgrade_* exercise the new timelock lifecycle:
test_propose_upgrade_stores_pending_and_effective_at— verifies both storage keys are written.test_propose_upgrade_double_propose_fails— verifies only one pending upgrade may exist at a time.test_execute_upgrade_before_timelock_fails— verifies the timelock panics before the deadline.test_execute_upgrade_after_timelock_succeeds— advances the ledger pastUPGRADE_TIMELOCK_LEDGERSand verifies the WASM swap fires andget_last_executed_upgradereturns the hash.test_cancel_upgrade_clears_pendingandtest_cancel_upgrade_during_timelock_succeeds— verify the cancel path.test_get_pending_upgrade— verifies the read-only getter returns the correct(hash, effective_at)tuple.
The contract uses a DataKey::StorageVersion key to track the current schema
version. This enables automated storage migrations after contract upgrades:
CURRENT_STORAGE_VERSION— defined at the top oflib.rs. Bump this and add a migration step inmigrate()whenever a struct layout, DataKey variant, or stored value encoding changes in a backward-incompatible way.DataKey::StorageVersion— persists the version number after each migration step completes, ensuring no step is applied twice.
initialize()setsStorageVersion = CURRENT_STORAGE_VERSIONfor new contracts, so they skip all historical migrations.- For upgraded contracts,
execute_upgrade()callsmigrate()immediately after swapping the WASM. migrate()reads the currentStorageVersion, applies each pending migration step sequentially (e.g.,migrate_v1_to_v2), and updatesStorageVersionafter each step.- The final assertion in
migrate()panics ifStorageVersiondoes not equalCURRENT_STORAGE_VERSION, catching incomplete migration sequences at upgrade time.
- Bump
CURRENT_STORAGE_VERSION. - Add the migration function (e.g.,
migrate_v2_to_v3). - Add a
if current < NEW_VERSION { migrate_v2_to_v3(env); set_storage_version(NEW_VERSION); }block inmigrate(). - Handle backward-incompatible changes — rename old keys, transform stored values, or backfill missing entries — inside the migration function.
The v1→v2 migration is empty because v1 storage is v2-compatible. It exists solely to establish the migration framework pattern. When the first real schema change is introduced, replace it with actual data transformations.
Run the focused regression test:
cargo test -p indigopay-contract --lib test_upgrade_preserves_donation_state_and_storage_keysRun the storage versioning tests:
cargo test -p indigopay-contract --lib test_storage_version_initialized
cargo test -p indigopay-contract --lib test_migration_runs_on_upgrade
cargo test -p indigopay-contract --lib test_migration_idempotentRun the timelock regression test:
cargo test -p indigopay-contract --lib propose_upgrade
cargo test -p indigopay-contract --lib execute_upgrade
cargo test -p indigopay-contract --lib cancel_upgradeRun the full contract suite:
cargo test