From 2cd3f3537fa156618f3a5105ac7a9590a6e17b1e Mon Sep 17 00:00:00 2001 From: Mayowa Date: Sun, 23 Aug 2026 11:49:14 +0000 Subject: [PATCH] Add differential upgrade compatibility gate --- .../docs/versioned-state-migration.md | 34 +++ craft-nexus-contract/src/lib.rs | 204 ++++++++++++++++++ craft-nexus-contract/src/test.rs | 66 ++++++ 3 files changed, 304 insertions(+) diff --git a/craft-nexus-contract/docs/versioned-state-migration.md b/craft-nexus-contract/docs/versioned-state-migration.md index 9ce6f3e6..59bd3b62 100644 --- a/craft-nexus-contract/docs/versioned-state-migration.md +++ b/craft-nexus-contract/docs/versioned-state-migration.md @@ -12,6 +12,40 @@ For every migration version, operators must strictly adhere to the following seq 2. **Migration Invocation:** Execute the targeted Soroban contract command. 3. **Post-Migration Verification:** Ensure the state matches the structural rules of the new schema version. +## Differential Upgrade Compatibility Gate + +An uploaded WASM and a successful unit-test run are not sufficient evidence for +an upgrade. Before execution, run the old and new artifacts against an isolated +fixture containing legacy profiles, active and disputed escrows, recurring +balances, stake queues, pending upgrades, and paused configuration. Compare +read results, authorization decisions, error classifications, invariants, and +events. Commit the pre-migration snapshot returned by +`get_upgrade_state_commitment` and the interface/authentication test results in +an `UpgradeCompatibilityManifest`. + +Submit the manifest with `submit_upgrade_compatibility_manifest`. It must: + +- identify the exact source and target contract versions; +- commit to storage preconditions, postconditions, interface behavior, + authorization behavior, and rollback limitations; +- include a resumable migration checkpoint; +- report `migration_complete: true` and `manual_records: 0`. + +`execute_upgrade` rejects missing, stale, incomplete, or manually unresolved +manifests before calling `update_current_contract_wasm`. On success, +`UpgradeHistory` records the source and target versions, WASM hash, state +commitment, and migration checkpoint. The manifest is removed only after the +upgrade record and version update are written. A migration runner may replace +the manifest for the same hash while it resumes; each execution attempt is +idempotently blocked until the final checkpoint is submitted. + +The manifest is an attestation boundary, not a substitute for isolated test +execution. CI and release tooling must fail closed when the differential +fixture, invariant suite, or rollback documentation does not produce all +non-zero commitments required by the on-chain gate. Records that cannot be +automatically migrated must remain outside execution until they are handled +and the manifest is resubmitted with a new checkpoint. + --- ## Migration 1: UserProfile (v1 -> v2) diff --git a/craft-nexus-contract/src/lib.rs b/craft-nexus-contract/src/lib.rs index e0695db8..65fe2afc 100644 --- a/craft-nexus-contract/src/lib.rs +++ b/craft-nexus-contract/src/lib.rs @@ -140,6 +140,12 @@ pub enum Error { AlreadyApproved = 43, /// Token decimal places are outside the supported range (0–18) InvalidTokenDecimals = 44, + /// No compatibility manifest has been submitted for the upgrade + UpgradeCompatibilityMissing = 45, + /// The compatibility manifest does not describe the current state/version + UpgradeCompatibilityInvalid = 46, + /// The migration report contains records that require manual handling + UpgradeMigrationIncomplete = 47, } /// Returns `true` if the error is transient and the operation may succeed on retry. @@ -386,6 +392,8 @@ pub enum DataKey { TotalStaked(Address), /// Bounded log of completed WASM upgrades. Capped at MAX_UPGRADE_HISTORY UpgradeHistory, + /// Compatibility evidence for completed WASM upgrades. + UpgradeCompatibilityHistory, /// Key for a recurring escrow by its ID RecurringEscrow(u64), /// ID counter for recurring escrows @@ -401,6 +409,8 @@ pub enum DataKey { /// Ledger timestamp (u64) recorded when the last upgrade proposal was /// cancelled. Used to enforce CANCEL_REPROPOSE_COOLDOWN (Issue #618). LastUpgradeCancelledAt, + /// Differential compatibility manifest keyed by the proposed WASM hash. + UpgradeCompatibilityManifest(BytesN<32>), } #[contracttype] @@ -912,6 +922,54 @@ pub struct UpgradeRecord { pub timestamp: u64, } +/// Additive audit record for compatibility evidence. Kept separate from +/// `UpgradeRecord` so existing serialized upgrade history remains readable. +#[contracttype] +#[derive(Clone, Eq, PartialEq)] +#[cfg_attr(any(test, feature = "testutils"), derive(Debug))] +pub struct UpgradeCompatibilityRecord { + pub from_version: u32, + pub to_version: u32, + pub wasm_hash: BytesN<32>, + pub state_commitment: BytesN<32>, + pub migration_checkpoint: BytesN<32>, + pub timestamp: u64, +} + +/// Evidence produced by an isolated old/new implementation compatibility run. +/// Hash fields commit to the complete manifest and its test evidence; the +/// contract deliberately does not trust an uncommitted human-readable report. +#[contracttype] +#[derive(Clone, Eq, PartialEq)] +#[cfg_attr(any(test, feature = "testutils"), derive(Debug))] +pub struct UpgradeCompatibilityManifest { + pub source_version: u32, + pub target_version: u32, + pub state_commitment: BytesN<32>, + pub interface_commitment: BytesN<32>, + pub authorization_commitment: BytesN<32>, + pub preconditions_commitment: BytesN<32>, + pub postconditions_commitment: BytesN<32>, + pub rollback_limitations_commitment: BytesN<32>, + pub migration_checkpoint: BytesN<32>, + pub migration_complete: bool, + pub manual_records: u32, +} + +/// Stable, representative state used by migration tooling when creating a +/// differential snapshot. The resulting hash is supplied in the manifest. +#[contracttype] +#[derive(Clone, Eq, PartialEq)] +#[cfg_attr(any(test, feature = "testutils"), derive(Debug))] +pub struct UpgradeStateSnapshot { + pub contract_version: u32, + pub escrow_count: u32, + pub recurring_escrow_next_id: u64, + pub upgrade_threshold: u32, + pub paused: bool, + pub onboarding_configured: bool, +} + /// Per-token fee configuration introduced for #239. /// /// The legacy `FeeTokenIndex` storage held only a flat `Vec
` of @@ -4214,6 +4272,107 @@ impl CraftNexusContract { .unwrap_or_else(|| Vec::new(&env)) } + /// Return the state summary that migration tooling must snapshot in its + /// isolated old/new differential run. + pub fn get_upgrade_state_snapshot(env: Env) -> UpgradeStateSnapshot { + let config = Self::get_platform_config_internal(&env); + UpgradeStateSnapshot { + contract_version: Self::get_version(env.clone()), + escrow_count: env + .storage() + .persistent() + .get(&DataKey::EscrowCount) + .unwrap_or(0), + recurring_escrow_next_id: env + .storage() + .persistent() + .get(&DataKey::NextRecurringEscrowId) + .unwrap_or(0), + upgrade_threshold: Self::get_upgrade_threshold(env.clone()), + paused: config.is_paused, + onboarding_configured: env + .storage() + .persistent() + .has(&DataKey::OnboardingContractAddress), + } + } + + /// Hash the current representative state. Tooling should call this before + /// and after its isolated migration and place the pre-migration value in + /// `UpgradeCompatibilityManifest::state_commitment`. + pub fn get_upgrade_state_commitment(env: Env) -> BytesN<32> { + let snapshot = Self::get_upgrade_state_snapshot(env.clone()); + env.crypto().sha256(&env.serialize(&snapshot)) + } + + fn is_zero_commitment(env: &Env, commitment: &BytesN<32>) -> bool { + commitment == &BytesN::<32>::from_array(env, &[0u8; 32]) + } + + fn validate_compatibility_manifest( + env: &Env, + manifest: &UpgradeCompatibilityManifest, + ) -> Result<(), Error> { + let current_version = Self::get_version(env.clone()); + if manifest.source_version != current_version + || manifest.target_version != current_version.saturating_add(1) + || Self::is_zero_commitment(env, &manifest.state_commitment) + || Self::is_zero_commitment(env, &manifest.interface_commitment) + || Self::is_zero_commitment(env, &manifest.authorization_commitment) + || Self::is_zero_commitment(env, &manifest.preconditions_commitment) + || Self::is_zero_commitment(env, &manifest.postconditions_commitment) + || Self::is_zero_commitment(env, &manifest.rollback_limitations_commitment) + || Self::is_zero_commitment(env, &manifest.migration_checkpoint) + { + return Err(Error::UpgradeCompatibilityInvalid); + } + + if !manifest.migration_complete || manifest.manual_records != 0 { + return Err(Error::UpgradeMigrationIncomplete); + } + + if manifest.state_commitment != Self::get_upgrade_state_commitment(env.clone()) { + return Err(Error::UpgradeCompatibilityInvalid); + } + Ok(()) + } + + /// Submit the differential compatibility evidence for a pending upgrade. + /// The manifest is resumable off-chain: a later submission replaces the + /// same hash's report, while execution accepts only a complete report with + /// no records requiring manual intervention. + pub fn submit_upgrade_compatibility_manifest( + env: Env, + wasm_hash: BytesN<32>, + manifest: UpgradeCompatibilityManifest, + ) -> Result<(), Error> { + let admin = Self::get_admin(&env)?; + admin.require_auth(); + Self::validate_upgrade_hash(&env, &wasm_hash)?; + if manifest.source_version != Self::get_version(env.clone()) { + return Err(Error::UpgradeCompatibilityInvalid); + } + env.storage().persistent().set( + &DataKey::UpgradeCompatibilityManifest(wasm_hash.clone()), + &manifest, + ); + Self::extend_persistent( + &env, + &DataKey::UpgradeCompatibilityManifest(wasm_hash), + ); + Ok(()) + } + + /// Read the compatibility evidence associated with a proposed WASM hash. + pub fn get_upgrade_compatibility_manifest( + env: Env, + wasm_hash: BytesN<32>, + ) -> Option { + env.storage() + .persistent() + .get(&DataKey::UpgradeCompatibilityManifest(wasm_hash)) + } + /// Upgrade the contract's WASM code after the grace period has elapsed. /// /// The caller passes the `expected_wasm_hash` they think is pending; if it @@ -4243,6 +4402,13 @@ impl CraftNexusContract { return Err(Error::UpgradeCooldownActive); } + let manifest: UpgradeCompatibilityManifest = env + .storage() + .persistent() + .get(&DataKey::UpgradeCompatibilityManifest(proposal.wasm_hash.clone())) + .ok_or(Error::UpgradeCompatibilityMissing)?; + Self::validate_compatibility_manifest(&env, &manifest)?; + env.deployer() .update_current_contract_wasm(proposal.wasm_hash.clone()); @@ -4269,11 +4435,25 @@ impl CraftNexusContract { timestamp: env.ledger().timestamp(), }, ); + Self::append_upgrade_compatibility_history( + &env, + UpgradeCompatibilityRecord { + from_version: current_version, + to_version: new_version, + wasm_hash: proposal.wasm_hash.clone(), + state_commitment: manifest.state_commitment, + migration_checkpoint: manifest.migration_checkpoint, + timestamp: env.ledger().timestamp(), + }, + ); // Clear proposal env.storage() .persistent() .remove(&DataKey::WasmUpgradeProposal); + env.storage().persistent().remove(&DataKey::UpgradeCompatibilityManifest( + proposal.wasm_hash.clone(), + )); Self::emit_upgrade_event( &env, @@ -4370,6 +4550,22 @@ impl CraftNexusContract { Self::extend_persistent(env, &DataKey::UpgradeHistory); } + fn append_upgrade_compatibility_history(env: &Env, record: UpgradeCompatibilityRecord) { + let mut history: Vec = env + .storage() + .persistent() + .get(&DataKey::UpgradeCompatibilityHistory) + .unwrap_or_else(|| Vec::new(env)); + history.push_back(record); + while history.len() > MAX_UPGRADE_HISTORY { + history.pop_front(); + } + env.storage() + .persistent() + .set(&DataKey::UpgradeCompatibilityHistory, &history); + Self::extend_persistent(env, &DataKey::UpgradeCompatibilityHistory); + } + /// Returns the bounded log of past contract upgrades (#241). /// /// Newer entries are at the back. The log is capped at @@ -4383,6 +4579,14 @@ impl CraftNexusContract { .unwrap_or_else(|| Vec::new(&env)) } + /// Returns compatibility evidence for completed upgrades. + pub fn get_upgrade_compatibility_history(env: Env) -> Vec { + env.storage() + .persistent() + .get(&DataKey::UpgradeCompatibilityHistory) + .unwrap_or_else(|| Vec::new(&env)) + } + /// Returns aggregate version + last-upgrade metadata (#241). Pairs the /// scalar `ContractVersion` with the most recent `UpgradeRecord` so a /// dashboard or migration script can read everything in one call. diff --git a/craft-nexus-contract/src/test.rs b/craft-nexus-contract/src/test.rs index 0dd24eb5..39f55649 100644 --- a/craft-nexus-contract/src/test.rs +++ b/craft-nexus-contract/src/test.rs @@ -1779,6 +1779,72 @@ fn test_contract_upgrade_unauthorized() { client.execute_upgrade(&dummy_hash); } +#[test] +fn test_upgrade_requires_compatibility_manifest() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _, _, _, _, _, admin) = setup_test(&env, true); + let wasm = Bytes::from_array(&env, &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]); + let wasm_hash = env.deployer().upload_contract_wasm(wasm); + + client.propose_upgrade_wasm(&admin, &wasm_hash); + env.ledger().with_mut(|ledger| { + ledger.timestamp += DEFAULT_WASM_UPGRADE_COOLDOWN as u64 + 1; + }); + + let result = client.try_execute_upgrade(&wasm_hash); + assert!(matches!(result, Err(Ok(Error::UpgradeCompatibilityMissing)))); +} + +#[test] +fn test_upgrade_manifest_is_recorded_and_consumed() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _, _, _, _, _, admin) = setup_test(&env, true); + let wasm = Bytes::from_array(&env, &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]); + let wasm_hash = env.deployer().upload_contract_wasm(wasm); + let commitment = client.get_upgrade_state_commitment(); + let nonzero = BytesN::from_array(&env, &[1u8; 32]); + let manifest = UpgradeCompatibilityManifest { + source_version: 1, + target_version: 2, + state_commitment: commitment.clone(), + interface_commitment: nonzero.clone(), + authorization_commitment: nonzero.clone(), + preconditions_commitment: nonzero.clone(), + postconditions_commitment: nonzero.clone(), + rollback_limitations_commitment: nonzero.clone(), + migration_checkpoint: nonzero, + migration_complete: true, + manual_records: 0, + }; + + client.propose_upgrade_wasm(&admin, &wasm_hash); + client.submit_upgrade_compatibility_manifest(&wasm_hash, &manifest); + assert_eq!( + client + .get_upgrade_compatibility_manifest(&wasm_hash) + .unwrap(), + manifest + ); + + env.ledger().with_mut(|ledger| { + ledger.timestamp += DEFAULT_WASM_UPGRADE_COOLDOWN as u64 + 1; + }); + client.execute_upgrade(&wasm_hash); + + let record = client + .get_upgrade_compatibility_history() + .last() + .unwrap(); + assert_eq!(record.from_version, 1); + assert_eq!(record.to_version, 2); + assert_eq!(record.state_commitment, commitment); + assert!(client + .get_upgrade_compatibility_manifest(&wasm_hash) + .is_none()); +} + #[test] fn test_get_version_initially() { let env = Env::default();