Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion contracts/teachlink/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub enum BridgeError {
RetryLimitExceeded = 140,
RetryBackoffActive = 141,
BridgeTransactionFailed = 142,
IncompatibleInterfaceVersion = 143,
InvalidInterfaceVersionRange = 144,
}

/// Escrow module errors
Expand All @@ -67,7 +69,6 @@ pub enum EscrowError {
RefundTimeMustBeInFuture = 203,
RefundTimeMustBeAfterReleaseTime = 204,
DuplicateSigner = 205,
DuplicateSigners = 205, // Alias for consistency
SignerNotAuthorized = 206,
SignerAlreadyApproved = 207,
CallerNotAuthorized = 208,
Expand Down
147 changes: 147 additions & 0 deletions contracts/teachlink/src/interface_versioning.rs
Original file line number Diff line number Diff line change
@@ -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(&current, &minimum_compatible)?;

env.storage().instance().set(&INTERFACE_VERSION, &current);
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);
}
}
69 changes: 55 additions & 14 deletions contracts/teachlink/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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<BridgeTransaction> {
bridge::Bridge::get_bridge_transaction(&env, nonce)
Expand Down
2 changes: 2 additions & 0 deletions contracts/teachlink/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==========

Expand Down
38 changes: 38 additions & 0 deletions contracts/teachlink/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
66 changes: 66 additions & 0 deletions contracts/teachlink/tests/test_interface_versioning.rs
Original file line number Diff line number Diff line change
@@ -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());
}
Loading
Loading