diff --git a/src/lib.rs b/src/lib.rs index 40fadc0d..22dc7451 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,6 +78,14 @@ pub enum RevoraError { SignerKeyNotRegistered = 29, /// Cross-contract token transfer failed. TransferFailed = 30, + /// Contract is already at the target version; no migration needed. + AlreadyAtTargetVersion = 31, + /// Target version is lower than the current deployed version. + MigrationDowngradeNotAllowed = 32, + /// Admin rotation failed: new admin cannot be the same as current. + AdminRotationSameAddress = 33, + /// Admin rotation failed: another rotation is already pending. + AdminRotationPending = 34, } // ── Event symbols ──────────────────────────────────────────── @@ -5138,6 +5146,87 @@ impl RevoraRevenueShare { (amount * fee_bps).checked_div(BPS_DENOMINATOR).unwrap_or(0) } + /// Migrate the contract state to the current CONTRACT_VERSION. + /// + /// This function is intended to be called by the admin after a WASM upgrade. + /// It executes versioned migration hooks sequentially from the last recorded version + /// to the current `CONTRACT_VERSION`. + /// + /// Security properties: + /// - Only the current admin can call this. + /// - Respects the contract freeze state. + /// - Prevents downgrades or re-running the same migration. + /// - Updates `DataKey::DeployedVersion` only on success. + pub fn migrate(env: Env) -> Result { + Self::require_not_frozen(&env)?; + + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(RevoraError::NotInitialized)?; + + admin.require_auth(); + + let stored_version = env + .storage() + .persistent() + .get(&DataKey::DeployedVersion) + .unwrap_or(0u32); + + if stored_version == CONTRACT_VERSION { + return Err(RevoraError::AlreadyAtTargetVersion); + } + + if stored_version > CONTRACT_VERSION { + return Err(RevoraError::MigrationDowngradeNotAllowed); + } + + // Run migration hooks sequentially + for version in (stored_version + 1)..=CONTRACT_VERSION { + Self::run_migration_hook(&env, version)?; + } + + env.storage().persistent().set(&DataKey::DeployedVersion, &CONTRACT_VERSION); + + env.events().publish( + (symbol_short!("migrated"), admin), + (stored_version, CONTRACT_VERSION), + ); + + Ok(CONTRACT_VERSION) + } + + /// Internal helper to run migration logic for a specific version bump. + fn run_migration_hook(env: &Env, version: u32) -> Result<(), RevoraError> { + match version { + 1 => { + // Initial version setup if needed (usually handled by initialize) + } + 2 => { + // Example v2 migration logic + } + 3 => { + // Example v3 migration logic + } + 4 => { + // Example v4 migration logic + } + _ => { + // Future versions will be handled here + } + } + Ok(()) + } + + /// Return the current deployed version of the contract state. + pub fn get_deployed_version(env: Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::DeployedVersion) + .unwrap_or(0) + } + /// Return the current contract version (#23). Used for upgrade compatibility and migration. pub fn get_version(env: Env) -> u32 { let _ = env; diff --git a/src/test.rs b/src/test.rs index cf6f5362..c89c45f4 100644 --- a/src/test.rs +++ b/src/test.rs @@ -8259,6 +8259,75 @@ mod regression { assert_eq!(client.get_version(), v0); } + // --------------------------------------------------------------------------- + // Versioned Migration Hooks (#44) + // --------------------------------------------------------------------------- + + #[test] + fn test_migrate_success() { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &cid); + let admin = Address::generate(&env); + client.initialize(&admin, &None, &None); + + // Initial deployed version should be 0 (since we didn't update initialize) + assert_eq!(client.get_deployed_version(), 0); + + // Migrate to current CONTRACT_VERSION + let result = client.migrate(); + assert_eq!(result, crate::CONTRACT_VERSION); + assert_eq!(client.get_deployed_version(), crate::CONTRACT_VERSION); + } + + #[test] + #[should_panic(expected = "HostError: Error(Contract, #19)")] + fn test_migrate_unauthorized() { + let env = Env::default(); + let cid = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &cid); + let admin = Address::generate(&env); + client.initialize(&admin, &None, &None); + + // Call migrate without auth (will fail because mock_all_auths is not set) + client.migrate(); + } + + #[test] + fn test_migrate_already_at_target() { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &cid); + let admin = Address::generate(&env); + client.initialize(&admin, &None, &None); + + client.migrate(); + assert_eq!(client.get_deployed_version(), crate::CONTRACT_VERSION); + + // Try to migrate again + let result = client.try_migrate(); + assert_eq!(result, Err(Ok(RevoraError::AlreadyAtTargetVersion))); + } + + #[test] + fn test_migrate_respects_freeze() { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &cid); + let admin = Address::generate(&env); + client.initialize(&admin, &None, &None); + + // Freeze contract + client.set_frozen(&true); + + // Try to migrate + let result = client.try_migrate(); + assert_eq!(result, Err(Ok(RevoraError::ContractFrozen))); + } + // --------------------------------------------------------------------------- // Input parameter validation (#35) // ---------------------------------------------------------------------------