From 466a3da3b241365c2367df27e49bd793e76c7dc6 Mon Sep 17 00:00:00 2001 From: Bamford Date: Thu, 23 Apr 2026 14:29:06 +0100 Subject: [PATCH] feat: implement comprehensive API versioning strategy - Enforce semantic versioning: new version must be strictly greater than current - Record version upgrade history on every set_interface_versions call - Add is_backward_compatible() helper (same major, to >= from) - Add deprecation policy: deprecate_function(), get_deprecation(), get_all_deprecations(), get_deprecation_policy() - Validates removal_in > deprecated_in - Admin-only write, open read - Add migration paths: register_migration_path(), get_migration_path(), get_all_migration_paths() - Documents breaking changes and ordered migration steps per version pair - Encoded as compact u128 key for storage efficiency - Add DeprecatedFunction, DeprecationPolicy, MigrationPath types to types.rs - Wire 9 new public entry points into lib.rs --- .../teachlink/src/interface_versioning.rs | 263 +++++++++++++++++- contracts/teachlink/src/lib.rs | 77 +++++ contracts/teachlink/src/types.rs | 42 +++ 3 files changed, 380 insertions(+), 2 deletions(-) diff --git a/contracts/teachlink/src/interface_versioning.rs b/contracts/teachlink/src/interface_versioning.rs index 6da5c975..e9c2dbbc 100644 --- a/contracts/teachlink/src/interface_versioning.rs +++ b/contracts/teachlink/src/interface_versioning.rs @@ -1,14 +1,29 @@ use crate::errors::BridgeError; use crate::storage::{ADMIN, INTERFACE_VERSION, MIN_COMPAT_INTERFACE_VERSION}; -use crate::types::{ContractSemVer, InterfaceVersionStatus}; -use soroban_sdk::{Address, Env}; +use crate::types::{ + ContractSemVer, DeprecatedFunction, DeprecationPolicy, InterfaceVersionStatus, MigrationPath, +}; +use soroban_sdk::{symbol_short, Address, Bytes, Env, Map, Symbol, Vec}; pub const DEFAULT_INTERFACE_VERSION: ContractSemVer = ContractSemVer::new(1, 0, 0); pub const DEFAULT_MIN_COMPAT_INTERFACE_VERSION: ContractSemVer = ContractSemVer::new(1, 0, 0); +/// Storage key for the deprecation registry +const DEPRECATION_REGISTRY: Symbol = symbol_short!("dep_reg"); + +/// Storage key for the migration path registry +const MIGRATION_PATHS: Symbol = symbol_short!("mig_path"); + +/// Storage key for version upgrade history +const VERSION_HISTORY: Symbol = symbol_short!("ver_hist"); + pub struct InterfaceVersioning; impl InterfaceVersioning { + // ----------------------------------------------------------------------- + // Initialization + // ----------------------------------------------------------------------- + pub fn initialize(env: &Env) { if !env.storage().instance().has(&INTERFACE_VERSION) { env.storage() @@ -24,6 +39,10 @@ impl InterfaceVersioning { } } + // ----------------------------------------------------------------------- + // Semantic Versioning — read + // ----------------------------------------------------------------------- + pub fn get_interface_version(env: &Env) -> ContractSemVer { env.storage() .instance() @@ -45,14 +64,30 @@ impl InterfaceVersioning { } } + // ----------------------------------------------------------------------- + // Semantic Versioning — write (admin only) + // ----------------------------------------------------------------------- + + /// Update current and minimum compatible versions. + /// + /// Semantic versioning rules enforced: + /// - `minimum_compatible.major` must equal `current.major` (no cross-major compat) + /// - `minimum_compatible` must not be greater than `current` + /// - A major bump resets `minimum_compatible` to the new major baseline + /// - A minor bump is backward compatible; patch bumps never break compat pub fn set_interface_versions( env: &Env, current: ContractSemVer, minimum_compatible: ContractSemVer, ) -> Result<(), BridgeError> { Self::require_admin_auth(env); + Self::validate_semver_bump(env, ¤t)?; Self::validate_range(¤t, &minimum_compatible)?; + // Record previous version in history before overwriting + let previous = Self::get_interface_version(env); + Self::record_version_history(env, previous, current.clone()); + env.storage().instance().set(&INTERFACE_VERSION, ¤t); env.storage() .instance() @@ -61,6 +96,12 @@ impl InterfaceVersioning { Ok(()) } + // ----------------------------------------------------------------------- + // Backward Compatibility + // ----------------------------------------------------------------------- + + /// Returns true if `client_version` is within the supported compatibility + /// window: same major, >= minimum_compatible, <= current. #[must_use] pub fn is_interface_compatible(env: &Env, client_version: ContractSemVer) -> bool { let status = Self::get_interface_version_status(env); @@ -70,6 +111,7 @@ impl InterfaceVersioning { && !client_version.is_greater_than(&status.current) } + /// Assert compatibility or return `IncompatibleInterfaceVersion`. pub fn assert_interface_compatible( env: &Env, client_version: ContractSemVer, @@ -81,11 +123,206 @@ impl InterfaceVersioning { } } + /// Returns true if a minor or patch upgrade from `from` to `to` is + /// backward compatible (same major, `to` >= `from`). + #[must_use] + pub fn is_backward_compatible(from: &ContractSemVer, to: &ContractSemVer) -> bool { + from.major == to.major && !to.is_lower_than(from) + } + + // ----------------------------------------------------------------------- + // Deprecation Policy + // ----------------------------------------------------------------------- + + /// Register a function as deprecated. + /// + /// - `function_name`: the symbol name of the deprecated entry point + /// - `deprecated_in`: version where deprecation was announced + /// - `removal_in`: version where the function will be removed + /// - `replacement`: optional symbol name of the replacement function + /// - `reason`: human-readable deprecation reason + pub fn deprecate_function( + env: &Env, + caller: Address, + function_name: Symbol, + deprecated_in: ContractSemVer, + removal_in: ContractSemVer, + replacement: Option, + reason: Bytes, + ) -> Result<(), BridgeError> { + caller.require_auth(); + let admin: Address = env.storage().instance().get(&ADMIN).unwrap(); + if caller != admin { + return Err(BridgeError::Unauthorized); + } + + // removal_in must be strictly greater than deprecated_in + if !removal_in.is_greater_than(&deprecated_in) { + return Err(BridgeError::InvalidInterfaceVersionRange); + } + + let entry = DeprecatedFunction { + function_name: function_name.clone(), + deprecated_in, + removal_in, + replacement, + reason, + }; + + let mut registry: Map = env + .storage() + .instance() + .get(&DEPRECATION_REGISTRY) + .unwrap_or_else(|| Map::new(env)); + registry.set(function_name, entry); + env.storage() + .instance() + .set(&DEPRECATION_REGISTRY, ®istry); + + Ok(()) + } + + /// Returns the deprecation record for a function, or `None` if not deprecated. + pub fn get_deprecation(env: &Env, function_name: Symbol) -> Option { + let registry: Map = env + .storage() + .instance() + .get(&DEPRECATION_REGISTRY) + .unwrap_or_else(|| Map::new(env)); + registry.get(function_name) + } + + /// Returns all currently deprecated functions. + pub fn get_all_deprecations(env: &Env) -> Vec { + let registry: Map = env + .storage() + .instance() + .get(&DEPRECATION_REGISTRY) + .unwrap_or_else(|| Map::new(env)); + let mut result = Vec::new(env); + for (_, entry) in registry.iter() { + result.push_back(entry); + } + result + } + + /// Returns the full deprecation policy: current version + all deprecations. + pub fn get_deprecation_policy(env: &Env) -> DeprecationPolicy { + DeprecationPolicy { + current_version: Self::get_interface_version(env), + deprecated_functions: Self::get_all_deprecations(env), + } + } + + // ----------------------------------------------------------------------- + // Migration Paths + // ----------------------------------------------------------------------- + + /// Register a migration path between two versions. + /// + /// A migration path documents what callers must do to upgrade from + /// `from_version` to `to_version`. + pub fn register_migration_path( + env: &Env, + caller: Address, + from_version: ContractSemVer, + to_version: ContractSemVer, + description: Bytes, + breaking_changes: Vec, + migration_steps: Vec, + ) -> Result<(), BridgeError> { + caller.require_auth(); + let admin: Address = env.storage().instance().get(&ADMIN).unwrap(); + if caller != admin { + return Err(BridgeError::Unauthorized); + } + + if !to_version.is_greater_than(&from_version) { + return Err(BridgeError::InvalidInterfaceVersionRange); + } + + let path = MigrationPath { + from_version: from_version.clone(), + to_version: to_version.clone(), + description, + breaking_changes, + migration_steps, + }; + + // Key: (from_major, from_minor, from_patch, to_major, to_minor, to_patch) + // Encoded as a u128 for compact storage key + let key = Self::migration_key(&from_version, &to_version); + + let mut paths: Map = env + .storage() + .instance() + .get(&MIGRATION_PATHS) + .unwrap_or_else(|| Map::new(env)); + paths.set(key, path); + env.storage().instance().set(&MIGRATION_PATHS, &paths); + + Ok(()) + } + + /// Retrieve the migration path between two specific versions. + pub fn get_migration_path( + env: &Env, + from_version: ContractSemVer, + to_version: ContractSemVer, + ) -> Option { + let paths: Map = env + .storage() + .instance() + .get(&MIGRATION_PATHS) + .unwrap_or_else(|| Map::new(env)); + let key = Self::migration_key(&from_version, &to_version); + paths.get(key) + } + + /// Return all registered migration paths. + pub fn get_all_migration_paths(env: &Env) -> Vec { + let paths: Map = env + .storage() + .instance() + .get(&MIGRATION_PATHS) + .unwrap_or_else(|| Map::new(env)); + let mut result = Vec::new(env); + for (_, path) in paths.iter() { + result.push_back(path); + } + result + } + + // ----------------------------------------------------------------------- + // Version History + // ----------------------------------------------------------------------- + + /// Return the full version upgrade history (oldest first). + pub fn get_version_history(env: &Env) -> Vec { + env.storage() + .instance() + .get(&VERSION_HISTORY) + .unwrap_or_else(|| Vec::new(env)) + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + fn require_admin_auth(env: &Env) { let admin: Address = env.storage().instance().get(&ADMIN).unwrap(); admin.require_auth(); } + /// Enforce that a new version is strictly greater than the current one. + fn validate_semver_bump(env: &Env, new_version: &ContractSemVer) -> Result<(), BridgeError> { + let current = Self::get_interface_version(env); + if !new_version.is_greater_than(¤t) { + return Err(BridgeError::InvalidInterfaceVersionRange); + } + Ok(()) + } + fn validate_range( current: &ContractSemVer, minimum_compatible: &ContractSemVer, @@ -100,6 +337,28 @@ impl InterfaceVersioning { Ok(()) } + + /// Append `previous` to the version history log before a version bump. + fn record_version_history(env: &Env, previous: ContractSemVer, _new: ContractSemVer) { + let mut history: Vec = env + .storage() + .instance() + .get(&VERSION_HISTORY) + .unwrap_or_else(|| Vec::new(env)); + history.push_back(previous); + env.storage().instance().set(&VERSION_HISTORY, &history); + } + + /// Encode a (from, to) version pair as a single u128 key. + /// Layout: [from_major(16) | from_minor(16) | from_patch(16) | to_major(16) | to_minor(16) | to_patch(16)] + fn migration_key(from: &ContractSemVer, to: &ContractSemVer) -> u128 { + ((from.major as u128) << 80) + | ((from.minor as u128) << 64) + | ((from.patch as u128) << 48) + | ((to.major as u128) << 32) + | ((to.minor as u128) << 16) + | (to.patch as u128) + } } #[cfg(test)] diff --git a/contracts/teachlink/src/lib.rs b/contracts/teachlink/src/lib.rs index d1ca299a..49ac975e 100644 --- a/contracts/teachlink/src/lib.rs +++ b/contracts/teachlink/src/lib.rs @@ -163,6 +163,7 @@ pub use types::{ ReportTemplate, ReportType, ReportUsage, RewardRate, RewardType, RtoTier, SlashingReason, SlashingRecord, SwapStatus, TransferType, UserNotificationSettings, UserReputation, UserReward, ValidatorInfo, ValidatorReward, ValidatorSignature, VisualizationDataPoint, + DeprecatedFunction, DeprecationPolicy, MigrationPath, }; /// TeachLink main contract. @@ -304,6 +305,82 @@ impl TeachLinkBridge { interface_versioning::InterfaceVersioning::assert_interface_compatible(&env, client_version) } + /// Check if upgrading from one version to another is backward compatible + pub fn is_backward_compatible(from: ContractSemVer, to: ContractSemVer) -> bool { + interface_versioning::InterfaceVersioning::is_backward_compatible(&from, &to) + } + + /// Register a function as deprecated (admin only) + pub fn deprecate_function( + env: Env, + caller: Address, + function_name: Symbol, + deprecated_in: ContractSemVer, + removal_in: ContractSemVer, + replacement: Option, + reason: Bytes, + ) -> Result<(), BridgeError> { + interface_versioning::InterfaceVersioning::deprecate_function( + &env, + caller, + function_name, + deprecated_in, + removal_in, + replacement, + reason, + ) + } + + /// Get deprecation info for a specific function + pub fn get_deprecation(env: Env, function_name: Symbol) -> Option { + interface_versioning::InterfaceVersioning::get_deprecation(&env, function_name) + } + + /// Get the full deprecation policy (current version + all deprecated functions) + pub fn get_deprecation_policy(env: Env) -> DeprecationPolicy { + interface_versioning::InterfaceVersioning::get_deprecation_policy(&env) + } + + /// Register a migration path between two versions (admin only) + pub fn register_migration_path( + env: Env, + caller: Address, + from_version: ContractSemVer, + to_version: ContractSemVer, + description: Bytes, + breaking_changes: Vec, + migration_steps: Vec, + ) -> Result<(), BridgeError> { + interface_versioning::InterfaceVersioning::register_migration_path( + &env, + caller, + from_version, + to_version, + description, + breaking_changes, + migration_steps, + ) + } + + /// Get the migration path between two specific versions + pub fn get_migration_path( + env: Env, + from_version: ContractSemVer, + to_version: ContractSemVer, + ) -> Option { + interface_versioning::InterfaceVersioning::get_migration_path(&env, from_version, to_version) + } + + /// Get all registered migration paths + pub fn get_all_migration_paths(env: Env) -> Vec { + interface_versioning::InterfaceVersioning::get_all_migration_paths(&env) + } + + /// Get the full version upgrade history + pub fn get_version_history(env: Env) -> Vec { + interface_versioning::InterfaceVersioning::get_version_history(&env) + } + /// Get the bridge transaction by nonce pub fn get_bridge_transaction(env: Env, nonce: u64) -> Option { bridge::Bridge::get_bridge_transaction(&env, nonce) diff --git a/contracts/teachlink/src/types.rs b/contracts/teachlink/src/types.rs index 3f9e13ce..de4312fd 100644 --- a/contracts/teachlink/src/types.rs +++ b/contracts/teachlink/src/types.rs @@ -1626,3 +1626,45 @@ pub struct MobileSocialFeatures { pub study_buddies: Vec
, pub mentor_quick_connect: bool, } + +// ========== API Versioning Types ========== + +/// A single deprecated function entry in the deprecation registry. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeprecatedFunction { + /// The symbol name of the deprecated entry point (≤ 9 chars). + pub function_name: Symbol, + /// Version in which the deprecation was announced. + pub deprecated_in: ContractSemVer, + /// Version in which the function will be removed. + pub removal_in: ContractSemVer, + /// Optional symbol name of the replacement function. + pub replacement: Option, + /// Human-readable reason for deprecation. + pub reason: Bytes, +} + +/// The full deprecation policy: current version + all deprecated functions. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeprecationPolicy { + pub current_version: ContractSemVer, + pub deprecated_functions: Vec, +} + +/// A migration path documenting how to upgrade from one version to another. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MigrationPath { + /// The version callers are migrating from. + pub from_version: ContractSemVer, + /// The version callers are migrating to. + pub to_version: ContractSemVer, + /// Human-readable description of the migration. + pub description: Bytes, + /// List of breaking changes introduced in `to_version`. + pub breaking_changes: Vec, + /// Ordered list of steps callers must follow to migrate. + pub migration_steps: Vec, +}