diff --git a/contracts/teachlink/src/errors.rs b/contracts/teachlink/src/errors.rs index 8adb6d6..7e5fa67 100644 --- a/contracts/teachlink/src/errors.rs +++ b/contracts/teachlink/src/errors.rs @@ -55,6 +55,8 @@ pub enum BridgeError { RetryLimitExceeded = 140, RetryBackoffActive = 141, BridgeTransactionFailed = 142, + IncompatibleInterfaceVersion = 143, + InvalidInterfaceVersionRange = 144, } /// Escrow module errors @@ -67,7 +69,6 @@ pub enum EscrowError { RefundTimeMustBeInFuture = 203, RefundTimeMustBeAfterReleaseTime = 204, DuplicateSigner = 205, - DuplicateSigners = 205, // Alias for consistency SignerNotAuthorized = 206, SignerAlreadyApproved = 207, CallerNotAuthorized = 208, diff --git a/contracts/teachlink/src/interface_versioning.rs b/contracts/teachlink/src/interface_versioning.rs new file mode 100644 index 0000000..6da5c97 --- /dev/null +++ b/contracts/teachlink/src/interface_versioning.rs @@ -0,0 +1,147 @@ +use crate::errors::BridgeError; +use crate::storage::{ADMIN, INTERFACE_VERSION, MIN_COMPAT_INTERFACE_VERSION}; +use crate::types::{ContractSemVer, InterfaceVersionStatus}; +use soroban_sdk::{Address, Env}; + +pub const DEFAULT_INTERFACE_VERSION: ContractSemVer = ContractSemVer::new(1, 0, 0); +pub const DEFAULT_MIN_COMPAT_INTERFACE_VERSION: ContractSemVer = ContractSemVer::new(1, 0, 0); + +pub struct InterfaceVersioning; + +impl InterfaceVersioning { + pub fn initialize(env: &Env) { + if !env.storage().instance().has(&INTERFACE_VERSION) { + env.storage() + .instance() + .set(&INTERFACE_VERSION, &DEFAULT_INTERFACE_VERSION); + } + + if !env.storage().instance().has(&MIN_COMPAT_INTERFACE_VERSION) { + env.storage().instance().set( + &MIN_COMPAT_INTERFACE_VERSION, + &DEFAULT_MIN_COMPAT_INTERFACE_VERSION, + ); + } + } + + pub fn get_interface_version(env: &Env) -> ContractSemVer { + env.storage() + .instance() + .get(&INTERFACE_VERSION) + .unwrap_or(DEFAULT_INTERFACE_VERSION) + } + + pub fn get_minimum_compatible_interface_version(env: &Env) -> ContractSemVer { + env.storage() + .instance() + .get(&MIN_COMPAT_INTERFACE_VERSION) + .unwrap_or(DEFAULT_MIN_COMPAT_INTERFACE_VERSION) + } + + pub fn get_interface_version_status(env: &Env) -> InterfaceVersionStatus { + InterfaceVersionStatus { + current: Self::get_interface_version(env), + minimum_compatible: Self::get_minimum_compatible_interface_version(env), + } + } + + pub fn set_interface_versions( + env: &Env, + current: ContractSemVer, + minimum_compatible: ContractSemVer, + ) -> Result<(), BridgeError> { + Self::require_admin_auth(env); + Self::validate_range(¤t, &minimum_compatible)?; + + env.storage().instance().set(&INTERFACE_VERSION, ¤t); + env.storage() + .instance() + .set(&MIN_COMPAT_INTERFACE_VERSION, &minimum_compatible); + + Ok(()) + } + + #[must_use] + pub fn is_interface_compatible(env: &Env, client_version: ContractSemVer) -> bool { + let status = Self::get_interface_version_status(env); + + client_version.major == status.current.major + && !client_version.is_lower_than(&status.minimum_compatible) + && !client_version.is_greater_than(&status.current) + } + + pub fn assert_interface_compatible( + env: &Env, + client_version: ContractSemVer, + ) -> Result<(), BridgeError> { + if Self::is_interface_compatible(env, client_version) { + Ok(()) + } else { + Err(BridgeError::IncompatibleInterfaceVersion) + } + } + + fn require_admin_auth(env: &Env) { + let admin: Address = env.storage().instance().get(&ADMIN).unwrap(); + admin.require_auth(); + } + + fn validate_range( + current: &ContractSemVer, + minimum_compatible: &ContractSemVer, + ) -> Result<(), BridgeError> { + if minimum_compatible.major != current.major { + return Err(BridgeError::InvalidInterfaceVersionRange); + } + + if minimum_compatible.is_greater_than(current) { + return Err(BridgeError::InvalidInterfaceVersionRange); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{InterfaceVersioning, DEFAULT_INTERFACE_VERSION}; + use crate::types::ContractSemVer; + use crate::TeachLinkBridge; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::{Address, Env}; + + #[test] + fn compatibility_requires_same_major_and_supported_range() { + let env = Env::default(); + let contract_id = env.register(TeachLinkBridge, ()); + + let token_admin = Address::generate(&env); + let sac = env.register_stellar_asset_contract_v2(token_admin); + let token = sac.address(); + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + + let client = crate::TeachLinkBridgeClient::new(&env, &contract_id); + env.mock_all_auths(); + client.initialize(&token, &admin, &1, &fee_recipient); + + assert!(env.as_contract(&contract_id, || { + InterfaceVersioning::is_interface_compatible(&env, ContractSemVer::new(1, 0, 0)) + })); + assert!(!env.as_contract(&contract_id, || { + InterfaceVersioning::is_interface_compatible(&env, ContractSemVer::new(2, 0, 0)) + })); + } + + #[test] + fn defaults_are_available_before_explicit_initialization() { + let env = Env::default(); + let contract_id = env.register(TeachLinkBridge, ()); + + let version = env.as_contract(&contract_id, || { + InterfaceVersioning::get_interface_version(&env) + }); + + assert_eq!(version, DEFAULT_INTERFACE_VERSION); + } +} diff --git a/contracts/teachlink/src/lib.rs b/contracts/teachlink/src/lib.rs index c2fe749..a0c6136 100644 --- a/contracts/teachlink/src/lib.rs +++ b/contracts/teachlink/src/lib.rs @@ -103,6 +103,7 @@ mod escrow; mod escrow_analytics; mod events; mod insurance; +mod interface_versioning; // FUTURE: Implement governance module (tracked in TRACKING.md) // mod governance; // mod learning_paths; @@ -114,10 +115,7 @@ mod notification; mod notification_events_basic; // mod content_quality; mod backup; -// NOTE: notification_tests is temporarily excluded from the default lib test suite -// because it uses direct storage access patterns incompatible with the current -// Soroban SDK test runtime without additional env.as_contract wrappers. -// mod notification_tests; +mod notification_tests; mod notification_types; mod performance; pub mod property_based_tests; @@ -146,15 +144,15 @@ pub use types::{ AlertConditionType, AlertRule, ArbitratorProfile, AtomicSwap, AuditRecord, BackupManifest, BackupSchedule, BridgeMetrics, BridgeProposal, BridgeTransaction, CachedBridgeSummary, ChainConfig, ChainMetrics, ComplianceReport, ConsensusState, ContentMetadata, ContentToken, - ContentTokenParameters, ContentType, CrossChainMessage, CrossChainPacket, DashboardAnalytics, - DisputeOutcome, EmergencyState, Escrow, EscrowMetrics, EscrowParameters, EscrowRole, - EscrowSigner, EscrowStatus, LiquidityPool, MultiChainAsset, NotificationChannel, - NotificationContent, NotificationPreference, NotificationSchedule, NotificationTemplate, - NotificationTracking, OperationType, PacketStatus, ProposalStatus, ProvenanceRecord, - RecoveryRecord, ReportComment, ReportSchedule, ReportSnapshot, ReportTemplate, ReportType, - ReportUsage, RewardRate, RewardType, RtoTier, SlashingReason, SlashingRecord, SwapStatus, - TransferType, UserNotificationSettings, UserReputation, UserReward, ValidatorInfo, - ValidatorReward, ValidatorSignature, VisualizationDataPoint, + ContentTokenParameters, ContentType, ContractSemVer, CrossChainMessage, CrossChainPacket, + DashboardAnalytics, DisputeOutcome, EmergencyState, Escrow, EscrowMetrics, EscrowParameters, + EscrowRole, EscrowSigner, EscrowStatus, InterfaceVersionStatus, LiquidityPool, MultiChainAsset, + NotificationChannel, NotificationContent, NotificationPreference, NotificationSchedule, + NotificationTemplate, NotificationTracking, OperationType, PacketStatus, ProposalStatus, + ProvenanceRecord, RecoveryRecord, ReportComment, ReportSchedule, ReportSnapshot, + ReportTemplate, ReportType, ReportUsage, RewardRate, RewardType, RtoTier, SlashingReason, + SlashingRecord, SwapStatus, TransferType, UserNotificationSettings, UserReputation, UserReward, + ValidatorInfo, ValidatorReward, ValidatorSignature, VisualizationDataPoint, }; /// TeachLink main contract. @@ -174,7 +172,9 @@ impl TeachLinkBridge { min_validators: u32, fee_recipient: Address, ) -> Result<(), BridgeError> { - bridge::Bridge::initialize(&env, token, admin, min_validators, fee_recipient) + bridge::Bridge::initialize(&env, token, admin, min_validators, fee_recipient)?; + interface_versioning::InterfaceVersioning::initialize(&env); + Ok(()) } /// Bridge tokens out to another chain (lock/burn tokens on Stellar) @@ -253,6 +253,47 @@ impl TeachLinkBridge { // ========== View Functions ========== + /// Get full interface version status (current and minimum compatible version) + pub fn get_interface_version_status(env: Env) -> InterfaceVersionStatus { + interface_versioning::InterfaceVersioning::get_interface_version_status(&env) + } + + /// Get current interface semantic version + pub fn get_interface_version(env: Env) -> ContractSemVer { + interface_versioning::InterfaceVersioning::get_interface_version(&env) + } + + /// Get minimum supported interface semantic version + pub fn get_min_compat_interface_version(env: Env) -> ContractSemVer { + interface_versioning::InterfaceVersioning::get_minimum_compatible_interface_version(&env) + } + + /// Update current and minimum compatible interface versions (admin only) + pub fn set_interface_version( + env: Env, + current: ContractSemVer, + minimum_compatible: ContractSemVer, + ) -> Result<(), BridgeError> { + interface_versioning::InterfaceVersioning::set_interface_versions( + &env, + current, + minimum_compatible, + ) + } + + /// Validate whether a client interface version is compatible + pub fn is_interface_compatible(env: Env, client_version: ContractSemVer) -> bool { + interface_versioning::InterfaceVersioning::is_interface_compatible(&env, client_version) + } + + /// Assert interface compatibility and return an explicit error if incompatible + pub fn assert_interface_compatible( + env: Env, + client_version: ContractSemVer, + ) -> Result<(), BridgeError> { + interface_versioning::InterfaceVersioning::assert_interface_compatible(&env, client_version) + } + /// 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/storage.rs b/contracts/teachlink/src/storage.rs index db408c0..44a74b1 100644 --- a/contracts/teachlink/src/storage.rs +++ b/contracts/teachlink/src/storage.rs @@ -14,6 +14,8 @@ pub const BRIDGE_FEE: Symbol = symbol_short!("bridgefee"); pub const BRIDGE_RETRY_COUNTS: Symbol = symbol_short!("br_rtryc"); pub const BRIDGE_LAST_RETRY: Symbol = symbol_short!("br_lstry"); pub const BRIDGE_FAILURES: Symbol = symbol_short!("br_fails"); +pub const INTERFACE_VERSION: Symbol = symbol_short!("if_ver"); +pub const MIN_COMPAT_INTERFACE_VERSION: Symbol = symbol_short!("if_minv"); // ========== Advanced Bridge Storage Keys ========== diff --git a/contracts/teachlink/src/types.rs b/contracts/teachlink/src/types.rs index 812560a..3f9e13c 100644 --- a/contracts/teachlink/src/types.rs +++ b/contracts/teachlink/src/types.rs @@ -7,6 +7,44 @@ use soroban_sdk::{contracttype, panic_with_error, Address, Bytes, Map, String, S // Include notification types pub use crate::notification_types::*; +// ========== Interface Versioning Types ========== + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractSemVer { + pub major: u32, + pub minor: u32, + pub patch: u32, +} + +impl ContractSemVer { + #[must_use] + pub const fn new(major: u32, minor: u32, patch: u32) -> Self { + Self { + major, + minor, + patch, + } + } + + #[must_use] + pub fn is_lower_than(&self, other: &Self) -> bool { + (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch) + } + + #[must_use] + pub fn is_greater_than(&self, other: &Self) -> bool { + (self.major, self.minor, self.patch) > (other.major, other.minor, other.patch) + } +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterfaceVersionStatus { + pub current: ContractSemVer, + pub minimum_compatible: ContractSemVer, +} + // ========== Chain Configuration Types ========== #[contracttype] diff --git a/contracts/teachlink/tests/test_interface_versioning.rs b/contracts/teachlink/tests/test_interface_versioning.rs new file mode 100644 index 0000000..9563f5d --- /dev/null +++ b/contracts/teachlink/tests/test_interface_versioning.rs @@ -0,0 +1,66 @@ +#![allow(clippy::needless_pass_by_value)] + +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, Env}; +use teachlink_contract::{ContractSemVer, TeachLinkBridge, TeachLinkBridgeClient}; + +fn setup_client(env: &Env) -> (TeachLinkBridgeClient<'_>, Address, Address, Address) { + let contract_id = env.register(TeachLinkBridge, ()); + let client = TeachLinkBridgeClient::new(env, &contract_id); + + let token_admin = Address::generate(env); + let sac = env.register_stellar_asset_contract_v2(token_admin); + let token = sac.address(); + + let admin = Address::generate(env); + let fee_recipient = Address::generate(env); + + env.mock_all_auths(); + client.initialize(&token, &admin, &1, &fee_recipient); + + (client, token, admin, fee_recipient) +} + +#[test] +fn interface_version_defaults_follow_semver() { + let env = Env::default(); + let (client, _, _, _) = setup_client(&env); + + let status = client.get_interface_version_status(); + + assert_eq!(status.current, ContractSemVer::new(1, 0, 0)); + assert_eq!(status.minimum_compatible, ContractSemVer::new(1, 0, 0)); + assert!(client.is_interface_compatible(&ContractSemVer::new(1, 0, 0))); + assert!(!client.is_interface_compatible(&ContractSemVer::new(2, 0, 0))); +} + +#[test] +fn interface_version_range_enforces_compatibility_window() { + let env = Env::default(); + let (client, _, _, _) = setup_client(&env); + + env.mock_all_auths(); + client.set_interface_version(&ContractSemVer::new(1, 3, 0), &ContractSemVer::new(1, 1, 0)); + + assert!(!client.is_interface_compatible(&ContractSemVer::new(1, 0, 9))); + assert!(client.is_interface_compatible(&ContractSemVer::new(1, 1, 0))); + assert!(client.is_interface_compatible(&ContractSemVer::new(1, 2, 5))); + assert!(client.is_interface_compatible(&ContractSemVer::new(1, 3, 0))); + assert!(!client.is_interface_compatible(&ContractSemVer::new(1, 3, 1))); + assert!(!client.is_interface_compatible(&ContractSemVer::new(2, 0, 0))); +} + +#[test] +fn interface_version_rejects_invalid_ranges() { + let env = Env::default(); + let (client, _, _, _) = setup_client(&env); + + env.mock_all_auths(); + let invalid_major = client + .try_set_interface_version(&ContractSemVer::new(2, 0, 0), &ContractSemVer::new(1, 9, 9)); + assert!(invalid_major.is_err()); + + let invalid_order = client + .try_set_interface_version(&ContractSemVer::new(1, 1, 0), &ContractSemVer::new(1, 2, 0)); + assert!(invalid_order.is_err()); +} diff --git a/docs/versions/CONTRACT_INTERFACE_VERSIONING.md b/docs/versions/CONTRACT_INTERFACE_VERSIONING.md new file mode 100644 index 0000000..7a8ec6c --- /dev/null +++ b/docs/versions/CONTRACT_INTERFACE_VERSIONING.md @@ -0,0 +1,75 @@ +# Contract Interface Versioning Strategy + +This document defines the TeachLink contract interface versioning system for compatibility management. + +## Scope + +This policy covers public contract entry points exposed by `TeachLinkBridge` and consumed by off-chain clients, SDKs, and indexers. + +## Semantic Versioning Model + +TeachLink uses semantic versioning (`MAJOR.MINOR.PATCH`) for interface compatibility: + +- `MAJOR`: breaking interface changes. +- `MINOR`: backward-compatible interface additions. +- `PATCH`: backward-compatible fixes and clarifications. + +Contract version values are represented on-chain as: + +- `current`: the latest contract interface version. +- `minimum_compatible`: the oldest client interface version supported by the deployed contract. + +## Compatibility Rules + +A client version is compatible only when all conditions hold: + +1. `client.major == current.major` +2. `client >= minimum_compatible` +3. `client <= current` + +If any condition fails, the contract reports incompatibility. + +## On-Chain Interface Methods + +The contract exposes explicit interface version controls: + +- `get_interface_version_status()` +- `get_interface_version()` +- `get_min_compat_interface_version()` +- `is_interface_compatible(client_version)` +- `assert_interface_compatible(client_version)` +- `set_interface_version(current, minimum_compatible)` (admin only) + +## Upgrade Policy + +When deploying interface changes: + +1. Decide version bump type (`MAJOR`, `MINOR`, or `PATCH`). +2. Set `current` to the new semantic version. +3. Set `minimum_compatible` to the oldest client version guaranteed to work. +4. Keep `minimum_compatible.major == current.major`. +5. Ensure `minimum_compatible <= current`. +6. Run compatibility tests before release. + +## Backward Compatibility Guarantees + +- Minor and patch releases must not break clients between `minimum_compatible` and `current`. +- Breaking changes require a major bump and corresponding client migration guidance. +- Contracts must reject impossible compatibility windows (for example, mismatched major versions). + +## Operational Guidance + +- SDKs should query `get_interface_version_status()` at startup. +- Clients should fail fast when outside the compatible window. +- Indexers should track interface versions per deployment and chain. + +## Testing Requirements + +Versioning tests must cover: + +- Default version state after initialization. +- Positive compatibility at lower/upper boundary versions. +- Rejection of below-minimum versions. +- Rejection of higher-than-current versions. +- Rejection of different major versions. +- Rejection of invalid admin updates (`minimum_compatible > current` or major mismatch). diff --git a/docs/versions/README.md b/docs/versions/README.md index 462e2ec..b441d53 100644 --- a/docs/versions/README.md +++ b/docs/versions/README.md @@ -86,3 +86,4 @@ See [CHANGELOG.md](./CHANGELOG.md) for detailed version history. --- *For API versioning, see [API_REFERENCE.md](../API_REFERENCE.md)* +*For on-chain interface compatibility policy, see [CONTRACT_INTERFACE_VERSIONING.md](./CONTRACT_INTERFACE_VERSIONING.md)*