Skip to content

Latest commit

 

History

History
248 lines (191 loc) · 12.9 KB

File metadata and controls

248 lines (191 loc) · 12.9 KB

IndigoPay Contract Upgrade Notes

Phase A — 48-hour timelock (current)

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:

  1. propose_upgrade(admin, new_wasm_hash) — records the proposed hash under DataKey::PendingUpgrade and the earliest executable ledger under DataKey::UpgradeEffectiveAt. Emits an upg_prop event.
  2. Wait for at least UPGRADE_TIMELOCK_LEDGERS = 34_560 ledgers (48h × 3600s / 5s/ledger) to elapse.
  3. execute_upgrade() — permissionless; anyone can call it after the timelock. The contract WASM is swapped via env.deployer().update_current_contract_wasm, the executed hash is recorded under DataKey::LastExecutedUpgrade, and an upg_exec event is emitted.
  4. Cancel — admin may call cancel_upgrade(admin) at any time before execution to drop a pending upgrade. Emits an upg_cncl event.

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_upgrade before the timelock elapses.

Upgrade Dry-Run Simulation

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.

What it checks

  1. Pending upgrade exists — panics if no upgrade was proposed.
  2. WASM hash is non-zero — rejects corrupted all-zeros proposals.
  3. Scalar storage keys — enumerates and verifies accessibility of all non-parameterized DataKey variants (AdminSet, AdminThreshold, ProjectCount, DonationCount, GlobalTotalRaised, GlobalCO2OffsetGrams, ContractPaused, DonationRateLimitMax, DonationRateLimitWindow, ProjectIdsAll, plus the upgrade lifecycle keys).
  4. Project-specific keys — iterates ProjectIdsAll and verifies each Project(pid) entry deserialises correctly, plus any associated Proposal(pid), VoterList(pid), or EmergencyWithdrawal(pid).

Limitations

  • 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.

Return value

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)
}

Calling

# 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);

Testing

Run the simulation tests:

cargo test -p indigopay-contract --lib simulate_upgrade

Storage Compatibility

IndigoPay 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::Admin
  • DataKey::Project(String)
  • DataKey::ProjectCount
  • DataKey::DonorStats(Address)
  • DataKey::ImpactNFT(Address, BadgeTier)
  • DataKey::DonationCount
  • DataKey::GlobalTotalRaised
  • DataKey::GlobalCO2OffsetGrams
  • DataKey::HasDonated(String, Address)
  • DataKey::Proposal(String)
    • VoteProposal now includes resolved_at: u32 (appended for backward compatibility). Legacy proposals stored before this field existed will decode with resolved_at == 0. Resolution functions (resolve_proposal, veto_proposal) always set this field; older resolved proposals that lack it are treated as having resolved_at == 0 and 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)
    • VestingSchedule now includes completed_at: u32 (appended for backward compatibility). Legacy schedules stored before this field existed will decode with completed_at == 0. Cancellation (cancel_vesting) and full claim (claim_vested_installment when all installments are released) set this field. Active schedules with completed_at == 0 are 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.

Donation rate-limit key migration

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:

  1. Read the canonical three-field per-token key first.
  2. If it is absent, read and move the transitional #421 key.
  3. If both are absent, read the legacy two-field key.
  4. When a legacy window exists, remove it and continue with that window under the canonical token-specific key.
  5. 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.

Regression Coverage

test_upgrade_preserves_donation_state_and_storage_keys covers the v1 to v2 same-code path:

  1. Deploys IndigoPay v1 in the Soroban test host.
  2. Registers a project and records a real token-backed donation.
  3. Replaces the executable at the same contract ID with the same IndigoPay code to model a v2 upgrade.
  4. Reads the donation-derived project totals, donor stats, badge/NFT state, global counters, and HasDonated marker through both public getters and direct DataKey lookups.

This confirms the storage keys and value layouts used by existing donation state remain backward-compatible across the upgrade.

Timelock Regression Coverage

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 past UPGRADE_TIMELOCK_LEDGERS and verifies the WASM swap fires and get_last_executed_upgrade returns the hash.
  • test_cancel_upgrade_clears_pending and test_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.

Storage Versioning and Migration (#379)

The contract uses a DataKey::StorageVersion key to track the current schema version. This enables automated storage migrations after contract upgrades:

Version constants

  • CURRENT_STORAGE_VERSION — defined at the top of lib.rs. Bump this and add a migration step in migrate() 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.

How migrations work

  1. initialize() sets StorageVersion = CURRENT_STORAGE_VERSION for new contracts, so they skip all historical migrations.
  2. For upgraded contracts, execute_upgrade() calls migrate() immediately after swapping the WASM.
  3. migrate() reads the current StorageVersion, applies each pending migration step sequentially (e.g., migrate_v1_to_v2), and updates StorageVersion after each step.
  4. The final assertion in migrate() panics if StorageVersion does not equal CURRENT_STORAGE_VERSION, catching incomplete migration sequences at upgrade time.

Adding a new migration

  1. Bump CURRENT_STORAGE_VERSION.
  2. Add the migration function (e.g., migrate_v2_to_v3).
  3. Add a if current < NEW_VERSION { migrate_v2_to_v3(env); set_storage_version(NEW_VERSION); } block in migrate().
  4. Handle backward-incompatible changes — rename old keys, transform stored values, or backfill missing entries — inside the migration function.

Example migration (v1 → v2)

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.

Validation

Run the focused regression test:

cargo test -p indigopay-contract --lib test_upgrade_preserves_donation_state_and_storage_keys

Run 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_idempotent

Run 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_upgrade

Run the full contract suite:

cargo test